Webhooks: delivery, signatures and retries

The webhook is the only thing that tells you money moved. Everything else — the API response, the browser redirect, a status you polled — is a hint. This page is how to make that channel trustworthy.

  • Reviewed
  • 12 min read
  • API version 2026-04-01

How delivery works

When something happens on your account we create an event, sign it, and POST it to every endpoint subscribed to that type. Delivery is at-least-once: we keep trying until you answer with a 2xx, which means a well-behaved endpoint will occasionally see the same event twice.

a delivery, headers and body
POST /hooks/bazpay HTTP/1.1Host: shop.exampleContent-Type: application/jsonBazPay-Signature: t=1776072671,v1=6a3f9c1d…,v1=b17e40aa…BazPay-Event-Id: evt_8Kd1Wm4pQzBazPay-Delivery-Attempt: 1{  "id": "evt_8Kd1Wm4pQz",  "type": "payment.succeeded",  "created": "2026-04-12T09:31:44Z",  "version": "2026-04-01",  "livemode": false,  "data": { "id": "pay_3fJ2Qk8vTn", "status": "succeeded" }}

livemode tells you which ledger the event came from — the fastest way to catch a test endpoint wired into a production queue.

  • Timing Events are dispatched within a second of the state change in the common case. Bank-rail confirmations depend on the rail and can take minutes.
  • Timeout You have 10 seconds to respond. A slower reply is recorded as a failure and the event is retried, even if your handler eventually finished.
  • Success Any 2xx. The body is ignored. A 3xx is not followed — a redirect counts as a failure.
  • Version The body is rendered in the API version pinned on the endpoint, not on whatever request produced the event.

Registering an endpoint

Add endpoints under Developers → Webhooks. Each one gets its own signing secret and its own subscription list, which is what lets you route fulfilment and finance to different services without either seeing the other's traffic.

  1. Give the URL. It must be HTTPS with a certificate that validates; self-signed is refused in live mode.
  2. Pick the event types. Subscribe narrowly — an endpoint that receives everything gets rewritten every time you add a product.
  3. Copy the whsec_ secret into your configuration. It is shown once.
  4. Send a test event from the dashboard and confirm your service returns 200 before you rely on it.
Endpoint hygiene

Keep the receiving route free of authentication middleware, session handling and CSRF checks — the signature is the authentication. Keep it out of any framework that parses and re-serialises the body before your code sees it, because verification needs the raw bytes. Both mistakes present identically: every signature fails.

Verifying the signature

Every delivery carries BazPay-Signature, containing a timestamp and one or more HMAC-SHA256 digests. Verification is four steps and takes a dozen lines in any language.

verification, step by step
# 1 · split the headert  = 1776072671            unix secondsv1 = 6a3f9c1d…             one or more hex digests# 2 · build the signed payload — RAW body, byte for bytesigned = t + "." + raw_request_body# 3 · compute and compare in constant timemine = hmac_sha256(key = whsec_…, message = signed)ok   = any(constant_time_equals(mine, v) for v in v1_list)# 4 · reject stale timestampsif abs(now - t) > 300: reject   # five minutes

Multiple v1 digests appear during a secret rotation: the payload is signed with both the old and the new secret so you can roll without dropping deliveries.

Three details that decide whether the check is real:

  • Raw bytes, not re-encoded JSON. Pretty-printing or key reordering changes the digest. Capture the body before your framework touches it.
  • Constant-time comparison. A normal string equality leaks timing information; every standard library ships a safe comparator for exactly this.
  • Check the timestamp. Without the five-minute tolerance a captured request can be replayed at any point in the future and will still verify.
handler shape
# the shape of a handler that survives productionon POST /hooks/bazpay:  body = read_raw_bytes()          # before any JSON parsing  if not verify(body, header): return 400  evt = parse(body)  if seen(evt.id): return 200      # duplicate, already handled  record(evt.id)                   # unique index on the id  enqueue(evt)                     # do the slow work elsewhere  return 200                       # inside 10 seconds

The event catalogue

