# Verifying signatures (/en/integrate/webhooks/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 [#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 [#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 [#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.

<Tabs items="[&#x22;Node.js&#x22;, &#x22;Python&#x22;]">
  <Tab value="Node.js">
    ```typescript
    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) };
    }
    ```
  </Tab>

  <Tab value="Python">
    ```python
    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)
    ```
  </Tab>
</Tabs>

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 [#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:

<Tabs items="[&#x22;Express&#x22;, &#x22;Fastify&#x22;, &#x22;Next.js (App Router)&#x22;]">
  <Tab value="Express">
    ```typescript
    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();
    });
    ```
  </Tab>

  <Tab value="Fastify">
    ```typescript
    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();
    });
    ```
  </Tab>

  <Tab value="Next.js (App Router)">
    ```typescript
    // 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 });
    }
    ```
  </Tab>
</Tabs>

## What to do when verification fails [#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](/integrate/webhooks/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 [#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. This is the webhook side of idempotency; for the `Idempotency-Key` you send on writes, see [Idempotency](/integrate/idempotency).

Use the top-level `event.id` as the dedup key:

```typescript
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 [#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.

```typescript
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 [#where-to-next]

<Cards>
  <Card icon="<Cable />" title="Setting up an endpoint" href="/integrate/webhooks/setting-up-an-endpoint">
    Create the endpoint and grab the `whsec_*` secret you'll verify with.
  </Card>

  <Card icon="<RefreshCw />" title="Replaying and retries" href="/integrate/webhooks/replaying-and-retries">
    The retry schedule when your handler returns non-2xx, and how to manually replay an event from
    the dashboard.
  </Card>

  <Card icon="<Terminal />" title="Local development" href="/integrate/webhooks/local-development">
    Tunnel webhook deliveries to your laptop while you build the handler.
  </Card>

  <Card icon="<Boxes />" title="Event reference" href="/integrate/webhooks/event-reference">
    Every event you can subscribe to, when it fires, and the object it carries.
  </Card>

  <Card icon="<Network />" title="Object model" href="/integrate/object-model">
    What each event's `data.object` contains, and the IDs to keep on your order record.
  </Card>
</Cards>