Skip to main content

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.

Prerequisites

Verification requires the merchant's callback secret. See Webhooks Overview — Secret for issuance and storage.

Algorithm

FieldValue
HashHMAC-SHA256
KeyPer-merchant callback secret (base64 string — decode to bytes before use)
Signed input${webhook-id}.${webhook-timestamp}.${raw_body} (. is a literal)
Encodingbase64
Header formatv1,<base64> (single pair today; we recommend writing your code to handle multiple space-separated pairs for forward compatibility)

Verification steps

  1. Extract the webhook-id and webhook-timestamp header values.
  2. Preserve the request body as raw bytes (do not re-serialize after JSON parsing).
  3. Base64-decode the callback secret to obtain the HMAC key. If decoding fails, treat it as a configuration error and stop.
  4. Apply HMAC-SHA256 to the string ${id}.${timestamp}.${body} with the decoded key → base64-encode → call this expected.
  5. Split the webhook-signature header on whitespace. For each pair (v1,<base64>), compare with expected in constant time. If any pair matches, accept.
  6. If no pair matches, do not process the body and return 401.
Preserving the raw body is mandatory

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.

FieldRecommended
Tolerance±300s (5 minutes)
Comparisonabs(now - webhook_timestamp) ≤ 300
On failureRespond 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-signature header 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)

  1. Request a new secret from CROSS Pay; the new secret becomes active.
  2. 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).
  3. Request CROSS Pay to revoke the old secret.
  4. Remove the old secret from the merchant side.

Next steps