Subscribe to what you act on. The list below covers the events most integrations need; the full set, including subscription and dispute lifecycle events, is in the dashboard beside the subscription checkboxes.

Events, when they fire, and what a handler should do
Event Fires when Typical action
payment.succeeded Funds captured and authorised. Fulfil the order. The single most important event on the list.
payment.failed Issuer or bank refused, with a reason code. Release stock, prompt another method.
payment.requires_action Authentication is needed after an asynchronous start. Email the payer a resume link if the browser has gone.
refund.settled The payer's bank confirmed the credit. Close the support ticket. Not the same moment as creating the refund.
payout.settled A SEPA Instant credit landed. Mark the seller paid. See payouts.
payout.returned The beneficiary bank rejected the credit. Re-collect bank details. Never retry the same IBAN blindly.
mandate.revoked A payer cancelled a direct debit authorisation. Stop the subscription before the next collection.
dispute.opened A scheme chargeback was raised. Gather evidence at once — the window is days, not weeks.
settlement.created A payout to your own account is scheduled. Pull the report and reconcile.

Retries and failure

A delivery that does not return 2xx within 10 seconds is retried on a widening schedule. The ladder is fixed, so you can reason about how long an outage can last before an event is lost.

Retry ladder by event class
Class Attempts Spacing Total window
Payment and dispute 6 1 min, 5 min, 30 min, 2 h, 6 h, 12 h ~24 hours
Payout and mandate 4 1 min, 15 min, 1 h, 4 h ~6 hours
Settlement and report 3 5 min, 1 h, 6 h ~8 hours

After the last attempt the delivery is marked failed and stays in history, where you can replay it. An endpoint that fails every delivery for 72 hours is disabled automatically and the account owner is emailed — a permanently broken URL should not keep a queue growing forever.

Return 2xx before you do the work

The most common production incident on this page is a handler that fulfils the order synchronously, takes eleven seconds under load, and is retried — so the order ships twice. Verify, record the event id, put the work on a queue, answer. Everything slow belongs on the other side of that answer.

Duplicates and ordering

Two properties of at-least-once delivery have to be designed for. Neither is a defect and neither can be configured away.

Duplicates

The same event can arrive more than once — a retry after your 200 was lost in transit, or a manual replay. Store id with a unique constraint and make the second insert a no-op. That single index is the whole solution, and it is cheaper than any deduplication logic further down the stack.

Ordering

Events are dispatched in the order they occur but delivered independently, so payment.succeeded can arrive after a later refund.settled for the same payment. Do not infer state from arrival order. Two options, in order of preference:

  • Treat the event as a trigger rather than as truth: read the object by id and act on the status you get back. Correct under every interleaving.
  • Compare created on the event with the timestamp you last stored for that object, and ignore anything older. Cheaper, and enough when you only track one object type.
Test this deliberately

Replay an old event from the dashboard while a newer one for the same payment is already processed. If your order flips back to an earlier state, you have found the bug in development instead of in a reconciliation meeting. The testing guide covers how to force the pairs that matter.

Replay and debugging

Every delivery is kept for 30 days with its request headers, body, your response status and your response body. That history is the first place to look when something did not happen.

  • Replay one Re-sends a single event with a fresh timestamp and signature. The event id is unchanged, so a correct handler treats it as a duplicate.
  • Replay a range Re-sends every failed delivery in a time window. This is the recovery path after an outage on your side.
  • Retention 30 days of history. Beyond that, reconcile from the settlement reports on real-time analytics instead.
  • Test mode Trigger any event type by hand against a sandbox endpoint, with a payload you choose. No real object has to exist first.

When every signature fails

Four causes account for nearly all of it, in the order worth checking:

  1. The body was parsed and re-serialised before verification. Capture raw bytes.
  2. The wrong secret — the endpoint's whsec_, not an API key, and per endpoint.
  3. A proxy rewriting the body: content encoding, charset normalisation, or a trailing newline added.
  4. Server clock drift beyond five minutes. Check NTP before you suspect the signature.

Header and field definitions: API reference · going from a working sandbox endpoint to a live one: the go-live checklist.