Skip to main content
Every webhook delivery from Crypto Checkout includes an HMAC-SHA256 signature so you can confirm that the request genuinely came from your Crypto Checkout instance and has not been tampered with in transit. Skipping signature verification leaves your endpoint open to spoofed requests from anyone who knows your URL.

Delivery headers

Two headers are sent with every POST request to your endpoint:
HeaderFormatDescription
X-Webhook-Signaturet=<unix_timestamp>,v1=<hex_hmac>The primary signature header. Contains both the timestamp and the HMAC digest.
X-Webhook-Timestamp<unix_timestamp>The same timestamp, provided as a standalone header for convenience.

How the signature is constructed

Crypto Checkout computes the signature using the following steps:
  1. Take the Unix timestamp (seconds since epoch) for the delivery attempt.
  2. Concatenate the timestamp, a literal . character, and the raw request body string: ${timestamp}.${rawBody}.
  3. Compute HMAC-SHA256 of that string using your endpoint’s secret as the key.
  4. Hex-encode the result and set the header to t=${timestamp},v1=${hexDigest}.
This Stripe-style signed-string format means the timestamp is bound to the signature, which lets you reject replayed deliveries that arrive outside the tolerance window.
Always compute the signature over the raw body bytes, before calling JSON.parse. Normalising the JSON (re-serialising, adding spaces, sorting keys) will produce a different string and cause verification to fail.

Tolerance window

Reject any delivery where the absolute difference between the t timestamp and the current server time exceeds 300 seconds (5 minutes). This prevents an attacker from replaying a legitimately signed payload captured from an earlier delivery.
|now_seconds - t| > 300  →  reject
The verifyWebhook function from the SDK handles header parsing, timestamp validation, HMAC computation, and timing-safe comparison in one call. It returns the parsed, typed event object on success and throws a WebhookVerificationError on any failure.
import { verifyWebhook } from "@your-org/checkout-sdk/webhooks";

// Express.js — must use express.raw() so req.body is the raw Buffer
app.post(
  "/webhooks/crypto",
  express.raw({ type: "application/json" }),
  (req, res) => {
    let event;
    try {
      event = verifyWebhook({
        payload: req.body.toString(),
        signature: req.headers["x-webhook-signature"],
        timestamp: req.headers["x-webhook-timestamp"],
        secret: process.env.CHECKOUT_WEBHOOK_SECRET!,
      });
    } catch (err) {
      console.error("Webhook verification failed:", err.message);
      return res.sendStatus(400);
    }

    // Handle the verified event
    if (event.type === "session.paid") {
      fulfillOrder(event.data.metadata.orderId);
    }

    res.sendStatus(200);
  }
);
When using Express, register express.raw({ type: "application/json" }) before any global express.json() middleware on this route. The express.json() middleware consumes and discards the raw body — once that happens, signature verification will always fail.

VerifyOptions reference

OptionTypeRequiredDescription
payloadstringThe raw request body string, before JSON.parse
signaturestring | undefined | nullValue of the X-Webhook-Signature header
timestampstring | number | nullValue of X-Webhook-Timestamp; used as fallback if t= is absent from the signature header
secretstringYour endpoint’s webhook secret (whsec_…)
toleranceSecondsnumberReplay window in seconds; defaults to 300
nowSecondsnumberOverride the current time (seconds since epoch) used for tolerance checking; useful in tests

WebhookVerificationError messages

The SDK throws a WebhookVerificationError (subclass of Error) with one of these messages:
MessageCause
missing signature headerX-Webhook-Signature header was absent or empty
malformed signature headerHeader value could not be parsed — missing t= or v1= component
timestamp outside tolerance windowDelivery is stale or the server clock is skewed by more than 5 minutes
signature mismatchHMAC digest did not match — payload may have been tampered with
payload is not valid JSONSignature checked out but the body is not parseable JSON

Manual verification (Node.js)

If you are not using the SDK, you can reproduce the same logic with Node’s built-in crypto module:
const crypto = require("crypto");

function verifySignature(rawBody, signatureHeader, secret) {
  const [tPart, v1Part] = signatureHeader.split(",");
  const ts = tPart.split("=")[1];
  const v1 = v1Part.split("=")[1];

  // Check timestamp tolerance (5 minutes)
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
    throw new Error("Stale webhook — timestamp outside tolerance window");
  }

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${ts}.${rawBody}`)
    .digest("hex");

  if (!crypto.timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(v1, "hex"))) {
    throw new Error("Invalid signature");
  }
}
Always use crypto.timingSafeEqual (or an equivalent constant-time comparison) instead of === when comparing HMAC values. A standard string comparison leaks timing information that can be exploited to forge signatures.

Framework-specific notes

Next.js App Router route handlers receive a Request object. Read the body as text once before passing it to verifyWebhook:
// app/api/webhooks/crypto/route.ts
import { verifyWebhook } from "@your-org/checkout-sdk/webhooks";
import { NextRequest, NextResponse } from "next/server";

export async function POST(req: NextRequest) {
  const rawBody = await req.text();

  let event;
  try {
    event = verifyWebhook({
      payload: rawBody,
      signature: req.headers.get("x-webhook-signature"),
      secret: process.env.CHECKOUT_WEBHOOK_SECRET!,
    });
  } catch (err) {
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }

  if (event.type === "session.paid") {
    // fulfil order...
  }

  return NextResponse.json({ received: true });
}

Finding and rotating your webhook secret

Your webhook secret is displayed once — immediately after you create the endpoint in the dashboard or via the API. It is stored only as a prefix (secretPrefix) in the endpoint object for identification; the full value is never retrievable after creation. If you lose your secret:
  1. Delete the existing endpoint (DELETE /api/v1/webhook_endpoints/{id}).
  2. Create a new endpoint with the same URL and event subscriptions.
  3. Copy the new secret from the create response and update your environment variable.
Any in-flight delivery attempts to the old endpoint will be abandoned when it is deleted. Make sure to update your CHECKOUT_WEBHOOK_SECRET environment variable and redeploy your application before incoming events resume.

Webhooks Overview

Learn how to create and manage webhook endpoints and understand the delivery lifecycle

TypeScript SDK

Full SDK reference including the verifyWebhook function and all exported types