OutpayDocs
Webhooks

Verify webhook signatures

Verify raw-body HMAC signatures before processing an event.

Who this is for

Developers implementing the merchant-side webhook receiver for checkout.paid events.

Before you begin

  • Your webhook signing secret (whsec_...), shown once when you save the endpoint in the Dashboard's Developers page.
  • A route capable of reading the raw, unparsed request body — see the warning below.

The signature is computed over the exact bytes Outpay sent:

HMAC_SHA256(webhook_secret, `${X-Outpay-Timestamp}.${raw_request_body}`)

Compare the resulting lowercase hexadecimal digest to the value after v1= using a timing-safe comparison. Reject stale timestamps and duplicate delivery IDs in your own handler — Outpay does not enforce a freshness window server-side beyond including the timestamp in the signed content.

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyOutpayWebhook(
  rawBody: string,
  timestamp: string,
  signature: string,
  secret: string,
): boolean {
  const expected = `v1=${createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex")}`;
  const received = Buffer.from(signature);
  const calculated = Buffer.from(expected);
  return received.length === calculated.length && timingSafeEqual(received, calculated);
}
export async function POST(request: Request) {
  const rawBody = await request.text();
  const timestamp = request.headers.get("X-Outpay-Timestamp") ?? "";
  const signature = request.headers.get("X-Outpay-Signature") ?? "";
  const deliveryId = request.headers.get("X-Outpay-Delivery-ID") ?? "";

  if (!verifyOutpayWebhook(rawBody, timestamp, signature, process.env.OUTPAY_WEBHOOK_SECRET!)) {
    return Response.json({ error: "invalid signature" }, { status: 401 });
  }

  const event = JSON.parse(rawBody) as { event?: string; checkout_ref?: string };
  if (event.event === "checkout.paid") {
    // Look up deliveryId before this point and make fulfilment idempotent.
  }
  return new Response(null, { status: 204 });
}

Read the body once

In frameworks that consume the request stream, capture the raw text before parsing JSON. Re-serializing a parsed object can change whitespace or key ordering and will produce a different signature — always verify against the exact bytes received.

Expected result

An invalid or missing signature returns 401 without processing the event. A valid, previously unseen checkout.paid event triggers your fulfilment logic exactly once; a redelivered copy of the same event is recognized and skipped.

Common problems

ProblemLikely causeNext step
Every signature fails verificationThe framework parsed the body as JSON before your handler could read the raw bytes.Read the raw request body first — see the Next.js example above.
The same order is fulfilled twiceThe handler does not deduplicate on checkout_ref or X-Outpay-Delivery-ID.Persist a processed-event record and check it before fulfilling — see receive and process webhooks.
Verification passes but the event is staleNo timestamp-freshness check is implemented locally.Reject signatures whose X-Outpay-Timestamp is older than your own tolerance window.

On this page