Verifying Webhooks
CROSS Pay webhook signatures follow the Standard Webhooks v1 specification. This page covers the signature verification algorithm, merchant-side verification code, and the recommended replay protection policy.
Verification requires the merchant's callback secret. See Webhooks Overview — Secret for issuance and storage.
Algorithm
| Field | Value |
|---|---|
| Hash | HMAC-SHA256 |
| Key | Per-merchant callback secret (base64 string — decode to bytes before use) |
| Signed input | ${webhook-id}.${webhook-timestamp}.${raw_body} (. is a literal) |
| Encoding | base64 |
| Header format | v1,<base64> (single pair today; we recommend writing your code to handle multiple space-separated pairs for forward compatibility) |
Verification steps
- Extract the
webhook-idandwebhook-timestampheader values. - Preserve the request body as raw bytes (do not re-serialize after JSON parsing).
- Base64-decode the callback secret to obtain the HMAC key. If decoding fails, treat it as a configuration error and stop.
- Apply HMAC-SHA256 to the string
${id}.${timestamp}.${body}with the decoded key → base64-encode → call thisexpected. - Split the
webhook-signatureheader on whitespace. For each pair (v1,<base64>), compare withexpectedin constant time. If any pair matches, accept. - If no pair matches, do not process the body and return 401.
If you re-serialize after JSON parsing, whitespace and key ordering change
and the signature breaks. In Express use
express.raw({ type: 'application/json' }); in Go use
io.ReadAll(r.Body) to keep the original bytes intact.
Node.js (Express)
import express from 'express';
import crypto from 'crypto';
const app = express();
const SECRET = process.env.CROSSPAY_WEBHOOK_SECRET;
// Preserve raw body
app.post(
'/webhooks/crosspay',
express.raw({ type: 'application/json' }),
(req, res) => {
const id = req.get('webhook-id');
const ts = req.get('webhook-timestamp');
const sigHeader = req.get('webhook-signature') || '';
const body = req.body; // Buffer
const secretBytes = Buffer.from(SECRET, 'base64'); // secret is base64-encoded
const signed = `${id}.${ts}.${body.toString('utf8')}`;
const expected = crypto
.createHmac('sha256', secretBytes)
.update(signed)
.digest();
const ok = sigHeader.split(' ').some((pair) => {
const [v, sig] = pair.split(',');
if (v !== 'v1' || !sig) return false;
const got = Buffer.from(sig, 'base64');
return (
got.length === expected.length &&
crypto.timingSafeEqual(got, expected)
);
});
if (!ok) return res.status(401).send('invalid signature');
// ... domain processing ...
res.status(200).send('ok');
}
);
Go (net/http)
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"io"
"net/http"
"os"
"strings"
)
var secret = os.Getenv("CROSSPAY_WEBHOOK_SECRET") // base64-encoded
func Handle(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("webhook-id")
ts := r.Header.Get("webhook-timestamp")
sigHeader := r.Header.Get("webhook-signature")
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read", http.StatusBadRequest)
return
}
secretBytes, err := base64.StdEncoding.DecodeString(secret)
if err != nil {
http.Error(w, "server misconfiguration", http.StatusInternalServerError)
return
}
mac := hmac.New(sha256.New, secretBytes)
mac.Write([]byte(id + "." + ts + "." + string(body)))
expected := mac.Sum(nil)
ok := false
for _, pair := range strings.Fields(sigHeader) {
v, sig, found := strings.Cut(pair, ",")
if !found || v != "v1" {
continue
}
got, err := base64.StdEncoding.DecodeString(sig)
if err != nil {
continue
}
if subtle.ConstantTimeCompare(got, expected) == 1 {
ok = true
break
}
}
if !ok {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
// ... domain processing ...
w.WriteHeader(http.StatusOK)
}
curl (debug)
To recompute the signature from a received payload and headers and compare it with the header value:
ID="0190a1b2-c3d4-7e5f-89ab-cdef01234567"
TS="1745236800"
SECRET="<base64-encoded callback secret>"
BODY="$(cat received_body.json)"
# The secret is base64-encoded: decode it first, then use the raw bytes
# (hex-encoded for openssl) as the HMAC key.
printf '%s.%s.%s' "$ID" "$TS" "$BODY" | \
openssl dgst -sha256 -mac HMAC \
-macopt hexkey:"$(printf '%s' "$SECRET" | base64 -d | xxd -p -c 256)" \
-binary | base64
# Compare the output with the value after 'v1,' in the webhook-signature header.
Replay protection
webhook-timestamp is the Unix epoch seconds at delivery time. Merchants
can compare it against the current time and reject requests that are too old.
| Field | Recommended |
|---|---|
| Tolerance | ±300s (5 minutes) |
| Comparison | abs(now - webhook_timestamp) ≤ 300 |
| On failure | Respond 401 or 400 and discard the body |
5 minutes is a practical value that absorbs clock skew, network latency, and short delivery queueing. Too short a window may reject legitimate requests; too long a window widens the attack surface for capture-and-replay.
Secret rotation
CROSS Pay tracks each secret's status (active / deprecated / revoked) so that secrets can be rotated without downtime.
Today's behavior
- Each webhook is signed with a single active secret.
- The
webhook-signatureheader therefore contains a single pair (v1,<base64>).
Merchant verification code
- The code samples above iterate over every pair in the header. If CROSS Pay later signs with multiple secrets at once for rollover, merchant code will continue to work without changes.
Recommended procedure when rotating secrets (assuming today's single-secret signing behavior)
- Request a new secret from CROSS Pay; the new secret becomes active.
- If possible, briefly accept both the new and the old secret in your merchant verification code (extend the iteration pattern above to an array of secrets).
- Request CROSS Pay to revoke the old secret.
- Remove the old secret from the merchant side.
Next steps
- Delivery & Retry — retry policy, idempotency, dedup
- Production Checklist — pre-production review items