Merchant API

Webhooks

Outbound merchant webhooks. Closed event-type union, signed payloads, 5-minute replay window, retry with exponential backoff.

Webhooks deliver state changes to your endpoint over HTTP POST. They are the production-grade alternative to polling payment and payout intent status. Subscribe in the dashboard; one URL per environment.

Event types

The event field is a closed union. Switch over it; every case is covered. The same list, including example payloads, is served at GET /webhook_events.

EventFires when
payment_intent.succeededThe payment intent succeeded. Funds are credited.
payment_intent.failedThe payment intent reached a terminal failure.
payment_intent.expiredThe payment intent expired without payment.
payout_intent.succeededThe payout intent settled at the provider.
payout_intent.failedThe payout intent failed after dispatch.
payout_intent.cancelledThe payout intent was cancelled or rejected before dispatch.

Envelope

Every delivery has the same top-level shape:

{
  "event": "payment_intent.succeeded",
  "payload": {
    "payment_intent_id": "dord_01HZXABC123",
    "status": "succeeded",
    "failure_code": null,
    "failure_message": null
  }
}

Payloads:

  • payment_intent.succeeded / .failed / .expiredpayment_intent_id, status, failure_code, failure_message.
  • payout_intent.*payout_intent_id, status, failure_code, failure_message.

Add new fields gracefully. Your verifier should not reject unknown keys.

Refund and dispute webhooks are outside the beta Merchant API. They are not emitted on the public webhook contract.

Signing

Every delivery carries a signed payload header:

webhook-signature: t=1715250000,v1=4d3a...

The canonical signed string is:

${t}.${rawBody}

Where t is the unix timestamp from the header and rawBody is the raw request body bytes. Compute HMAC-SHA256 with your endpoint's signing secret and compare the lowercase hex against v1 using a constant-time comparator.

Replay window

Reject deliveries whose t is more than 5 minutes from your server's clock. A captured payload replayed later must not be accepted.

Secret rotation

Each endpoint can carry two active secrets during rotation, so a deploy never races a secret swap.

Add a second secret

Add a second signing secret in the dashboard. Both are now valid.

Verify against either

Update your verifier to accept a signature matching either secret.

Roll the new secret out

Deploy the new secret through your producer or config.

Remove the old secret

Remove the previous secret in the dashboard once every instance verifies against the new one.

Retry policy

A delivery is successful when your endpoint returns 2xx within a reasonable timeout. Any other outcome triggers retries:

  • 6 attempts total including the first.
  • Exponential backoff with jitter, starting at 1 second.
  • After the last attempt the delivery is parked and surfaced in the dashboard.

Make your handler idempotent. The same (id, event) pair can arrive more than once if your endpoint is slow or flaky.

Verification snippet

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

type VerifyWebhookSignatureArgs = {
  header: string | null | undefined;
  rawBody: string;
  secret: string;
  toleranceSeconds?: number;
};

export function verifyWebhookSignature({
  header,
  rawBody,
  secret,
  toleranceSeconds = 300,
}: VerifyWebhookSignatureArgs): boolean {
  if (!header) return false;
  const parts: Record<string, string> = {};
  for (const segment of header.split(",")) {
    const eq = segment.indexOf("=");
    if (eq < 0) continue;
    const key = segment.slice(0, eq).trim();
    const value = segment.slice(eq + 1).trim();
    if (key) parts[key] = value;
  }
  const t = Number(parts.t);
  const v1 = parts.v1;
  if (!Number.isFinite(t) || !v1) return false;

  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - t) > toleranceSeconds) return false;

  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  let a: Buffer;
  let b: Buffer;
  try {
    a = Buffer.from(expected, "hex");
    b = Buffer.from(v1, "hex");
  } catch {
    return false;
  }
  if (a.length !== b.length) return false;
  return timingSafeEqual(a, b);
}

Verify against the raw request body bytes, not a parsed-and-re-stringified object. Re-serializing reorders keys and changes whitespace, so the HMAC will never match.

See Handle a webhook for an end-to-end Express example.

On this page