Merchant API

Webhooks

Outbound Merchant webhooks with a versioned event catalogue, frozen signed bytes, retries, and manual resend.

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 and cover every case documented below.

FamilyEvents
Paymentpayment_intent.requires_payment_method, .requires_action, .processing, .succeeded, .failed, .expired, .cancelled
Payoutpayout_intent.requires_approval, .processing, .succeeded, .failed, .cancelled, .expired, .returned

Envelope

Every delivery has the same top-level shape:

{
  "version": 1,
  "id": "wevt_5276f0877bd1874da00b1ca4",
  "event": "payment_intent.succeeded",
  "environment": "test",
  "merchantTeamId": "team_merchant",
  "organizationId": "org_merchant",
  "createdAt": "2026-05-09T12:01:00.000Z",
  "transitionSequence": 3,
  "data": {
    "id": "dord_01HZXABC123",
    "status": "succeeded"
  }
}

data is the resource projection frozen when the effective transition committed. transitionSequence is the immutable Payment or Payout transition sequence and is null for events without one. Add new fields gracefully; do not reject unknown keys.

Payout event data uses status, not the internal executionStatus. A successful Payout has status: "succeeded". The payout_intent.returned event reports a return of funds and has status: "failed", matching GET. Existing Payment event data is unchanged.

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

Rotating an endpoint secret is an atomic swap. The previous secret stops signing new deliveries as soon as the new secret is issued. Deploy the new secret to your receiver before completing the dashboard rotation.

Prepare the receiver

Deploy support for the new secret reference before rotating the endpoint.

Rotate in the dashboard

Issue the replacement secret. New deliveries use it immediately.

Verify the replacement

Send a Test delivery and verify its signature with the replacement secret.

Retire the old receiver configuration

Remove the previous secret from your receiver after the replacement is verified.

Retry policy

A delivery is successful when your endpoint returns 2xx within 10 seconds. Any other response, timeout, or unknown transport outcome advances to the next attempt:

  • The endpoint default is 6 retries after the first attempt, for 7 attempts total. The endpoint setting can change this count.
  • Delays are 15 seconds, 30 seconds, 2 minutes, 10 minutes, 1 hour, then 6 hours for further retries.
  • Payload bytes, URL, and secret version remain identical for every attempt. Each physical attempt receives a fresh signature timestamp and signature.
  • After exhaustion the delivery is failed and requires manual resend. Resend creates a linked new delivery; it never mutates the failed attempt history.
  • Disabling an endpoint terminates a pending delivery instead of retrying it.

Delivery is at-least-once and is not ordered. The same event id can arrive more than once if your endpoint accepted a request before Init observed an unknown transport result. Deduplicate by id and use transitionSequence to ignore stale out-of-order Payment or Payout events.

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