Signature scheme
HMAC-SHA256 signing over the raw request body. Verification samples for Node, Python, Ruby, Go, and PHP.
Every webhook is signed with HMAC-SHA256 using the merchant's webhook_secret.
Where to find your secret#
The secret is shown once when the merchant is created (next to the API key). After that, the value is never displayed again — store it in your secret manager at creation time. If you lose it, open the merchant's Signing secret tab in the dashboard and click Rotate; the new value is shown once and the old one stops signing immediately. See Dashboard → Merchants for the UI.
Rotation also affects pending and retried deliveries. Signatures are computed at delivery time, using whatever value is currently stored — not the value that was active when the event was first queued. After you rotate, any event that has not yet been delivered (or that you retry from the dashboard) will be signed with the new secret. Update your receiver before clicking Rotate.
Contract#
- Algorithm: HMAC-SHA256.
- Key: the merchant's
webhook_secret(exactly as issued — no decoding). - Message: the raw HTTP request body, verbatim. Do not re-serialise the parsed JSON before verifying.
- Header:
X-Webhook-Signature: sha256=<lowercase-hex>.
The timestamp and delivery ID are also supplied:
X-Webhook-Timestamp— Unix epoch seconds.X-Webhook-ID— UUID v4 per delivery attempt, stable across retries.
Verification samples#
Node.js#
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyWebhook(rawBody, header, secret) {
const [algo, signature] = (header ?? "").split("=");
if (algo !== "sha256" || !signature) return false;
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(signature, "hex");
const b = Buffer.from(expected, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}
Python#
import hmac
import hashlib
def verify_webhook(raw_body: bytes, header: str, secret: str) -> bool:
prefix, _, signature = (header or "").partition("=")
if prefix != "sha256" or not signature:
return False
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)
Ruby#
require "openssl"
def verify_webhook(raw_body, header, secret)
prefix, signature = (header || "").split("=", 2)
return false unless prefix == "sha256" && signature
expected = OpenSSL::HMAC.hexdigest("sha256", secret, raw_body)
# Rack uses a constant-time comparison under the hood.
OpenSSL.fixed_length_secure_compare(signature, expected)
rescue ArgumentError
false
end
Go#
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strings"
)
func Verify(rawBody []byte, header, secret string) bool {
parts := strings.SplitN(header, "=", 2)
if len(parts) != 2 || parts[0] != "sha256" {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(parts[1]), []byte(expected))
}
PHP#
<?php
function verify_webhook(string $rawBody, string $header, string $secret): bool {
if (strncmp($header, "sha256=", 7) !== 0) {
return false;
}
$signature = substr($header, 7);
$expected = hash_hmac("sha256", $rawBody, $secret);
return hash_equals($signature, $expected);
}
Shared test vector#
You can verify your implementation against this canonical fixture. If your code produces the expected signature on this input, you are compatible with the platform's dispatcher.
{
"payload": "{\"invoice_id\":\"00000000-0000-0000-0000-000000000001\",\"status\":\"confirmed\"}",
"secret": "whsec_test_deterministic_value",
"expected_signature": "sha256=54f49ad4b8d32a4d9d61a7c1bdb1cc28b1c7dca9beff51a1e9fb8b3bf0e812d3"
}
Note: the signature value above is illustrative — the canonical test vector is published at
content/docs/_fixtures/signature.jsonalongside a smoke suite that cross-validates all five language samples.
Common mistakes#
- Parsing the JSON before computing HMAC. Your framework may auto-parse the body; most give you a raw-body escape hatch. Use it. Re-serialising via
JSON.stringify(body)reorders keys and changes whitespace, which guarantees the signature fails. - String comparison with
==. Use a constant-time comparator (timingSafeEqual,hmac.compare_digest,hash_equals,hmac.Equal). Timing attacks against HMAC verification are real. - Trusting
X-Webhook-Eventwithout verifying the signature. An unverified request may still have all the headers set. Always verify before you read anything. - Accepting arbitrarily old timestamps. Optionally reject deliveries older than a few minutes using
X-Webhook-Timestampto mitigate replay. The platform retries on-demand, not automatically, so a strict 5-minute window is usually safe.