Verifying signatures
Confirm every webhook is really from XPay before you trust it. HMAC-SHA256 over the raw body, with a 5-minute replay window.
Anyone with your endpoint URL can POST to it. The signing secret is what tells you which POSTs are actually XPay. Every webhook XPay delivers carries an XPay-Signature header. Your handler recomputes the signature using the endpoint's whsec_* secret and rejects any request where the value doesn't match.
There is no XPay-shipped library for this today. The verification is small enough to live in your handler in about 20 lines.
The header
XPay sends one signature header on every delivery:
XPay-Signature: t=1730000000,v1=a1b2c3d4...| Field | Meaning |
|---|---|
t | Unix timestamp (seconds) at the moment XPay computed the signature. |
v1 | Hex-encoded HMAC-SHA256 of the timestamp, a ., and the raw body, signed with the endpoint's secret. |
Both fields are required. A malformed header, or a request with no header at all, should be rejected outright.
How XPay signs
XPay computes the signature as:
signedPayload = `${timestamp}.${rawRequestBody}`
signature = HMAC-SHA256(endpointSecret, signedPayload)Where:
timestampis the same value sent in thetfield.rawRequestBodyis the bytes of the JSON body XPay sent. It is not a normalized form. Re-serializing the parsed JSON on your side will not produce the same bytes.
Hex-encode the digest and compare it, in constant time, against the v1 value.
How to verify
The verification is five small steps:
- Read the raw body of the request as a string or
Buffer. Don't parse it as JSON yet. - Read the
XPay-Signatureheader and split it on,intot={...}andv1={...}. - Reject if
abs(now - t) > 300seconds. This is the replay window. - Compute
HMAC-SHA256(secret, "${t}.${rawBody}")and hex-encode the digest. - Constant-time compare the digest against the
v1value.
Only after all five pass should you JSON.parse(rawBody) and act on the event.
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verifyXPaySignature(
rawBody: string,
header: string | undefined,
secret: string,
): { valid: boolean; event?: unknown } {
if (!header) return { valid: false };
const parts = Object.fromEntries(
header.split(",").map((p) => {
const [k, ...rest] = p.split("=");
return [k, rest.join("=")];
}),
);
const timestamp = Number.parseInt(parts.t ?? "", 10);
const received = parts.v1;
if (!Number.isFinite(timestamp) || !received) return { valid: false };
// Replay protection
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > TOLERANCE_SECONDS) {
return { valid: false };
}
const computed = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(computed);
const b = Buffer.from(received);
if (a.length !== b.length) return { valid: false };
if (!crypto.timingSafeEqual(a, b)) return { valid: false };
return { valid: true, event: JSON.parse(rawBody) };
}import hmac, hashlib, time, json
TOLERANCE_SECONDS = 300
def verify_xpay_signature(raw_body: str, header: str, secret: str):
if not header:
return None
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
try:
timestamp = int(parts["t"])
except (KeyError, ValueError):
return None
received = parts.get("v1", "")
if not received:
return None
# Replay protection
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return None
computed = hmac.new(
secret.encode(),
f"{timestamp}.{raw_body}".encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(computed, received):
return None
return json.loads(raw_body)The function returns the parsed event when verification succeeds, and a not-valid signal otherwise. Always parse the JSON inside the verifier (after the signature check), not before.
Wiring the raw body in your framework
The single most common reason verification fails is that the framework already parsed the body to JSON and re-serialized it before your handler ran. A re-serialized body has different whitespace and key order from the bytes XPay signed, and the HMAC won't match.
A few framework recipes:
import express from "express";
const app = express();
// IMPORTANT: use express.raw on this exact route, before any global JSON parser.
app.post(
"/webhooks/xpay",
express.raw({ type: "application/json" }),
(req, res) => {
const rawBody = (req.body as Buffer).toString("utf8");
const result = verifyXPaySignature(
rawBody,
req.header("XPay-Signature"),
process.env.XPAY_WEBHOOK_SECRET!,
);
if (!result.valid) return res.status(400).send("invalid signature");
handleEvent(result.event);
res.status(200).send();
},
);import Fastify from "fastify";
const fastify = Fastify();
// Register a content-type parser that hands you the raw bytes.
fastify.addContentTypeParser(
"application/json",
{ parseAs: "string" },
(_req, body, done) => done(null, body),
);
fastify.post("/webhooks/xpay", async (req, reply) => {
const rawBody = req.body as string;
const result = verifyXPaySignature(
rawBody,
req.headers["xpay-signature"] as string,
process.env.XPAY_WEBHOOK_SECRET!,
);
if (!result.valid) return reply.code(400).send("invalid signature");
handleEvent(result.event);
return reply.code(200).send();
});// app/api/webhooks/xpay/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
const rawBody = await req.text(); // read once, before any .json()
const result = verifyXPaySignature(
rawBody,
req.headers.get("XPay-Signature") ?? undefined,
process.env.XPAY_WEBHOOK_SECRET!,
);
if (!result.valid) {
return new NextResponse("invalid signature", { status: 400 });
}
handleEvent(result.event);
return new NextResponse(null, { status: 200 });
}What to do when verification fails
A signature mismatch isn't always an attack. The same code path catches plenty of routine misconfigurations.
| Cause | Symptom |
|---|---|
| Framework parsed and re-serialized the body | Verification fails on every request. Most common cause. |
| Wrong endpoint secret | Verification fails on every request. Test vs live secret mixed up, or you're using a stale value from a deleted endpoint. |
| Endpoint deleted and recreated | The new endpoint has a new whsec_*. Update your env var. |
| Clock drift on your server | Replay-window rejection. Sync the server clock via NTP. |
| Header rewritten | t or v1 missing. A reverse proxy or ingress modified the header. Allow XPay-Signature through unmodified. |
When verification fails, return a non-2xx (400 is the right choice). XPay's delivery workflow treats that as a failed attempt and retries on the standard schedule. See Replaying and retries.
A handler that returns 200 on a bad signature is silently broken: you'll never notice signing changed, and an attacker who finds your URL can forge events.
Idempotency
The same event can be delivered more than once. XPay retries on every non-2xx and timeout, and you may also see duplicates if a successful response failed to make it back to XPay. Your handler must be idempotent.
Use the top-level event.id as the dedup key:
async function handleEvent(event: { id: string; type: string; data: unknown }) {
// Skip if we've seen this id before
const inserted = await db.processedEvents.insertIfNew(event.id);
if (!inserted) return;
switch (event.type) {
case "checkout.session.completed":
await fulfillOrder(event.data);
break;
// other cases
}
}The event.id is unique per event and stable across retries.
Acknowledge fast, work in the background
XPay considers a delivery successful only if your handler returns 2xx within 30 seconds. Anything slower triggers a retry, even if your work eventually finished.
The right shape is to verify, dedup, enqueue the event, and return 200 as fast as possible. Run fulfillment, email sending, downstream API calls, and database writes from a queue worker, not from inside the request handler.
const result = verifyXPaySignature(rawBody, header, secret);
if (!result.valid) return res.status(400).send();
await queue.add("xpay-event", result.event);
return res.status(200).send();Where to next
Setting up an endpoint
Create the endpoint and grab the whsec_* secret you'll verify with.
Replaying and retries
The retry schedule when your handler returns non-2xx, and how to manually replay an event from the dashboard.
Local development
Tunnel webhook deliveries to your laptop while you build the handler.
Event reference
Every event you can subscribe to, when it fires, and the object it carries.
Object model
What each event's data.object contains, and the IDs to keep on your order record.