# Webhooks

Webhooks notify your server when something happens, most importantly when a charge
succeeds. They let your backend react to events without polling the API.

---

## Registering an endpoint

Register an endpoint from the dashboard or over the API. Both manage the same objects.

**Dashboard:** Developers → Webhooks → Add endpoint. Enter your HTTPS URL and select the
events you want.

**API** (requires the `webhooks:write` scope, or `webhooks:read` for the reads):

| Method | Path | Purpose |
|---|---|---|
| `POST` | `/v1/webhook-endpoints` | Create an endpoint |
| `GET` | `/v1/webhook-endpoints` | List endpoints |
| `GET` | `/v1/webhook-endpoints/{id}` | Retrieve one endpoint |
| `DELETE` | `/v1/webhook-endpoints/{id}` | Delete an endpoint |
| `POST` | `/v1/webhook-endpoints/{id}/roll-secret` | Roll the signing secret |

Either way you get a signing secret used to verify deliveries (see below).

---

## Events

Subscribe only to events you handle. The charge / capture path commonly emits:

| Event | When | Payload |
|---|---|---|
| `charge.succeeded` | Sale capture, Payment Element success, or manual capture settles | `transactionId`, `amount`, `instrument`, and `detail` (see [below](#the-detail-block)) |
| `charge.authorized` | `capture_method: manual` (or delayed capture) holds funds | The same set, plus `detail` |
| `charge.failed` | Decline / hard failure, including a charge our fraud screening blocked ([below](#fraud-blocks)) | The same set, plus the decline summary and `detail` |
| `charge.canceled` | Authorization voided | `transactionId`, `amount`, `instrument`, `orderReference`, `metadata`. **No `detail`** |
| `charge.expired` | An authorization nobody captured ran past the card network's window and was canceled (it also fires `charge.canceled`) | The same set as `charge.canceled`. **No `detail`** |
| `charge.refunded` | Full or partial refund | The same set, plus `refundAmount`, `parentTransactionId` and `detail` |
| `dispute.created` | A dispute was opened (also fires for a pre-dispute alert) | Dispute event (see [below](#dispute-payloads)) |
| `dispute.won` / `.lost` | The dispute was decided (each also fires `dispute.closed`) | Dispute event; `.lost` adds `fee` |
| `dispute.updated` | Evidence was submitted, or the dispute left the sale it named | Dispute event (see [link changes](#link-changes)) |
| `dispute.funds_withdrawn` / `.funds_reinstated` | Disputed funds left or returned to your balance | Dispute event |
| `pre_dispute_alert.created` | Ethoca / CDRN / RDR alert received, ahead of any chargeback | Alert event (see [below](#alert-payloads)) |
| `pre_dispute_alert.updated` | The alert moved: matched to a sale, unlinked or moved to another sale, refunded, or resolved | Alert event (see [below](#alert-payloads)) |

That is the payments subset. The rest covers customers, checkout sessions, orders,
subscriptions, invoices, disputes, decline recovery, and payouts. See the [full
catalogue](./19-events.md). Dashboard → **Webhooks → Add endpoint** lists the deliverable
names, served from the same API that validates `enabled_events`, so that list is the
authority on what exists rather than any count written down here.

### Fraud blocks

A charge our scored fraud screening refuses is recorded as a failed charge, so it raises
`charge.failed` and `transaction.failed` once, like any decline, whichever flow it came
from: `POST /v1/charges`, a payment intent, the hosted checkout or a subscription charge.
Its decline summary says that we refused it, not the issuer:

```json
{
  "type": "charge.failed",
  "data": { "object": {
    "transactionId": "TXN-01JABCDEFGHJKMNPQRSTVWXYZ0",
    "amount": { "minorUnits": "2500", "currencyCode": "USD" },
    "instrument": { "brand": "visa", "last4": "4242" },
    "decline": {
      "category": "BLOCKED",
      "declineCode": "fraudulent",
      "declineOrigin": "PLATFORM"
    },
    "detail": {
      "orderReference": "ORD-77431",
      "declineCode": "fraudulent",
      "declineReason": "Your card was declined."
    }
  } }
}
```

- **`decline.declineOrigin: "PLATFORM"`** means the platform refused the charge before any
  gateway call. No processor saw it, so `mid`, `processor`, `gateway`, `acquirerCode`,
  `acquirerLabel` and `decline.networkDeclineCode` are absent. An issuer decline reads
  `"NETWORK"` and a gateway refusal `"GATEWAY"`.
- **`transactionId`** is the `charge` the `POST /v1/charges` 400 names; `GET
  /v1/charges/{id}` reads it back with `outcome.type: blocked`.
- **No rule or reason code** is in the payload. The score and decision are on `GET
  /v1/charges/{id}/risk`.
- **Once per block.** A retry under the same `Idempotency-Key` replays the refusal and sends
  no second webhook.
- **A fraud pre-screen (velocity) block sends no webhook.** It is visible on the charge
  list, but it raises no event.

### Subscriptions, invoices and orders

These carry the **whole record**, not just an id and a total, so a webhook-only
integration can act without calling back.

| Event | When | Payload |
|---|---|---|
| `subscription.created` / `.updated` | Created, or plan/quantity/trial changed | `detail`, see [below](#the-subscription-block) |
| `subscription.renewed` | A new billing period started | `detail`, incl. `amount`, `interval` and `currentPeriodEnd` |
| `subscription.past_due` | A renewal charge failed | `detail`, incl. the recurring `amount` and `currentPeriodEnd` |
| `subscription.suspended` | A hard decline ended the retries | `detail` |
| `subscription.canceled` / `.paused` / `.resumed` | Lifecycle change | `detail`, incl. `cancelAtPeriodEnd` and `cancellationReason` |
| `subscription.usage_exceeded` | Metered usage passed the limit | `currentUsage`, `usageLimit`, `percentOver`. **No `detail`** |
| `invoice.created` / `.finalized` | Drafted, or finalized (number and URLs stamped) | `detail`, see [below](#the-invoice-block) |
| `invoice.sent` | We emailed it to your customer | `detail`, plus a top-level `hostedInvoiceUrl` and `checkoutUrl`, both live |
| `invoice.overdue` | Past its due date, unpaid | `detail`, incl. `amountRemaining` |
| `invoice.paid` / `.payment_succeeded` | `amountRemaining` reached zero | `detail`, incl. the lines |
| `invoice.voided` | Voided | `detail`. Check `amountPaid`: a void can still owe a refund |
| `order.created` / `.paid` / `.fulfilled` / `.cancelled` | Order lifecycle | `order`, the stored order row minus its relations |
| `payment_link.created` / `.updated` / `.deactivated` | A link's own lifecycle | Link id, code, and on `.updated` a `changedFields` list |
| `payment_link.completed` | A checkout **through** a link finished | Checkout session |
| `inventory.low_stock` | A store product hit its low-stock threshold on a sale | `productId`, `storeId`, `quantityAfter`, `threshold` (but see the zero-value rule below) |

There is **no `invoice.payment_failed`**. A card renewal that declines is
`subscription.past_due`. Do not wait for an invoice event that is not in this
catalog.

> **`payment_link.updated` names the fields that changed, not their new values.**
> `changedFields` is `["amount"]`, not the amount. Re-read the link when you need
> the value. This is deliberate: the event rides a topic that is not scoped to the
> link's contents.

> **`store.*` and `product.*` are not merchant events.** They record administrative
> changes we make to a catalogue, not changes you make, so they are not deliverable.
> Your own product edits are yours already.

> ⚠️ **`subscription.renewed`, `.past_due` and `.suspended` also deliver as
> `subscription.updated`.** An endpoint subscribed to both names receives every
> renewal twice, under two names with one shared occurrence. Subscribe to one or
> deduplicate on the subscription id and the timestamp. This is the same
> two-surfaces rule that applies to `charge.*` and `transaction.*`.

> ⚠️ **`charge.succeeded` and `charge.captured` are the same delivery moment, not two
> stages.** One captured sale fans out to `transaction.succeeded`, `charge.succeeded`
> **and** `charge.captured`, and it does so for an ordinary auto-capture sale and a
> recorded cash sale exactly as it does for an authorization you capture later. So
> `charge.captured` is not "the second half of auth-then-capture". An endpoint subscribed
> to both `charge.succeeded` and `charge.captured` receives **every sale twice**. Pick one
> name. `charge.authorized` is the event that means funds are held and nothing has
> settled.

> ⚠️ **`order.created`, `order.paid` and `order.completed` all fire on the same order, at
> the same instant.** An order row is written already paid, and that single write emits
> all three names, `order.completed` is an alias of `order.paid`, and `order.created` is
> not an earlier lifecycle step you can act on first. An endpoint subscribed to all three
> receives one order three times. Subscribe to one.

> ⚠️ Marking an order shipped and adding a tracking number are separate writes, and each
> is a fulfilment fact. Marking it shipped emits `order.fulfilled` with
> `order.trackingNumber` empty; adding a tracking number the order did not already hold
> emits `order.fulfilled` again, that time carrying the number. Either write can come
> first, and when the tracking number arrives together with the shipping mark there is
> only one delivery. So key your handler on the order id and treat a second
> `order.fulfilled` as an update rather than a duplicate, re-marking an already-shipped
> order, or re-sending a tracking number we already hold, emits nothing further.

> ⚠️ **`inventory.low_stock` omits `quantityAfter` when the product sold out.** The field
> is a plain integer, and the payload drops zero-valued fields entirely (see the
> [zero-value rule](#omitted-values)), so the sold-out case (the alert you most want to
> act on) arrives with **no `quantityAfter` key at all** rather than `quantityAfter: 0`.
> Read it as `Number(o.quantityAfter ?? 0)`. `threshold` behaves the same way for a
> merchant who set their alert threshold to zero.

Every delivery is the same envelope. Your payload is at **`data.object`**, one level
deeper than `data`. Reading `data.id` returns `undefined`.

```json
{
  "id": "evt_5c1d90a7-3f2b-4a86-b0e7-8d41c6f2a915",
  "object": "event",
  "api_version": "2026-01-01",
  "created": 1733673600,
  "livemode": false,
  "type": "charge.succeeded",
  "data": {
    "object": {
      "transactionId": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3",
      "processorTransactionId": "1234567890",
      "amount": { "minorUnits": "2000", "currencyCode": "USD" },
      "merchantAccountId": "cmt3yfavu00013iokngc9rjtw",
      "mid": "9876543",
      "processor": "NMI",
      "instrument": { "brand": "visa", "last4": "4242", "bin": "424242" },
      "customerRef": "ae7babf2-8c52-43fe-b2ae-c3395b17add8",
      "riskScore": 12,
      "occurredAt": "2026-08-13T10:15:00Z",
      "envMode": "ENV_MODE_TEST",
      "idempotencyKey": "order_1234",
      "ip": "203.0.113.7"
    }
  }
}
```

<a id="omitted-values"></a>
Three things to read carefully before you write the handler:

- **`data.object` keys are camelCase, and the envelope's are snake_case.** The envelope
  (`api_version`, `livemode`) is ours; `data.object` is a protobuf message rendered with
  the canonical JSON mapping, which converts `transaction_id` to `transactionId`. So it is
  `e.data.object.transactionId`, not `e.data.object.transaction_id`. The snake_case form
  returns `undefined`. This applies to every event type, disputes included.
- **`amount` is an object, not an integer.** `amount.minorUnits` is the integer cents *as
  a JSON string* (protobuf renders 64-bit integers that way), and `amount.currencyCode` is
  uppercase ISO-4217. `Number(e.data.object.amount.minorUnits ?? 0)` gives you `2000`.
  Note this differs from the REST API, where `amount` is a plain integer and `currency` is
  lowercase. **The `?? 0` is not defensive habit. A zero money really does arrive as
  `{"currencyCode":"USD"}` with no `minorUnits` key.** See the [zero-value
  rule](#omitted-values).
- **The field names are not the REST field names.** `data.object` is the event as the
  engine emits it, not the charge object `POST /v1/charges` returns: the id is
  `transactionId`, the card is `instrument`, there is no `status` field, and the `metadata`
  you sent on `POST /v1/charges` (or on the payment intent the charge was made from) is under
  `detail` (`detail.metadata`) — at the top level only on `charge.canceled` /
  `charge.expired`, which have no `detail`.
  The outcome is already in the event `type`. `processor` is Admin → Acquirers → **Code**
  of the acquirer **Payment Workflow** chose for this sale (`NMI`, `PAYARC`, `NUVEI`,
  `MAVERICK`, … as configured). Those processors share one NMI security key; the NMI
  `processor_id` on the sale is that row's charge code (Connector code (`processor_id`)).
  Connector codes and that charge code are not this field. `mid` is omitted when the row
  has none. `processorTransactionId` is the gateway's own id (NMI `transactionid`).
  **Disputes and pre-dispute alerts are the exception**: they nest the REST object itself
  under `data.object.dispute` / `data.object.alert`, shaped by the same mapper the read
  API uses. See [Dispute payloads](#dispute-payloads) and [Alert
  payloads](#alert-payloads).
- **Inside `data.object`, empty *and zero* fields are omitted, not nulled.** That half of
  the body is protobuf rendered with the canonical JSON mapping, and that mapping drops
  every field still holding its default: unset strings (`customerRef`, `ip`,
  `idempotencyKey`), the number `0`, `false`, and an enum sitting on its first value. So
  there is no `""`, no `0` and no `false` anywhere under `data.object`. The key is simply
  not there. Read every field defensively (`e.data.object.customerRef ?? ""`, `Number(x ??
  0)`, `flag === true`) and never test for a key's presence to decide whether something
  happened. The envelope around it is ours and does not behave this way: `livemode` is a
  real `false` on a test-mode delivery.

### Matching a delivery back to your order

**On the money events, read `data.object.detail.orderReference`. It is your own
reference, echoed back.** That is the direct join, and it is the one to use on:

| event | |
|---|---|
| `charge.authorized` / `transaction.authorized` | |
| `charge.succeeded` / `charge.captured` / `transaction.succeeded` | |
| `charge.failed` / `transaction.failed` | a decline carries it too |
| `charge.refunded` / `transaction.refunded` | matches the refund to the original order |
| `charge.canceled` / `transaction.voided` | reads it at the **top level**, not under `detail`, as is `metadata`; a void raised from settlement carries neither |
| `charge.expired` / `transaction.expired` | top level, as on `charge.canceled` |

`detail` carries the rest of the record with it: the buyer as you supplied them,
what the gateway answered (`authCode`, `responseCode`, `avsResult`) and the card
expiry. On the refund events it also carries the settlement trail (`arn`,
`settlementBatchId`) as it arrives. See [The `detail` block](#the-detail-block) below.

> ⚠️ **Where `detail` rides, exactly.** It is on the first four rows of the table above
> (authorization, capture, decline, refund, under both name families), on
> `transaction.enriched`, on every `subscription.*` event except `usage_exceeded`, on
> every `invoice.*` event, and on `customer.created` / `customer.updated`. It is **not**
> on `charge.canceled` / `transaction.voided` or `charge.expired` / `transaction.expired`,
> not on `transaction.updated`, and not on
> either `charge.three_d_secure.*` event.  Three families carry a different shape instead,
> and none of them has a `detail` key: `dispute.*` and `pre_dispute_alert.*` nest their
> REST resource (`data.object.dispute`, `data.object.alert`) and are joined through the
> `transactionId` they name; `order.*` nests the order row at `data.object.order`.
> `customer.deleted` carries neither; by then there is nothing left to send.
> `customer.created` / `.updated` carry no transaction id at all: they are keyed on the
> customer id, and `detail` is the customer record, not a payment.

> ⚠️ **It is a snapshot of what we stored, so it can be absent.** `orderReference` is what
> you sent us at charge time; a field you never sent is not invented here. Read
> defensively (`detail?.orderReference ?? ""`) rather than assuming the key exists. The same
> goes for `detail.metadata`, the `metadata` you sent on `POST /v1/charges` or on the payment
> intent the charge was made from: it is omitted
> when you sent none, on a charge made before it was stored, and on a refund synced from your
> gateway (a refund issued here repeats the original sale's). Read it as
> `detail?.metadata?.order_id`. The whole block is optional in the contract, so keep reading
> it as `detail?.…`.
> It used to be genuinely missing on the gateway-sync lanes. The ones that record
> transactions we observe at the processor rather than take through the API, and the
> largest source of declines here. That gap is closed: every producer of an event whose
> payload declares `detail` now attaches it, built from the row it just wrote rather than
> from the observation that arrived. An absent block would mean *not loaded*, which is a
> different fact from a present block whose fields are empty. Keep the payment-intent
> fallback below wired as your second join rather than your emergency one.

**The fallback, when you have no reference of your own.** If you did not set an
`orderReference` at charge time, link the payment intent to your order on the way in:

1. When your server creates the payment intent, store the returned `id` (`pi_…`)
   **and** `client_secret` against your order.
2. The browser's `onSuccess` hands you `paymentIntentId`. The same `pi_…`. That is your
   first, immediate confirmation, and it is already tied to your order.
3. To resolve the intent to the transaction the webhook will name, read `GET
   /v1/payment_intents/{client_secret}` (the secret, **not** the `pi_…` id) and take
   **`latest_charge`**. That value is the `transactionId` you will see in `data.object`.
   (`latest_charge` is snake_case because it is a REST field; the webhook renders the same
   value under a camelCase key.) A retrieve with only `pi_…` is a 404.

Either way, a handler must be idempotent rather than assume it sees each event once.

`metadata` carries your order id the same way, whichever way you charge. Set it on
`POST /v1/charges`, or on the payment intent (`POST /v1/payment_intents`, or `PATCH` before
the buyer pays), whose charge copies it when the buyer pays: it comes back as
`data.object.detail.metadata` on the charge events, and on `GET /v1/charges/{id}`. That is
where the `metadata` examples elsewhere in these docs apply.

> Deliveries are **asynchronous**. A charge never waits on your endpoint. If your server
> is slow or down, the charge still completes and the webhook is retried independently.
> Don't treat the API response and the webhook as a strict ordering; either may arrive
> first.

### The `detail` block

On the money events, `data.object.detail` carries the record as we stored it, so a
webhook-only integration does not have to call back for the rest of a transaction.

```json
{
  "type": "charge.refunded",
  "data": { "object": {
    "transactionId": "TXN-337b3790-1bc1-436e-baf3-dbe688b393fb",
    "parentTransactionId": "cmrze8mzt000101lqu51gwhp9:12480511238",
    "amount":       { "minorUnits": "7060", "currencyCode": "USD" },
    "refundAmount": { "minorUnits": "7060", "currencyCode": "USD" },
    "instrument": { "brand": "visa", "last4": "5013" },
    "gateway": "NMI", "acquirerLabel": "Payarc", "nmiUsername": "YourGatewayLogin",
    "detail": {
      "orderReference": "ORD-77431",

      "billingName": "Dana Whitfield",
      "billingEmail": "dana@example.com",
      "billingPhone": "+14155550142",
      "billingLine1": "1200 Harrison St",
      "billingCity": "San Francisco",
      "billingState": "CA",
      "billingPostal": "94103",
      "billingCountry": "US",

      "authCode": "A7X21B",
      "responseCode": "100",
      "responseMessage": "Refund approved",
      "avsResult": "Y",

      "cardExpMonth": 11,
      "cardExpYear": 2029,

      "arn": "74567890123456789012345",
      "settlementBatchId": "BATCH-5512"
    }
  }}
}
```

**What it holds, and how reliably.** These are fill rates measured across the stored
transaction rows. They say how often *we hold a value*, not whether the block is on the
delivery, and they are not a guarantee for any one transaction:

| Field | On a charge | On a refund | |
|---|---|---|---|
| `orderReference` | ~100% | ~100% | your own key for the order |
| `metadata` | when you sent it | issued here only | your `metadata` from `POST /v1/charges`, or from the payment intent the charge was made from |
| `responseCode` / `responseMessage` | ~99% | ~99% | what the gateway said |
| `cardExpMonth` / `cardExpYear` | ~99% | ~98% | expiry; brand/last4 ride `instrument` |
| `billingName` / `billingEmail` | ~99% | ~95% | the buyer as you supplied them |
| `billingLine1` / `City` / `Country` | ~99% | ~95% | |
| `billingPhone` | ~87% | ~72% | |
| `authCode` | ~59% | ~87% | approvals only |
| `avsResult` / `cvvResult` | ~94% / ~28% | ~86% / not sent | address and CVV verdicts |
| `arn` | not sent | ~27% | refunds only, see below |
| `settlementBatchId` | not sent | ~43% | refunds only |

> ⚠️ **`arn` and `settlementBatchId` are on the REFUND events only.** They are not carried on
> `charge.*` at all, because a charge is emitted at authorisation, before either value exists.
> For a charge's settlement trail, re-read the transaction or subscribe to
> `transaction.enriched`, the event that fires when clearing data lands.

> ⚠️ **Sparse is not missing.** An `arn` is minted by the card network at clearing, days
> after the refund, so an absent one means *not yet*, never *never*. The same event is
> not re-sent when it arrives; re-read the resource, or use `transaction.enriched`.

> ⚠️ **Empty and zero fields are omitted, not nulled**. The same rule as the rest of the
> payload. Read every field defensively (`detail?.authCode ?? ""`,
> `Number(detail?.cardExpMonth ?? 0)`).

**Two values that are deliberately absent.**

- **`refundReason` is not populated.** It exists in the schema, but refunds made at the
  gateway carry no reason from the processor, and that is nearly all of them. Rather than
  send you a field that is always empty, we leave it unset, do not build on it.
- **3-D Secure and `paymentMethodType` are on charge events, not refund events.** A
  refund is not re-authorised, so it has no SCA outcome of its own. Read them from the
  `charge.*` event for the original sale.
- **`walletType` rides beside them**: `"apple_pay"` or `"google_pay"` when the charge was
  an Apple Pay / Google Pay payment we processed, omitted otherwise. An approved live sale
  synced from your gateway is tagged only once the gateway has named its wallet, usually a
  few minutes after the sale, so its events omit it, and no event is sent when the wallet is
  learned: read it on the transaction (`card.wallet` on `GET /v1/charges/{id}`). The same
  holds for a sale synced before this existed, tagged when a one-off history check reaches
  it: old sales never re-notify you.
  On such a charge `instrument.last4` is the device token's, not the buyer's physical card; the physical
  card's last four and network ride as `walletCardLast4` / `walletCardBrand` when the
  wallet sheet stated them (omitted otherwise). All three are on the event raised when the
  charge is decided (`charge.succeeded` on an automatic capture, `charge.authorized`,
  `charge.failed`) and on a later manual capture's `charge.succeeded`, and a refund issued
  here (API or dashboard) repeats them from the original sale. A later capture carries no 3-D Secure
  block, so read that from the charge itself (`GET /v1/charges/{id}`).

**Verdicts are the published short form.** `avsResult` / `cvvResult` read `"MATCH"`,
`"NO_MATCH"`. The same values the REST API returns, never an internal enum name.
Gateway-sync transactions may instead carry the raw issuer letter (`"Y"`, `"N"`, `"U"`),
passed through as the processor sent it.

---

### The subscription block

`data.object.detail` on every `subscription.*` event except `usage_exceeded`, which is a
metering signal and carries none. The flat fields above it (`subscriptionId`, `planId`,
`status`, `quantity`) stay; this adds the rest of the record.

```json
"detail": {
  "planId": "plan_pro", "status": "PAST_DUE", "quantity": 3, "currency": "USD",
  "amount": { "minorUnits": "8997", "currencyCode": "USD" },
  "interval": "MONTH", "intervalCount": 1,

  "currentPeriodStart": "2026-08-01T00:00:00Z",
  "currentPeriodEnd":   "2026-09-01T00:00:00Z",
  "billingTimezone": "America/New_York",

  "trialEnd": "2026-08-15T00:00:00Z",

  "cancelAtPeriodEnd": true,
  "cancellationReason": "too expensive",

  "collectionMethod": "CHARGE_AUTOMATICALLY",
  "dunningState": "NONE",

  "collectTax": true, "couponId": "cpn_10", "currentUsage": 42
}
```

**The one field worth building on.** `cancelAtPeriodEnd`, on `subscription.canceled`,
is the difference between *revoke access now* and *revoke access on the period
boundary*. Getting it wrong either cuts a paying customer off early or gives away free
time. Everything else on the block is descriptive; read the caveats below before you
branch on any of it.

> **`amount` is the recurring base for the whole subscription**. The plan's price
> multiplied by `quantity`. Three seats of a $29.99 plan read `"8997"`, not `"2999"`. It
> is **not** the settled charge: coupon discounts and downgrade credits are subtracted and
> tax is added when the renewal is charged, so the amount your customer is billed can
> differ. Read `amount` as "what this agreement bills before adjustments".

> **`amount`, `interval` and `intervalCount` ride the same priced line, so all three
> arrive or vanish together.** They are on all eight events that carry `detail`,
> `created`, `updated`, `renewed`, `past_due`, `canceled`, `paused`, `resumed` and
> `suspended`. Five of those shipped none of the three until recently, so an older
> integration treating them as renewal-only fields was reading a real gap. They still drop
> out together when no priced line is stored against the subscription: an absent `amount`
> means *not stated on this event*, never *this subscription bills nothing*, and the plan
> stays the price of record.

> ⚠️ **`dunningState` is always `"NONE"`, and that does not mean nobody is retrying.**
> Billing runs no dunning clock of its own, decline recovery is owned by the payments
> engine, which does retry a failed renewal and reports it on the
> `decline_recovery.recovered` / `.failed` / `.hard_declined` events. So the trio is inert
> here: `dunningState` ships the literal `"NONE"`, and `dunningAttempts` is always a zero
> the payload omits. `nextDunningAt` is absent too, with one exception. A retry you
> trigger yourself from the dashboard stamps it with the moment that attempt was
> *claimed*, so where it does appear it is a past timestamp of an attempt already made,
> never a future one we have scheduled. Subscribe to `decline_recovery.*` if you need to
> know whether we are still trying, and do not branch on the dunning trio.  `"RETRYING"`
> is not one of this field's values and never was. The declared set is `NONE`, `ACTIVE`,
> `FIRST_NOTICE`, `SECOND_NOTICE`, `FINAL_NOTICE`, `SUSPENDED`.

> ⚠️ **These fields exist in the schema and are never populated:** `nextBillingAt`,
> `endDate`, `externalId`, `invoiceDescription` and `taxRateId`. No write path in the billing
> service sets any of them, so they are absent from **every** delivery. They are listed
> here rather than quietly dropped so you can recognise them if you meet them in a
> schema; do not build on them. For the next charge date, read `currentPeriodEnd`.

> **No `checkoutUrl`, no `defaultPaymentMethodId`, no `metadata`.** The first is an
> operational handle that expires, the second is our tokenisation detail, and the third
> is already yours: `GET /v1/subscriptions/{id}` returns it.

### The invoice block

`data.object.detail` on every `invoice.*` event.

```json
"detail": {
  "invoiceNumber": "INV-2026-AB12CD-000001", "status": "OPEN",
  "title": "August services",

  "subtotal":        { "minorUnits": "13000", "currencyCode": "USD" },
  "discountAmount":  { "minorUnits": "500",   "currencyCode": "USD" },
  "taxAmount":       { "minorUnits": "1320",  "currencyCode": "USD" },
  "shippingAmount":  { "minorUnits": "1000",  "currencyCode": "USD" },
  "total":           { "minorUnits": "14820", "currencyCode": "USD" },
  "amountPaid":      { "minorUnits": "10000", "currencyCode": "USD" },
  "amountRemaining": { "minorUnits": "4820",  "currencyCode": "USD" },

  "dueDate": "2026-08-15T00:00:00Z", "finalizedAt": "2026-08-01T00:00:00Z",
  "hostedInvoiceUrl": "https://pay.example/invoices/inv_1/view",
  "collectionMethod": "SEND_INVOICE", "autoAdvance": true,

  "lineCount": 2,
  "lines": [
    { "description": "Pro plan, 3 seats", "quantity": 3,
      "unitPrice": { "minorUnits": "3000", "currencyCode": "USD" },
      "taxAmount": { "minorUnits": "900",  "currencyCode": "USD" },
      "total":     { "minorUnits": "9400", "currencyCode": "USD" },
      "productId": "prod_pro" }
  ]
}
```

- **`amountRemaining` is the field to branch on, read it as
  `Number(detail.amountRemaining?.minorUnits ?? 0)`.** A *partially paid* invoice is not a
  paid one, and `status` alone does not tell you which you have. The `?? 0` is mandatory,
  not stylistic: on a fully paid invoice the remaining balance is zero, and a zero money
  has no `minorUnits` key (see below). `Number(undefined) === NaN`, and `NaN === 0` is
  `false`, so the naive read reports a paid invoice as still owing, on **every**
  `invoice.paid` / `invoice.payment_succeeded` delivery.
- **On `invoice.voided`, check `amountPaid`.** A void after a partial payment leaves
  money that has to be refunded or credited.
- **`lineCount` is the true total; `lines` stops at 200.** On the rare invoice longer
  than that, `lines.length` is less than `lineCount` and the resource is the authority.

> **Every money value is an object, never a bare number**, so cents can never be read as
> dollars. When there is an amount it is `{ "minorUnits": "4820", "currencyCode": "USD"
> }`, `minorUnits` is a JSON *string*, because it is a 64-bit integer.  ⚠️ **A zero money
> arrives as `{ "currencyCode": "USD" }` with no `minorUnits` key.** Zero is the default
> for an integer, and the payload omits defaults, so the key is gone rather than `"0"`.
> Always read money as `Number(m?.minorUnits ?? 0)`.  This is not an edge case in this
> block. `amountRemaining` is zero on every paid invoice; `taxAmount` is zero on an
> invoice with no tax; `discountAmount` is zero on an undiscounted one; `amountPaid` is
> zero on `invoice.created` and `.finalized`; a line's `taxAmount` is zero on an untaxed
> line. Every one of those arrives with `currencyCode` alone.  ⚠️ **`invoice.overdue` is
> not on that list, and assuming it is costs money.** The overdue sweep picks up any
> invoice that is still `OPEN` with a balance left past its due date, so a *partially
> paid* one is exactly what it fires on. `amountPaid` is a real figure there. The same
> reason the `invoice.voided` bullet above tells you to read it.

> **`lines` is present on every invoice event.** If it is ever absent, that means *not
> sent on this event*, never *nothing was billed*. Read the invoice if you need them.

**Where the payable link is.** `invoice.sent` carries two links at the **top level**
of `data.object`, beside `detail` rather than inside it, and both arrive complete:

| Field | What it opens |
|---|---|
| `hostedInvoiceUrl` | the hosted invoice page, view, download, print, and pay |
| `checkoutUrl` | the checkout session that takes the payment |

These are **bearer links**, deliberately. They are the same links our own invoice email
puts in front of your customer, and they work for whoever holds them. That is what makes
an invoice payable without a login. Send them on; that is what they are for.

> ⛔ **Therefore treat an `invoice.sent` body, and anything you log it into, as secret
> material.** Anyone who can read that delivery (or your access logs, or a screenshot in a
> support ticket) can pay, or view, that invoice. Store the links where you would store a
> payment link, not where you would store an event id, and keep them out of analytics
> pipelines and error trackers.

> **Inside `detail` it is different, and the two fields differ from each other.** On every
> `invoice.*` event, `invoice.sent` included, `detail.hostedInvoiceUrl` is the invoice
> page **with its `?token=` removed** (a stable reference you can key on, not a way in)
> and `detail.checkoutUrl` is **always the empty string**. The checkout link is
> `…/checkout/<sessionToken>` and that token *is* the payment capability, carried as a
> path segment, so there is no token-free form of it to publish: the builder drops the
> whole URL rather than try to filter it.  On `invoice.sent` that costs you nothing,
> because the payable pair is already at the top level. On every other invoice event it is
> the difference between a reference and a credential. If you need a payable link outside
> `invoice.sent`, re-send the invoice from the dashboard; there is no public REST endpoint
> that returns one.

## Retries and auto-disable

A failed delivery (non-`2xx`, timeout, or connection error) is retried on a fixed backoff
ladder, one attempt per rung:

| Retry | Delay after previous attempt |
|---|---|
| 1st | 1 minute |
| 2nd | 5 minutes |
| 3rd | 15 minutes |
| 4th | 1 hour |
| 5th | 3 hours |
| 6th | 6 hours |
| 7th | 12 hours |
| 8th | 12 hours |

> **Retries stop after the 8th.** There are **9 attempts in total**, spanning roughly **34
> hours** from the first failure. After that the delivery is marked permanently failed and
> is never retried again. An outage longer than that window drops those events for good
> (see [reconciling](#reconciling-without-webhooks).)

The retry budget belongs to each event, not to the endpoint: every event gets the same 9
attempts, whenever the endpoint was created.

### When an endpoint keeps failing

A failing endpoint goes through two stages. Both end at the first `2xx`.

**Throttled, after 10 failures in a row.** We stop sending it every event. It receives
**at most one automatic request a minute**, and the other events are kept for it (see
below). Nothing else changes: the endpoint stays enabled and nobody is emailed. This is
what stops a down or slow endpoint from holding up your other endpoints.

<a id="when-an-endpoint-is-auto-disabled"></a>
**Auto-disabled, after 24 hours.** An endpoint is auto-disabled only when its failures
have been continuous for a sustained period: **every delivery to it has failed for at
least 24 hours, and at least 10 deliveries in a row have failed.** Both must hold. A
burst of failures does not disable it, however many there are (a bad deploy that returns
`500` for ten minutes, or a signature check that rejects everything until you fix it).
When we disable an endpoint we email your account owner and add a notification to your
dashboard. The dashboard shows it with an "Auto-disabled" badge, and its delivery log
shows the responses that led there. Disabling does not delete the endpoint or its
configuration.

> **While it is disabled, your URL receives at most one automatic request an hour.** When
> an hour has passed, the next event that comes due for the endpoint is sent as a trial
> delivery (a *probe*); if no event comes due, there is no probe until one does. If your
> server returns `2xx`, the endpoint is switched back on and the failure count resets. If
> it does not, the endpoint stays disabled and the next probe is at least an hour later.
> So a server that recovers on its own (a deploy that finishes, a certificate that gets
> renewed) needs no action from you, and one that stays down is not flooded. You can
> re-enable it at once from Dashboard → Developers → Webhooks → the endpoint's toggle.
> **Retry** and **Send test** in the dashboard are not automatic: they send immediately,
> whatever state the endpoint is in, and a `2xx` from either switches it back on too.

> **Events that occur while it is throttled or disabled are kept, not dropped.** Each one
> is queued for the endpoint and keeps the retry schedule above. Once the endpoint
> succeeds again, each queued event is sent on its next retry. Expect them over the
> following hours, not all at once and not in order, so dedupe on `X-Webhook-Id`. In the
> endpoint's delivery log a queued event shows as `PENDING` with no response, or as
> `FAILED` with its last response if it had already been tried before the endpoint was
> held back.
>
> **The queue is bounded by each event's own retry window.** When an event's 9 attempts
> run out while the endpoint is still failing (about 34 hours after the event), its queued
> delivery becomes `DEAD_LETTER`: it is not pushed any more, it stays in
> [`GET /v1/events`](./19-events.md), and **Retry** in the delivery log still sends it.
>
> **An endpoint that has been disabled for longer than that stops queueing.** From then
> on a new event is not queued for it at all: its delivery is recorded as `DEAD_LETTER`
> straight away, with nothing sent, and **Retry** still sends it. The event is in
> `GET /v1/events` for 7 days. The hourly probe goes on, so the endpoint can still recover
> on its own. Anything you need from that period is what
> [reconciling](#reconciling-without-webhooks) is for.

An endpoint **you** switch off is different: we never probe it, never queue events for it
and never switch it back on. That includes switching off an endpoint we had
auto-disabled: from then on it is yours, and stays off until you turn it on. Deliveries
still queued for it at that moment become `DEAD_LETTER` (you can still **Retry** one).
Deleting an endpoint marks its queued deliveries `DEAD_LETTER` too.

Because the retry window is finite, [reconciling](#reconciling-without-webhooks) is not an
optional safety net. It is how you recover anything that fell outside it. Treat webhook
health as something you actively monitor: if you stop seeing deliveries for an event you
subscribe to, check the dashboard rather than assuming nothing happened.

### Deliveries that were recorded but never sent

When we re-import historical data (a backfill, or a re-sync after a provider outage), the
events it produces are recorded for your endpoint but **deliberately not sent**, so a
re-import cannot re-notify you about things you were told about weeks ago. In the delivery
log such a row is labelled `SUPPRESSED`. Underneath, it is stored as delivered with no
response status and carries a `replay` flag, so read the flag before the status: a
suppressed row is not a request your server received, and it cannot be retried.

---

## Verifying a delivery

Each delivery is signed. Verify the signature before trusting the payload. That proves
it came from WorthyPayments and wasn't tampered with. Use the signing secret from the
dashboard endpoint configuration. Reject any request whose signature doesn't match.

Every delivery carries these headers:

| Header | Value |
|---|---|
| `X-Webhook-Signature` | `v1=<hex>`. HMAC-SHA256, hex digest, prefixed with the literal `v1=`. After `POST /webhook-endpoints/{id}/roll-secret`, both the new and previous secrets are valid for **24 hours** and the header is `v1=<current>,v1=<previous>`. Verify by testing each `v1=` part, do not compare the whole header string. |
| `X-Webhook-Timestamp` | Unix seconds at send time. Re-generated on every retry attempt, so it is **not** the envelope's `created` field. |
| `X-Webhook-Id` | The **occurrence** id. The same value as `id` in the body. Stable across every retry, and identical on every endpoint you have subscribed to that event. Use it to dedupe. |
| `X-Webhook-Mode` | `live` or `test`: the mode of the endpoint that signed this delivery. Use it to pick the secret before you read the body. |
| `X-Webhook-Endpoint-Id` | The id of the endpoint that signed this delivery (the `id` returned when you created it). If you keep one secret per endpoint id, look the secret up by this. |

> ⚠️ **`X-Webhook-Id` is not a handle you can fetch.** It identifies the occurrence, and
> `GET /v1/events/{id}` is keyed on a **delivery** (one row per subscribed endpoint) so
> passing the header value there answers `404`. On the polled feed, this header's value is
> the item's `event_id`, not its `id`. See [Events → Two ids](./19-events.md#list-events).

> ⚠️ **One signing secret per endpoint, and test mode and live mode are separate
> endpoints.** An endpoint belongs to one mode. Register the same URL in test mode and in
> live mode and you have two endpoints with two different secrets; a delivery is signed
> with the secret of the endpoint it was sent to, and nothing else. So if one URL serves
> both modes, choose the secret by the `X-Webhook-Mode` header (`live` → your live
> endpoint's secret, `test` → your test one; the envelope's `livemode` says the same), or by
> `X-Webhook-Endpoint-Id`, or try each endpoint's secret in turn. Trust nothing in the body
> until the signature verifies.

```js
// One URL, both modes: pick the secret by the header, then verify.
const secret = req.get("X-Webhook-Mode") === "live"
  ? process.env.WP_WEBHOOK_SECRET_LIVE
  : process.env.WP_WEBHOOK_SECRET_TEST;
```

> Checking a live delivery against the test secret rejects every
> delivery, which looks exactly like a broken integration, and an endpoint that rejects
> every delivery for a day is [auto-disabled](#when-an-endpoint-is-auto-disabled).

The signed string is `` `${timestamp}.${rawBody}` ``: the timestamp from
`X-Webhook-Timestamp`, a literal `.`, then the exact raw request body.

```js
import crypto from "crypto";

function verify(rawBody, headers, secret) {
  const timestamp = headers["x-webhook-timestamp"];
  const signatureHeader = headers["x-webhook-signature"];
  if (!timestamp || !signatureHeader) return false;

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

  // Roll-secret overlap sends `v1=<current>,v1=<previous>`. Compare each part.
  // Length MUST match before timingSafeEqual — it throws a RangeError on
  // unequal-length buffers, which would 500 your handler instead of rejecting.
  for (const part of String(signatureHeader).split(",")) {
    const got = Buffer.from(part.trim());
    if (got.length !== expected.length) continue;
    if (crypto.timingSafeEqual(expected, got)) return true;
  }
  return false;
}
```

Verify against the **raw request body**. Don't parse the JSON first, or whitespace
differences will break the signature.

Also reject deliveries whose `X-Webhook-Timestamp` is far from your own clock (a few
minutes of tolerance is typical) so a captured delivery cannot be replayed later.

---

## Responding

Return a `2xx` quickly to acknowledge receipt. Do any heavy work asynchronously. If you
block, the delivery may time out and be retried, causing duplicate processing. Make your
handler **idempotent**: dedupe on the charge `id` so a retried delivery doesn't double-fulfill
an order.

---

## Reconciling without webhooks

Webhooks are the push model. If you miss one (endpoint down for a window), reconcile by
polling [`GET /charges`](./03-charges.md), walking the pages as described in
[Pagination](./13-pagination.md). Every succeeded charge is there regardless of webhook
delivery.

There is **no server-side date filter**. `GET /charges` accepts `limit`, `starting_after`
and `status` only; any other query parameter is ignored, and you get an unfiltered page
back rather than an error. So reconcile by walking the cursor and stopping yourself:

```js
// Walk newest-first until you reach a charge you have already recorded.
async function reconcile(lastSeenChargeId) {
  const recovered = [];
  let cursor;
  for (;;) {
    const qs = new URLSearchParams({ limit: "100", status: "succeeded" });
    if (cursor) qs.set("starting_after", cursor);
    const page = await api(`/v1/charges?${qs}`);

    for (const charge of page.data) {
      if (charge.id === lastSeenChargeId) return recovered;
      recovered.push(charge);
    }
    if (!page.has_more) return recovered;
    cursor = page.data[page.data.length - 1].id;
  }
}
```

Persist the id of the newest charge you have processed and pass it as `lastSeenChargeId`
on the next run. `GET /events` (`events:read`) lists recent deliveries the same way, and
also takes `limit` and `starting_after` only.

### Pre-dispute alerts: poll by timestamp instead

Alerts are the one resource with a real server-side cursor over *change* rather than
creation, because an alert's whole point is that its state moves, matched, then refunded.
`GET /v1/pre_dispute_alerts` takes `updated_since`, so you do not have to walk from the
top and stop yourself.

First, the `api()` helper the examples below use. Nothing exotic. One `fetch` wrapper that
attaches the key and turns a non-2xx into a throw:

```js
const BASE = "https://api.worthypayments.com";

async function api(path) {
  const res = await fetch(`${BASE}${path}`, {
    headers: { "x-api-key": process.env.WP_SECRET_KEY },
  });
  if (!res.ok) {
    const body = await res.json().catch(() => ({}));
    // 401 = bad key, 403 = key is missing the `disputes:read` scope,
    // 429 = slow down and honour Retry-After.
    throw new Error(`${res.status} ${body.error?.code ?? "request_failed"}`);
  }
  return res.json();
}
```

Then the paging loop:

```js
// Persist `highWaterMark` between runs. Start it at 0 for a first full sync.
async function pollAlerts(highWaterMark) {
  let cursor;
  let newest = highWaterMark;

  for (;;) {
    const qs = new URLSearchParams({ limit: "100" });
    if (highWaterMark) qs.set("updated_since", String(highWaterMark));
    if (cursor) qs.set("starting_after", cursor);
    const page = await api(`/v1/pre_dispute_alerts?${qs}`);

    for (const alert of page.data) {
      handle(alert);                                  // must be idempotent — see below
      newest = Math.max(newest, alert.updated_at);
    }
    if (!page.has_more) return newest;                // caught up; sleep until next run
    cursor = page.next_cursor;
  }
}
```

The polled object is a **superset** of the webhook payload, same lifecycle fields, plus
the match evidence (`arn`, `auth_code`, `card_bin`, `card_last4`, `order_id`), the
sub-merchant MID, the processor, and the linked sale's gateway id. Reconcile push against
pull on `id`.

`auto_rule` is worth reading even when you only care about refunds. On an `AUTO_REFUNDED`
alert it names the rule that moved the money and when it was evaluated; when nothing fired
it carries `decision: "NONE"` and a `reason`, so an alert sitting open is explained rather
than mysterious, `sale-already-refunded` means the platform found the sale already
refunded and refused, `operator-excluded` means a person marked it hands-off.

Two properties worth knowing, both consequences of ordering by `updated_at` ascending:

- **You cannot miss an alert.** One that changes while you are paging moves to the *end* of
  the sequence rather than shifting the rows ahead of it, so it lands on a later page.
- **You can see one twice**, for exactly that reason. Dedupe on `(id, updated_at)`. The
  same at-least-once handling the webhooks already require of you.

Do not pass an `updated_since` you are unsure of: a value that is neither unix seconds nor
RFC3339 is rejected with `400 parameter_invalid` rather than ignored, because silently
treating a typo as "from the beginning" would re-deliver your entire alert history.

The endpoint needs the `disputes:read` scope, which new keys get by default. An older key
may predate that, add it under Dashboard → **Developers** if a call answers 403. `GET
/v1/pre_dispute_alerts/{id}` fetches one alert, and accepts either our id or the network's
own (`ETH-9821`, `CDRN-4408`, `RDR-2841`).

<a id="alert-payloads"></a>
### Pre-dispute alert payloads

`pre_dispute_alert.created` and `.updated` carry the **same object** `GET
/v1/pre_dispute_alerts` returns, under `data.object.alert`, same producer, same mapper, so
a pushed alert and a polled one never disagree.

```json
{
  "id": "evt_5c1d90a7-3f2b-4a86-b0e7-8d41c6f2a915",
  "object": "event",
  "type": "pre_dispute_alert.created",
  "livemode": true,
  "data": {
    "object": {
      "alertId": "f206cc55-9266-49fe-8663-74879c4589cf",
      "transactionId": "TXN-3729a67d-...",
      "source": "ETHOCA",
      "slaDueAt": "2026-08-20T15:48:32Z",
      "occurredAt": "2026-08-19T15:48:41.049Z",
      "alert": {
        "id": "f206cc55-...",
        "externalId": "ETH-9821",
        "amount": { "minorUnits": "13550", "currencyCode": "USD" },
        "reason": "FRAUDULENT",
        "state": "NEW",
        "priority": "HIGH",
        "slaStatus": "OK",
        "customerId": "ae7babf2-8c52-43fe-b2ae-c3395b17add8",
        "matchEvidence": {
          "arn": "74599000111222333444555",
          "authCode": "B7C4D1",
          "cardBin": "424242",
          "cardLast4": "4242",
          "orderId": "ord_9400",
          "rung": "auth",
          "strength": "strong"
        }
      }
    }
  }
}
```

> **Read `alert`, not the flat fields.** The keys beside it, `alertId`, `transactionId`,
> `source`, `slaDueAt`, are the original payload and stay for subscribers written against
> it, but they cannot tell you which charge the alert names in *your* system.
> `alert.matchEvidence` carries the ARN and auth code you already hold, which is what
> makes the alert reconcilable on first delivery rather than after a follow-up API call.
> Its card BIN/last4 are the card the *issuer* reported, and they can differ from the card
> you hold for the sale without the link being wrong: Apple Pay and Google Pay charge a
> device token rather than the card, and virtual and reissued cards do the same. Reconcile
> on `transactionId`, the ARN or the auth code, never on the card digits.
> `alert` is absent only when the emitting path had no row to shape,
> treat a missing `alert` as "fall back to the flat fields", never as an error.

<a id="dispute-payloads"></a>
### Dispute payloads

`dispute.created`, `dispute.won`, `dispute.lost` and `dispute.closed` carry the **same
`dispute` object** `GET /v1/disputes` returns, under `data.object.dispute`, same producer,
same mapper, so a pushed dispute and a polled one never disagree. That object is where
`nmi_transaction_id`, `source_acquirer` (Payarc / Nuvei / Maverick Payments), the card
BIN/last4, `respond_by` and `reason_message` live.

`dispute.updated` and `dispute.funds_withdrawn` carry it too. The object is declared on
every `dispute.*` event and is filled from the row inside the transaction that wrote it.

⚠️ **`dispute.funds_reinstated` is the one name where `data.object.dispute` is optional,
read it as neither guaranteed nor absent.** Six paths in our dispute service return held
funds and each raises this event. One of them fills the nested object; the other five send
the flat fields alone. Four of those five raise `dispute.won` in the same transaction
*with* the object, so the cheapest fallback is to take the card and the gateway id off
that event. The fifth is a pre-dispute alert freeing the reserve, and it raises
`dispute.won` only when the alert is refunded. A dismissed one raises no `dispute.won`, so
keep `GET /v1/disputes/{disputeId}` as the last resort. Write the handler as
`data.object.dispute ?? await fetchDispute(...)`: "always" and "never" are both wrong on
this name, and a handler built for either one is what breaks.

⚠️ **`nmi_transaction_id` is best-effort.** It comes from joining the linked sale, so an
unmatched dispute (or one whose sale has not synced yet) carries an empty value.

The flat fields beside it differ per event and stay for subscribers written against them,
but they cannot tell you which charge the dispute names in *your* system. Read `dispute`
first.

`dispute.created` is the widest. It fires once per dispute, for every stage and provider:

```json
{
  "id": "evt_5c1d90a7-3f2b-4a86-b0e7-8d41c6f2a915",
  "object": "event",
  "api_version": "2026-01-01",
  "created": 1787052300,
  "livemode": true,
  "type": "dispute.created",
  "data": {
    "object": {
      "disputeId": "25758130-bb0d-46bd-b65d-b22bd8bf7ccb",
      "transactionId": "9f1c6a2e-77d1-4f0a-9a3e-2b6c4d8e1a55",
      "merchantAccountId": "cmt3yfavu00013iokngc9rjtw",
      "envMode": "ENV_MODE_LIVE",
      "amount": { "minorUnits": "13550", "currencyCode": "USD" },
      "reasonCode": "10.4",
      "status": "NEEDS_RESPONSE",
      "provider": "Payarc",
      "occurredAt": "2026-08-18T10:15:00Z",
      "customerRef": "ae7babf2-8c52-43fe-b2ae-c3395b17add8",
      "dispute": {
        "id": "25758130-bb0d-46bd-b65d-b22bd8bf7ccb",
        "nmiTransactionId": "12437218480",
        "sourceAcquirer": "Payarc",
        "cardBrand": "visa",
        "cardBin": "424242",
        "cardLastFour": "4242",
        "cardholderName": "A. LOVELACE",
        "status": "DISPUTE_STATUS_NEEDS_RESPONSE",
        "stage": "DISPUTE_STAGE_INQUIRY",
        "reasonCode": "10.4",
        "reasonMessage": "Cardholder does not recognise the charge",
        "amount": { "minorUnits": "13550", "currencyCode": "USD" },
        "respondBy": "2026-08-25T00:00:00Z"
      }
    }
  }
}
```

> `dispute` is optional on `dispute.funds_reinstated`. One of the six paths that return
> held funds fills it, five do not (see above), and on the other names it is missing only
> when the emitting path could not read the row. Treat a missing `dispute` as "fall back
> to the flat fields", never as an error. `dispute.won` and `dispute.lost` carry it, which
> is what makes a resolution actionable: they used to say only *that* you won or lost,
> never on which card or why.

> ⚠️ **Enum values inside `dispute` are prefixed; the flat fields beside it are not.** The
> nested object is generated from the protobuf `Dispute` message, where `status`, `stage`,
> `reason`, `outcome`, `escalationLevel` and `partnerProvider` are enums, so the wire
> carries `"DISPUTE_STATUS_NEEDS_RESPONSE"`, not `"NEEDS_RESPONSE"`. The flat `status` at
> the top of the payload is a plain string and stays unprefixed. Match on the prefixed
> form when you read `dispute.status`, or strip the `DISPUTE_STATUS_` / `DISPUTE_STAGE_`
> prefix yourself.  Timestamps inside `dispute` (`respondBy`, `dueDate`, `disputeDate`,
> `transactionDate`) are RFC3339 strings, while `created` on the envelope is unix seconds.
> `pre_dispute_alert.*` differs here: its nested `alert` declares `state`, `priority`,
> `reason` and `slaStatus` as plain strings, so those arrive unprefixed. The two objects
> are not interchangeable on this point.

What each event carries. Every key camelCase, and omitted entirely when the producer had
no value for it:

| Event | Fields beyond the common set |
|---|---|
| `dispute.created` | `reasonCode`, `status`, `provider`, `customerRef` |
| `dispute.updated` | `reasonCode`, `status`, `provider`. Fires on evidence submission, and on a [link change](#link-changes), which adds `customerRef`, `previousTransactionId`, `linkChange` and `linkChangeReason` |
| `dispute.won` | `customerRef` |
| `dispute.lost` | `customerRef`, **`fee`** (a Money object. The dispute fee you were charged) |
| `dispute.funds_withdrawn` | `customerRef` |
| `dispute.funds_reinstated` | `customerRef` |

The common set is `disputeId`, `transactionId`, `merchantAccountId`, `envMode`, `amount`
and `occurredAt`.

Four things that bite:

- **`disputeId`, not `id`.** Reading `data.object.id` returns `undefined`. That value is
  the same id `GET /v1/disputes/{id}` takes.
- **`dispute.won` and `dispute.lost` each also fire `dispute.closed`.** One decision
  delivers twice if you subscribe to both. They are separate deliveries with separate
  event ids, so dedupe on `disputeId` + your own state, not on the event id alone.
- **`dispute.created` also fires for pre-dispute alerts** (Ethoca / CDRN / RDR), which are
  warnings, not chargebacks. If you only want formal disputes, subscribe to
  `pre_dispute_alert.created` as well and branch, or reconcile against `GET /v1/disputes`.
  An alert-sourced row carries `kount_alert_id` there.
- **`stage` is in the nested `dispute` object**, never among the flat fields, read
  `data.object.dispute.stage` to tell an inquiry from a chargeback, no round-trip needed.
  It is there on `dispute.created`, `.won`, `.lost`, `.closed`, `.updated` and
  `.funds_withdrawn`. On `dispute.funds_reinstated` it rides only the shared
  terminal-resolution lane; on the other five paths that return held funds it is absent,
  so that one needs the `dispute.won` that usually accompanies it, or `GET
  /v1/disputes/{disputeId}`.

Because the event is thinner than the REST object, the common pattern is to treat the
webhook as a *hint* and fetch the full record:

```js
app.post("/webhooks/wp", async (req, res) => {
  res.sendStatus(200);                       // ack first, work after
  const e = req.body;
  if (!e.type.startsWith("dispute.")) return;
  const id = e.data.object.disputeId;
  const dispute = await api(`/v1/disputes/${id}`);   // full object, REST field names
  await upsertDispute(dispute);                       // idempotent on dispute.id
});
```

<a id="link-changes"></a>
### Link changes: a case left the sale it named

When we remove a pre-dispute alert's or a dispute's link to a sale, move it to another
sale, or give a dispute we delivered without a sale its sale after review, you receive the
update event you already subscribe to: `pre_dispute_alert.updated` for an alert,
`dispute.updated` for a dispute. Three keys are added to `data.object`, on
that update only:

| Key | Meaning |
|---|---|
| `previousTransactionId` | The sale the case was linked to before this change (absent on `LINKED`) |
| `linkChange` | `UNLINKED`: the case names no sale now. `RELINKED`: it moved to the sale in `transactionId`. `LINKED`: a dispute you received without a sale now names the sale in `transactionId` |
| `linkChangeReason` | Why the link changed (see below) |

`transactionId` keeps its meaning: the sale linked **now**. On an `UNLINKED` update the key
is absent, so read the sale that was cleared from `previousTransactionId`. A `LINKED` update
has no `previousTransactionId`: the dispute had no sale before. It follows a review that
matched a chargeback we first delivered unmatched, for example one whose card digits are the
card network's token for the card rather than the card itself.

| `linkChangeReason` | What happened |
|---|---|
| `EVIDENCE_CONFLICT` | The network's authorization code or card digits contradict the sale |
| `DECLINED_ATTEMPT` | The sale was a declined, voided or otherwise unsuccessful attempt, not a completed sale |
| `NOT_A_SALE` | The sale was a refund, not a completed sale. On `RELINKED` the case moved to the sale that refund reversed |
| `ARN_COLLISION` | The acquirer reference matched more than one sale, so a link made on it alone was withdrawn or moved |
| `OPERATOR` | Our operations team corrected the link after review |
| `KOUNT_TWIN_SYNC` | The alert and the dispute raised from it were put back on the same sale |
| `REMATCH` | Matching proved a different sale |

**What to do with it.** If you acted on the old link, for example flagged or blocked the
customer behind `previousTransactionId`, undo that for this case. On `RELINKED`, apply it
to the sale in `transactionId` instead. On `LINKED`, treat it as the dispute's sale from now
on, as you would a dispute that arrived matched.

```json
{
  "type": "pre_dispute_alert.updated",
  "data": {
    "object": {
      "alertId": "f206cc55-9266-49fe-8663-74879c4589cf",
      "externalId": "ETH-9821",
      "outcome": "NEW",
      "previousTransactionId": "TXN-3729a67d-...",
      "linkChange": "UNLINKED",
      "linkChangeReason": "EVIDENCE_CONFLICT",
      "alert": { "id": "f206cc55-...", "state": "NEW" }
    }
  }
}
```

Four things to know:

- **Branch on `linkChange`.** Every other update carries none of the three keys. A first
  link (an alert matched for the first time) is not a link change either: it arrives as
  `outcome: "MATCHED"` without them.
- **`outcome` on an alert.** An open alert reads `MATCHED` after a move and `NEW` after an
  unlink, never `MATCHED` without a sale. A resolved alert keeps its resolution
  (`REFUNDED`, `DISMISSED`, ...): the refund or the dismissal happened, whatever the link
  says now. A link change moves no money, so it carries no `refundStatus`, except while an
  automatic refund is in flight: that alert reads `AUTO_REFUNDED` with `refundStatus:
  "REQUESTED"`, as the refund rule already announced it. A refund our team requested that the
  gateway has not confirmed yet reads as an open alert (`MATCHED` or `NEW`).
- **Once per change.** Each move is delivered once, even if our correction is re-run, and a
  case that really moves back and forth is announced every time it moves.
- **History replays stay silent.** A change made while we re-import historical data is
  recorded but not delivered. Corrections are delivered as they happen.

### Disputes: the same poll

Formal chargebacks / inquiries use the same cursor on `GET /v1/disputes`. Subscribe to
`dispute.created` / `.updated` / `.won` / `.lost` / `.closed` if you want push; poll is the
catch-up path, and the one that still works when a webhook is silent.

```js
async function pollDisputes(highWaterMark) {
  let cursor;
  let newest = highWaterMark;
  for (;;) {
    const qs = new URLSearchParams({ limit: "100" });
    if (highWaterMark) qs.set("updated_since", String(highWaterMark));
    if (cursor) qs.set("starting_after", cursor);
    const page = await api(`/v1/disputes?${qs}`);
    for (const dispute of page.data) {
      handle(dispute);
      newest = Math.max(newest, dispute.updated_at);
    }
    if (!page.has_more) return newest;
    cursor = page.next_cursor;
  }
}
```

A page item is the full `dispute` object, REST field names (snake_case), `amount` as an
integer of cents, `currency` lowercase, timestamps as unix seconds:

```json
{
  "id": "25758130-bb0d-46bd-b65d-b22bd8bf7ccb",
  "object": "dispute",
  "transaction_id": "9f1c6a2e-77d1-4f0a-9a3e-2b6c4d8e1a55",
  "nmi_transaction_id": "12437218480",
  "billing_name": "Ada Lovelace",
  "billing_email": "ada@example.com",
  "cardholder_name": "A. LOVELACE",
  "card_bin": "424242",
  "reason_message": "Cardholder does not recognise the charge",
  "alert_source": "ETHOCA",
  "transaction_date": 1786966800,
  "respond_by": 1787656800,
  "closed_at": null,
  "status": "NEEDS_RESPONSE",
  "stage": "INQUIRY",
  "outcome": "",
  "reason": "FRAUDULENT",
  "reason_code": "10.4",
  "amount": 13550,
  "currency": "usd",
  "source_acquirer": "Payarc",
  "processor_dispute_id": "cb_9f2210",
  "kount_alert_id": "f34a64b7-b8ac-4d4c-9245-efb5437d7261",
  "card_brand": "visa",
  "card_last4": "4242",
  "dispute_date": 1787052000,
  "due_date": 1787656800,
  "created_at": 1787052300,
  "updated_at": 1787066400
}
```

| Field | Notes |
|---|---|
| `id` | This API's dispute id. What `dispute.created` calls `disputeId`. |
| `transaction_id` / `nmi_transaction_id` | The matched sale, and its gateway id. **Both `""` until the dispute is matched**, alert-sourced rows often arrive unmatched. |
| `billing_name` / `billing_email` | Cardholder name and email on the matched sale, projected the same way. `""` when unmatched, or when the sale itself carries no contact, best-effort, not guaranteed. |
| `cardholder_name` | The name as the **provider** sent it on the dispute. Not the same field as `billing_name`, which comes from the sale. They disagree often enough to be worth reading both. ~15% populated. |
| `card_bin` | First six PAN digits. Populated on effectively every dispute. |
| `reason_message` | The provider's own wording, alongside the normalised `reason` / `reason_code`. ~71% populated. |
| `alert_source` | The pre-dispute network this originated from (`ETHOCA`, `RDR`, `VERIFI`); `""` for an ordinary acquirer chargeback. Distinct from `source_acquirer`, which is who reported it. ~52% populated. |
| `transaction_date` | Unix seconds. When the underlying sale happened, lets you age a dispute without fetching the charge. ~48% populated. |
| `respond_by` | Unix seconds. The provider's stated evidence deadline. `due_date` is ours; this is theirs. ~59% populated. |
| `closed_at` | Unix seconds. When the dispute reached a terminal state. ~35% populated. |
| `status` | `NEEDS_RESPONSE`, `NEEDS_MANUAL_MATCH`, `UNDER_REVIEW`, `WON`, `LOST`, `ACCEPTED`, `EXPIRED`, `PROCESSING`, `BUYER_REVIEW`, `BANK_REVIEW`, `APPEALABLE`, `STALE`, `CLOSED`, `RELATED_CASE`. A `RELATED_CASE` is a PayArc related case (reversal, resolution record, pre-arbitration, credit, retrieval fulfilment): not a dispute of its own, its `parent_dispute_id` carries the family's state. |
| `parent_dispute_id` | Set only when `status` is `RELATED_CASE`: the dispute that carries the family's state. `""` for every other dispute. |
| `stage` | `INQUIRY`, `CHARGEBACK`, `PRE_ARBITRATION`, `ARBITRATION`, `UNCLASSIFIED`. Unprefixed here; the webhook's nested `dispute.stage` carries the same fact as `DISPUTE_STAGE_INQUIRY`. Not among the webhook's flat fields at all. |
| `amount` | Integer **cents** (`13550` = $135.50), unlike the webhook's `amount.minorUnits` string. |
| `outcome` | `""` while open. |
| `source_acquirer` | `Payarc`, `Maverick Payments`, `NMI`, `Nuvei`. |
| `kount_alert_id` | Set when the dispute came from an Ethoca/CDRN alert; joins to `pre_dispute_alert.id`. |
| `due_date` | Evidence deadline, `null` when the provider set none. Watch this, not `dispute_date`. |
| `updated_at` | Your high-water mark. |

Unknown strings are `""` and unknown timestamps are `null`, never missing, so a field
access will not throw. (The webhook payload behaves the opposite way: empty fields are
omitted entirely.)

Filter with `status` and `stage` if you only care about part of the book. An unrecognised
value is **ignored**, not rejected. The filter is dropped and you get the unfiltered list,
so check the spelling if a filtered poll returns more than you expected.

`GET /v1/disputes/{id}` accepts our id, the processor's dispute id, or the Kount request
id (`kount_alert_id`). Same `disputes:read` scope. Dedupe on `(id, updated_at)`. A dispute
belonging to another merchant answers `404`, identical to one that does not exist.

A row's `updated_at` can move without a webhook (matcher sweeps, ingest). Poll is the
source of truth for "what changed"; the webhook is the push hint.

### Running it on a schedule

The whole job, end to end: read the last high-water mark, drain both feeds, store the new
mark. Every 5–10 minutes is a sensible cadence. The data is not real-time, and a tighter
loop mostly spends your rate limit re-asking a question with no new answer.

```js
// poll-job.js — `node poll-job.js`, or call runOnce() from your own scheduler.
import { readFile, writeFile } from "node:fs/promises";

const STATE = "./wp-poll-state.json";   // use your DB in production, not a file

async function loadMarks() {
  try {
    return JSON.parse(await readFile(STATE, "utf8"));
  } catch {
    return { alerts: 0, disputes: 0 };  // first run: 0 = full backfill
  }
}

export async function runOnce() {
  const marks = await loadMarks();

  // Each returns the newest updated_at it saw, so a crash mid-run re-reads
  // rather than skips. Both are safe to call concurrently.
  const [alerts, disputes] = await Promise.all([
    pollAlerts(marks.alerts),
    pollDisputes(marks.disputes),
  ]);

  await writeFile(STATE, JSON.stringify({ alerts, disputes }));
  console.log("caught up:", { alerts, disputes });
}

await runOnce();
```

Run it from cron every 5 minutes:

```cron
*/5 * * * * cd /srv/wp-poll && /usr/bin/node poll-job.js >> poll.log 2>&1
```

Four things that decide whether this survives contact with production:

- **Persist the mark only after the page is handled.** Writing it first means a crash
  loses every row in flight, permanently, because the next run starts past them.
- **`handle()` must be idempotent.** Ordering by `updated_at` guarantees you cannot *miss*
  a row, at the cost of sometimes seeing one twice. Upsert on `id`; do not append.
- **Start at 0 once, not every run.** A first sync with `highWaterMark = 0` walks your
  whole history. That is intended. Re-reading 0 every run re-downloads it every time.
- **Do not run two copies at once.** Two workers sharing one mark will both do the work and
  can write the older value last. Take a lock, or run it as a singleton.

If you get a `403`, the key is missing the `disputes:read` scope (add it in Dashboard →
**Developers** and retry) new keys carry it by default, on both feeds.
