docs
IntegrateWebhooks

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...
FieldMeaning
tUnix timestamp (seconds) at the moment XPay computed the signature.
v1Hex-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:

  • timestamp is the same value sent in the t field.
  • rawRequestBody is 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:

  1. Read the raw body of the request as a string or Buffer. Don't parse it as JSON yet.
  2. Read the XPay-Signature header and split it on , into t={...} and v1={...}.
  3. Reject if abs(now - t) > 300 seconds. This is the replay window.
  4. Compute HMAC-SHA256(secret, "${t}.${rawBody}") and hex-encode the digest.
  5. Constant-time compare the digest against the v1 value.

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.

CauseSymptom
Framework parsed and re-serialized the bodyVerification fails on every request. Most common cause.
Wrong endpoint secretVerification fails on every request. Test vs live secret mixed up, or you're using a stale value from a deleted endpoint.
Endpoint deleted and recreatedThe new endpoint has a new whsec_*. Update your env var.
Clock drift on your serverReplay-window rejection. Sync the server clock via NTP.
Header rewrittent 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

On this page