Verify webhook signatures
Check that a webhook really comes from ProcessOut and that nobody changed it on the way. Where to get your signing secret, how to check the signature, and how to rotate the secret without dropping deliveries.
Every webhook we send to your endpoint carries a ProcessOut-Signature header. It is an HMAC-SHA256 of the request body, computed with a secret that only you and ProcessOut know.
When the signature checks out, you know two things:
- the request was sent by ProcessOut, not by someone who found your webhook URL
- the body is exactly what we sent.
Only webhooks sent to your webhook endpoints are signed. Webhooks sent to a
custom URL set on an invoice (webhook_url) are not signed.
Signatures are being enabled project by project. If your deliveries do not carry the header yet, contact support to have it enabled for your project.
How it works
-
Each webhook endpoint has its own signing secret, for example
whsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8. -
For every delivery, we compute an HMAC-SHA256 over the raw request body using that secret and send the result, hex-encoded, in the
ProcessOut-Signatureheader. -
Your server computes the same value from the body it received and compares the two. If they match, the request is genuine.
Nothing else about the request changes. The body and your custom headers stay the same.
Demo values only
Demo values only. All secrets, signatures, event IDs and request bodies on this page are demo values for illustration and testing only. They are not real credentials. Do not copy them into real environments (test or live). Always use the signing secret generated for your own webhook endpoint.
Step 1: Get your signing secret
The secret is shown only once, right after it is created. We cannot show it again. Store it the
way you store your API keys, in a secrets manager or an environment variable. Never commit it or write it to logs.
From the Dashboard
- New endpoint: create it under Developers › Webhook endpoints. The signing secret is shown in a window right after the endpoint is created.
- Existing endpoint: open the endpoint's action menu and choose Generate signing secret (or Rotate signing secret if it already has one).
Endpoints created before signing was available
These endpoints have no secret. Their secrets list is empty and their deliveries are not signed.
To give one a secret, use Generate signing secret in the Dashboard, or call the
rotation endpoint once. It creates the first secret.
Test mode and live mode
Every endpoint has its own secret. Test-mode endpoints and live endpoints never share one, so keep a separate secret for each endpoint you receive webhooks on.
Step 2: Read the header
A normal delivery carries one signature:
ProcessOut-Signature: v1=2dad8a6f8903b53809e36e486ed36d5bc4b1beed4d9ac785478ae64023b58967Right after a secret rotation, it can carry one signature per valid secret:
ProcessOut-Signature: v1=2dad8a6f8903b53809e36e486ed36d5bc4b1beed4d9ac785478ae64023b58967, v1=c18b8b0aa2e7c48c0313d72b7a17b0c711233609abeaeabadb44e0c2e5a0b80bHow to read it:
- The header name is case-insensitive, like every HTTP header. It arrives as
Processout-Signature, and some tools show it asprocessout-signature. Look it up with your
framework's header accessor, not with a case-sensitive string match. - Entries are separated by commas. Trim the spaces around each one.
- Each entry is
scheme=value.v1means HMAC-SHA256, written as 64 lowercase hex characters. - Ignore any entry whose scheme is not
v1. We may add new schemes later, and your code should keep working when we do. - The order of the entries is not guaranteed. There are at most four.
- The request is genuine if any
v1entry matches any secret you hold. You do not need all of them to match.
Step 3: Verify the signature
For each secret you currently hold (usually one, two during a rotation):
- Take the raw request body, byte for byte, exactly as it arrived. Do this before any JSON
parsing. A body that has been parsed and serialised again will not match. - Remove the
whsec_prefix from the secret. Base64url-decode the rest (the URL-safe
alphabet, with no=padding). You get 32 bytes. This is the HMAC key. - Compute HMAC-SHA256 of the raw body with those 32 bytes as the key.
- Compare the result with each
v1value in the header. Use a constant-time comparison,
not==, so the comparison does not leak timing information.
If nothing matches, reply with 401 and do not process the event.
Answering 401 is safe. A rejected delivery is retried on the normal schedule for up to 72 hours, and every retry is signed again with the endpoint's current secrets. If the problem was a wrong secret in your configuration, fix it and the retries go through. See
Webhook delivery and retries.
The examples below take secrets as a list, so the same code keeps working during a rotation.
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"io"
"net/http"
"strings"
)
// VerifySignature reports whether header holds a valid v1 signature of body for any of secrets.
func VerifySignature(body []byte, header string, secrets []string) bool {
for _, secret := range secrets {
key, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
continue
}
mac := hmac.New(sha256.New, key)
mac.Write(body)
expected := mac.Sum(nil)
for _, entry := range strings.Split(header, ",") {
scheme, value, ok := strings.Cut(strings.TrimSpace(entry), "=")
if !ok || scheme != "v1" {
continue
}
received, err := hex.DecodeString(value)
if err == nil && hmac.Equal(received, expected) {
return true
}
}
}
return false
}
func HandleWebhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "cannot read body", http.StatusBadRequest)
return
}
if !VerifySignature(body, r.Header.Get("ProcessOut-Signature"), signingSecrets()) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusOK)
// Now parse body and fetch the event by its event_id.
}import base64
import hashlib
import hmac
from flask import Flask, abort, request
app = Flask(__name__)
def verify_signature(body: bytes, header: str, secrets: list[str]) -> bool:
for secret in secrets:
encoded = secret.removeprefix("whsec_")
key = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4))
expected = hmac.new(key, body, hashlib.sha256).hexdigest()
for entry in header.split(","):
scheme, _, value = entry.strip().partition("=")
if scheme == "v1" and hmac.compare_digest(value.encode(), expected.encode()):
return True
return False
@app.post("/webhooks/processout")
def processout_webhook():
body = request.get_data() # raw bytes: do not use request.json here
header = request.headers.get("ProcessOut-Signature", "")
if not verify_signature(body, header, SIGNING_SECRETS):
abort(401)
# Now parse body and fetch the event by its event_id.
return "", 200const crypto = require('crypto');
const express = require('express');
function verifySignature(rawBody, header, secrets) {
for (const secret of secrets) {
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64url');
const expected = crypto.createHmac('sha256', key).update(rawBody).digest();
for (const entry of (header || '').split(',')) {
const [scheme, value] = entry.trim().split('=');
if (scheme !== 'v1' || !value) continue;
const received = Buffer.from(value, 'hex');
if (received.length === expected.length && crypto.timingSafeEqual(received, expected)) {
return true;
}
}
}
return false;
}
const app = express();
// express.raw keeps the body as a Buffer. Register it on this route, before any JSON parser.
app.post('/webhooks/processout', express.raw({ type: '*/*' }), (req, res) => {
if (!verifySignature(req.body, req.get('ProcessOut-Signature'), signingSecrets)) {
return res.sendStatus(401);
}
res.sendStatus(200);
const { event_id } = JSON.parse(req.body);
// Now fetch the event by its event_id.
});<?php
function verifySignature(string $body, string $header, array $secrets): bool
{
foreach ($secrets as $secret) {
$key = base64_decode(strtr(preg_replace('/^whsec_/', '', $secret), '-_', '+/'));
$expected = hash_hmac('sha256', $body, $key);
foreach (explode(',', $header) as $entry) {
$parts = explode('=', trim($entry), 2);
if (count($parts) === 2 && $parts[0] === 'v1' && hash_equals($expected, $parts[1])) {
return true;
}
}
}
return false;
}
$body = file_get_contents('php://input'); // raw body: do not use json_decode output here
$header = $_SERVER['HTTP_PROCESSOUT_SIGNATURE'] ?? '';
if (!verifySignature($body, $header, $signingSecrets)) {
http_response_code(401);
exit;
}
http_response_code(200);
// Now parse $body and fetch the event by its event_id.require "base64"
require "openssl"
# OpenSSL.secure_compare needs Ruby 3.0 or later.
def verify_signature(body, header, secrets)
secrets.any? do |secret|
key = Base64.urlsafe_decode64(secret.delete_prefix("whsec_"))
expected = OpenSSL::HMAC.hexdigest("SHA256", key, body)
header.to_s.split(",").any? do |entry|
scheme, value = entry.strip.split("=", 2)
scheme == "v1" && !value.nil? && OpenSSL.secure_compare(value, expected)
end
end
end
# Sinatra
post "/webhooks/processout" do
body = request.body.read # raw body: do not parse it first
halt 401 unless verify_signature(body, request.env["HTTP_PROCESSOUT_SIGNATURE"], SIGNING_SECRETS)
# Now parse body and fetch the event by its event_id.
200
endCheck your code with a test vector
Run your verification function on these values. It must return true. These are demo values for testing your code only. Never use this secret as a real signing secret.
| Input | Value |
|---|---|
| Secret | whsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8 |
| Body | {"event_id":"ev_2mNXTMwfzXPPFZrzsMjiw1wPoZ9mGsBC","event_type":"transaction.captured"} |
| Header | v1=2dad8a6f8903b53809e36e486ed36d5bc4b1beed4d9ac785478ae64023b58967 |
The body has no spaces and no trailing newline. To check the rotation case, keep the same secret and body and use the two-signature header from Step 2. It must also
return true. It must return false if you change a single character of the body.
If you get a different hex value, check these first:
- You used the whole
whsec_…string as the key. The key is the 32 bytes you get after removingwhsec_and decoding the rest. - You decoded with standard base64 instead of base64url. The secret can contain
-and_. - Your framework parsed the JSON body and you serialised it again. Use the raw bytes.
Step 4: Rotate the secret
Rotate the secret on a regular schedule, when someone who knew it leaves your team, or at once if you think it has leaked. Rotating also gives you a new secret if you lost the old one.
In the Dashboard, choose Rotate signing secret in the endpoint's action menu.
What happens after a rotation
- The new secret is active immediately.
- The old secret stays valid for 30 minutes. Its
expires_atshows the exact time. - Until then, a delivery can be signed with the old secret only, or with both. After
expires_at, deliveries are signed with the new secret only.
So, to rotate without rejecting a single delivery:
- Rotate and save the new secret.
- Within 30 minutes, add the new secret to your configuration next to the old one. Your
code now accepts both. - After the old secret's
expires_athas passed, remove the old secret.
Do not replace the old secret with the new one in step 2. For a few minutes some deliveries are still signed with the old secret only, and you would reject them.
If you miss the 30 minutes, deliveries signed only with the new secret fail your check and you answer 401. Nothing is lost. They are retried for up to 72 hours, and they go through as soon as you add the new secret.
If the secret has leaked, remove the old secret from your configuration straight away and keep only the new one. Deliveries that are still signed only with the old secret will fail your check or up to 30 minutes and are then retried with the new signature.
Rotation limit
An endpoint can hold at most four secrets: one active and up to three that are expiring. In
practice this means at most three rotations in any 30 minutes. A fourth attempt returns 400 with the error type request.validation.secret-limit-exceeded. Wait until the oldest expiring secret passes its expires_at, then try again.
See which secrets an endpoint has
GET /webhook-endpoints/{id} and the Dashboard list the secrets of an endpoint. You see when each was created and when it expires, but never the secret itself. The active secret has no expires_at.
The ProcessOut-Signature header is reserved
ProcessOut-Signature header is reservedYou cannot add a custom header called ProcessOut-Signature (in any letter case) to a webhook endpoint. The request is rejected with 400 and the error type
request.validation.reserved-header-name.
What the signature does not protect
- Replays. The signature has no timestamp, so a copy of a genuine request stays valid. Someone who captured one could send it to you again. Two habits you should already have make this harmless: deduplicate on the event id, and fetch the event from our API before you act on it.
A replayed request then only makes you look up an event you already handled. Always use anhttps://URL for your endpoint, so requests cannot be captured in transit in the first place. - Headers and the URL. Only the body is signed. Your custom headers are sent as you configured them, but they are not part of the signature.
Quick reference
| Item | Value |
|---|---|
| Header | ProcessOut-Signature: v1=<hex>[, v1=<hex>…] |
| Algorithm | HMAC-SHA256, hex-encoded, lowercase |
| What is signed | the raw request body, byte for byte |
| Key | the secret without whsec_, base64url-decoded (unpadded), 32 bytes |
| Valid when | any v1 entry matches any secret you hold |
| Unknown schemes | ignore them |
| Secret shown | once, when it is created or rotated |
| Old secret after rotation | still valid for 30 minutes, until its expires_at |
| Secrets per endpoint | at most 4: 1 active and up to 3 expiring |
| Not signed | webhooks sent to a per-invoice webhook_url |
| Rotation endpoint | POST /webhook-endpoints/{id}/secret with an Idempotency-Key header |
| On a failed check | answer 401. The delivery is retried for up to 72 hours |
Frequently asked
My deliveries have no ProcessOut-Signature header. Why?
There are three possible reasons:
- Signing is not enabled for your project yet. Contact support.
- The endpoint was created before signing was available and has no secret. Generate one in the Dashboard or call the rotation endpoint.
- The webhook was sent to a per-invoice
webhook_url. These are never signed.
Should I reject a delivery that has no signature?
On a webhook endpoint that already receives signed deliveries, yes. In rare cases a delivery can go out unsigned because of an internal error on our side. If you reject it, it is retried like any other failed delivery.
Do not require a signature on a URL that you also use as a per-invoice webhook_url. Those
deliveries are never signed, so you would reject all of them. Use a separate URL for them, and check them by fetching the event from our API.
I lost my secret. Can you send it to me again?
No. We cannot show a secret a second time. Rotate the endpoint's secret to get a new one.
Can I choose my own secret?
No. Secrets are always generated by ProcessOut.
Does signing change the body or the delivery rules?
No. The body, the retry schedule and the five-second response limit stay the same.
Updated about 1 hour ago

