Xtopay Docs
Webhooks

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: 1716537600

Verifying 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-Timestamp is 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.

On this page