Signature Verification
Verify that webhook payloads are genuinely from Xtopay using HMAC-SHA256.
Overview
Every webhook request Xtopay sends includes an X-Xtopay-Signature header. This is an HMAC-SHA256 signature of the raw request body, keyed with your Client Secret. Verifying this signature proves the payload is authentic and hasn't been tampered with.
Never process a webhook payload without first verifying its signature.
Signature format
X-Xtopay-Signature: sha256=a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1
X-Xtopay-Timestamp: 1716537600Verifying with the SDK
import Xtopay from "@xtopay/node";
const xtopay = new Xtopay({
clientId: process.env.XTOPAY_CLIENT_ID!,
clientSecret: process.env.XTOPAY_CLIENT_SECRET!,
});
export async function POST(req: Request) {
const body = await req.text(); // raw bytes — don't parse JSON first
const signature = req.headers.get("x-xtopay-signature")!;
let event;
try {
event = xtopay.webhooks.constructEvent(body, signature);
} catch (err) {
return new Response(`Webhook error: ${err.message}`, { status: 400 });
}
// Safe to use event.data here
console.log(event.type, event.data);
return new Response("ok");
}Verifying manually
If you're not using the SDK:
import crypto from "crypto";
function verifyWebhookSignature(
rawBody: string,
signatureHeader: string,
timestampHeader: string,
clientSecret: string,
): boolean {
// 1. Reject if timestamp is more than 5 minutes old
const timestamp = parseInt(timestampHeader, 10);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) return false;
// 2. Reconstruct the signed string
const signingString = `${timestampHeader}.${rawBody}`;
// 3. Compute expected signature
const expected = crypto
.createHmac("sha256", clientSecret)
.update(signingString)
.digest("hex");
// 4. Compare signatures (constant-time to prevent timing attacks)
const received = signatureHeader.replace("sha256=", "");
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(received, "hex"),
);
}Critical rules
- Use the raw body — parse the body as a raw string before JSON.parse. Parsing and re-serialising JSON can change whitespace, breaking the signature.
- Use timingSafeEqual — don't compare signatures with
===. String comparison is vulnerable to timing attacks. - Check the timestamp — reject events where
X-Xtopay-Timestampis more than 5 minutes old. This prevents replay attacks.
Rotating your Client Secret
Signatures are keyed to your Client Secret. When you rotate your secret, Xtopay sends webhooks signed with the new secret immediately. See Rotating credentials for the zero-downtime rotation process.
During the 15-minute transition window, Xtopay includes two signatures in the header so you can verify against both:
X-Xtopay-Signature: sha256=<old_sig>,sha256=<new_sig>Accept the webhook if either signature is valid.