# Events

Every webhook delivery is also stored as an **event** you can fetch back through the API.
If your endpoint was down and missed a delivery, list events to reconcile. You never lose
data waiting on a webhook.

```
GET /v1/events          GET /v1/events/:id
POST/GET/DELETE /v1/webhook-endpoints
```

Scope: `events:read`, `webhooks:read`, `webhooks:write` (write server-only).

**One event stream, whichever way the payment was taken.** A charge taken through the
[Payment Element](./08b-payment-element.md) and one built on your own server emit the
same `charge.*` events. The Element is a front door onto the same money path, not a
separate one. If the Element reports `processing`, this is where the outcome arrives.

---

## List events

```bash
curl "https://api.worthypayments.com/v1/events?limit=20" \
  -H "x-api-key: sk_test_xxx"
```
```json
{
  "object": "list",
  "data": [
    {
      "id": "evt_3f2b4a86-b0e7-4d41-9c6f-2a915c1d90a7", "object": "event", "type": "charge.succeeded",
      "created": 1733600000, "event_id": "evt_5c1d90a7-3f2b-4a86-b0e7-8d41c6f2a915",
      "data": {
        "id": "evt_5c1d90a7-3f2b-4a86-b0e7-8d41c6f2a915", "object": "event", "api_version": "2026-01-01",
        "created": 1733600000, "livemode": false, "type": "charge.succeeded",
        "data": { "object": { "transactionId": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3", "amount": { "minorUnits": "2000",
                  "currencyCode": "USD" }, "instrument": { "brand": "visa", "last4": "4242" }, ... } }
      }
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

⚠️ **The polled item WRAPS the delivered envelope. It is not the same shape.** Each list
item has five keys (`id`, `object`, `type`, `created`, `event_id`), and `data` holds the
entire envelope that was POSTed to your endpoint. So the resource sits at
**`data.data.object`**, one level deeper than in the webhook body, and `api_version` /
`livemode` are at `data.api_version` / `data.livemode` rather than at the top.

⚠️ **The item's `id` and the envelope's `id` are different values.** The outer `id`
identifies the *delivery*. One row per subscribed endpoint. The envelope's own `id`,
inside `data`, identifies the *occurrence*, and the item repeats it as `event_id`. So
`item.data.id === item.event_id`, and `item.data.id !== item.id`. Both are
`evt_`-prefixed, which is exactly why they are easy to conflate.

The practical consequence: `event.data.object.transactionId` works on a **pushed** body and
returns `undefined` on a **polled** one. Reach for `event.data.data.object.transactionId`
when polling, or unwrap once (`const envelope = item.data`) and share the rest of your
handler between both paths.

Page with `limit` and `starting_after`; retrieve one with `GET /v1/events/:id`.

> **Polling reads the occurrence log.** `GET /v1/events` lists every event 07 recorded for
> your merchant in this mode, including charges that happened with **no webhook endpoint
> subscribed**. A push still requires an endpoint; polling does not. The panel's
> per-endpoint delivery log is a different surface (`GET /webhooks/:id/deliveries`).

> ⚠️ **A few rows are recorded but never POSTed, and polling shows them anyway.** When we
> re-drive historical data (a backfill, a re-sync after a provider outage) the delivery
> row is written and marked delivered, but the outbound request is deliberately suppressed
> so a backfill cannot re-notify you about things you were already told about weeks ago.
> Because polling reads that same log, **such an event appears in `GET /v1/events` as a
> normal delivered event that your endpoint never received.** This is the one place where
> the polled feed and what actually arrived at your URL diverge. If you reconcile "did I
> receive everything the feed lists?", expect a small number of legitimate misses; the
> event is real and the state change happened, we simply chose not to re-notify.

> **This event feed is not your source of record. It is a change feed.** Occurrences older
> than the outbox retention window (7 days after they complete) are not kept. Nothing is
> backfilled when you add a webhook later, and you cannot ask us to re-send history. To
> load history, or to re-sync after an outage, read the resource itself: `GET
> /v1/pre_dispute_alerts`, `GET /v1/charges`, `GET /v1/customers` and friends are
> queryable over the full record, and `/v1/pre_dispute_alerts` takes `updated_since` for
> incremental pulls. The durable pattern is *events tell you something changed, the
> resource endpoint tells you what it is now*. That way a missed event is never lost data.

### Filter by type

```bash
curl "https://api.worthypayments.com/v1/events?type=pre_dispute_alert.created&limit=50" \
  -H "x-api-key: sk_test_xxx"
```

`type` takes one exact event name. There is no wildcard, and an unrecognised name returns
an empty list rather than an error. The filter is applied before paging, so `limit` still
means "up to N *matching* events" and `has_more` describes the filtered set. Re-send
`type` with each `starting_after` page, the same way you re-send `limit`.

Other query parameters are ignored rather than rejected, so check spelling: a mistyped
`types=` returns *unfiltered* events, not a `400`.

### Poll without missing events: use `created_after`

```bash
curl "https://api.worthypayments.com/v1/events?created_after=1733600000&limit=100" \
  -H "x-api-key: sk_test_xxx"
```

> **Anchor every poll on `created_after`, not on the page number.** `starting_after` is a
> 1-based *page number* over a newest-first feed. New deliveries land at the head, so if
> events arrive between your page 1 and your page 2, page 2 starts *later* in the sequence
> than page 1 ended and the rows in between are returned to nobody, silently, with a `200`
> and no gap marker.  Store the highest `created` you have processed and pass it as
> `created_after` on the next poll. Then a concurrent insert cannot slide a row past your
> cursor. It accepts unix seconds or RFC3339, and rejects anything else with a `400`
> rather than quietly returning your whole history.

**Two ids, and they answer different questions.**

| Field | What it identifies | Use it to |
|---|---|---|
| `id` | this **delivery**. One row per subscribed endpoint, and the only value `GET /v1/events/:id` resolves | ignore a **retry** of the same delivery |
| `event_id` | the **occurrence**. One value shared by every delivery of it. This is the `X-Webhook-Id` header, and the `id` inside the delivered envelope | ignore **fan-out** across endpoints |

⚠️ **`GET /v1/events/{X-Webhook-Id}` answers `404`.** That header carries the occurrence
id; this endpoint is keyed on the delivery id. If you are holding a pushed body and want
the polled row for it, list with `type` + `created_after` and match on `event_id`. There
is no lookup by occurrence.

A retry reuses the delivery row, so `id` is stable across all attempts of one delivery. But
fan-out writes **one delivery row per subscribed endpoint**: with two endpoints on the same
event type you receive that occurrence twice, under two different `id` values and one shared
`event_id`.

**So deduplicate on `event_id` when you have it**, and fall back to `id` when you do not.
It is emitted only when the producer set it, and an unset value is absent from the JSON
rather than null. Keying on `id` alone still protects you from retries, which is the
common case; it just lets one occurrence through twice if you subscribe two endpoints to
the same type.

> ⚠️ **`starting_after` here is a 1-based PAGE NUMBER, not an object id.** Every other
> list in this API is cursor-paginated and takes the previous page's `next_cursor`;
> `/events` is the exception, and `next_cursor` on this endpoint is simply the next page
> number as a string.
>
> This matters because passing an id fails **silently**: a non-numeric `starting_after`
> is read as page 1, so a loop that pages by the last event's id re-fetches the first
> page forever and never terminates. Pass back the `next_cursor` you were given, and stop
> when `has_more` is `false`.

---

## Managing webhook endpoints

Register an endpoint to receive events. The signing secret is returned **once** on create,
so store it to [verify deliveries](./16-webhooks.md).

```bash
curl https://api.worthypayments.com/v1/webhook-endpoints \
  -H "x-api-key: sk_test_xxx" -H "Content-Type: application/json" \
  -d '{ "url": "https://shop.example.com/webhooks", "enabled_events": ["charge.succeeded", "charge.refunded", "charge.failed"] }'
```
```json
{ "id": "we_...", "object": "webhook_endpoint", "url": "https://shop.example.com/webhooks",
  "enabled_events": ["charge.succeeded", "charge.refunded", "charge.failed"],
  "status": "enabled", "created": 1733600000, "secret": "whsec_..." }
```

- `GET /v1/webhook-endpoints` lists them, with secrets omitted.
- `DELETE /v1/webhook-endpoints/:id` removes one.
- `POST /v1/webhook-endpoints/:id/roll-secret` rotates the signing secret.

---

## Event types

Subscribe to exactly the events you handle when you create the endpoint. Every name
below is deliverable: an event only appears here if a real producer emits it. Names
that had no producer were deliberately removed rather than left in as a black hole
you could subscribe to and never hear from.

**Payments.** `charge.*` is the public API naming; `transaction.*` is the same
underlying event under the dashboard's naming. Subscribe to one family, not both, or
you will handle every payment twice.

| Event | When |
|---|---|
| `charge.succeeded` | A charge was captured successfully |
| `charge.authorized` | A `capture_method: manual` charge was authorized (funds held) |
| `charge.captured` | A previously authorized charge was captured |
| `charge.failed` | A charge was declined |
| `charge.refunded` | A charge was refunded |
| `charge.canceled` | An authorized charge was voided |
| `charge.expired` | An authorized charge nobody captured ran past the card network's window (4 days 18 hours for a Visa merchant-initiated charge, 7 days otherwise) and was canceled. The hold is released and the charge can no longer be captured. `charge.canceled` fires at the same moment |
| `transaction.succeeded` | Dashboard-naming equivalent of `charge.succeeded` |
| `transaction.authorized` | Dashboard-naming equivalent of `charge.authorized` |
| `transaction.failed` | Dashboard-naming equivalent of `charge.failed` |
| `transaction.refunded` | Dashboard-naming equivalent of `charge.refunded` |
| `transaction.voided` | Dashboard-naming equivalent of `charge.canceled` |
| `transaction.expired` | Dashboard-naming equivalent of `charge.expired` |
| `transaction.updated` | A transaction you already know about learned new facts. **Not** a money event (see below) |
| `transaction.enriched` | The same moment as `transaction.updated`, but the payload carries the new values (`detail`, the AVS/CVV codes, the card identity, the ARN, the settlement id) instead of naming them. **Not** a money event either |
| `charge.three_d_secure.challenged` | The issuer required a challenge and it was shown to the buyer |
| `charge.three_d_secure.abandoned` | A challenge was shown and never completed (see below) |

### 3-D Secure: the abandoned challenge

`charge.three_d_secure.abandoned` fires when the buyer reached their bank's challenge page
and walked away.

⚠️ **This produces no other event.** No `charge.failed`, because nothing was declined. The
authentication simply never finished, so no money moved and no decline code exists. Until
this event, that outcome was indistinguishable from a page that never loaded.

It is usually worth a nudge: the shopper's card is fine, they just did not complete the
step. `charge.three_d_secure.challenged` is the informational twin. The challenge was
shown, the payment is still in flight, and a `charge.succeeded` or `charge.failed` will
follow.

Both carry the session id, the transaction id and your customer reference. They do **not**
carry the device or card-hash fields our fraud engine uses internally.

### `transaction.updated`: the first observation is not the last one

A charge is booked from what the gateway said at authorization. Facts keep arriving after
that, and some of them **cannot** be on the first event:

- the **ARN** is minted at clearing, hours later
- the **settlement id** appears when the batch closes
- **AVS/CVV result codes** and the **card identity** (BIN, fingerprint) often arrive on a
  fuller later observation than the one that created the row

Until now those were written to your transaction silently. This event tells you they
landed.

```json
{
  "type": "transaction.updated",
  "data": { "object": {
    "transactionId": "TXN-8ac4796a-…",
    "updatedFields": ["arn", "settlement_id"],
    "source": "GATEWAY_SYNC"
  } }
}
```

⛔ **It never means the money changed.** Capture, refund and void keep their own events. If
you fulfil orders on `charge.succeeded`, nothing here changes that, treat this purely as
"re-read this transaction".

**It carries which fields changed, not their values.** Read the new values with
`GET /v1/charges/{id}`.

⚠️ **This is the exception, not the rule, and the reasoning has changed.** We used to
argue that values in an event would be "a second, staler truth". That held while events
were assembled from the incoming request, but a charge event is now snapshotted from the
row inside the same transaction that writes it, so it is not a copy that can drift: it is
the record at that instant. The money events therefore carry a full `detail` object:
`charge.authorized`, `charge.succeeded` / `.captured`, `charge.failed`, `charge.refunded`
and their `transaction.*` twins, plus `customer.created` / `.updated`. **`charge.canceled`
/ `transaction.voided`, `charge.expired` / `transaction.expired` and both
`charge.three_d_secure.*` events do not**. A void or an expiry carries
its `orderReference` and `metadata` at the top level instead, and the 3-D Secure pair are authentication
signals: session id, transaction id and your customer reference, with the fraud hashes
those messages also hold stripped before delivery.

`transaction.updated` stays field-names-only because it is genuinely different: it fires
when a LATER observation lands (an ARN at clearing, a settlement id at batch close), often
long after the charge, and what it reports is that the resource moved. Not a
self-contained fact about a moment. `transaction.enriched` is its value-carrying twin for
the same moment: it re-reads the row after the merge and ships `detail`, so a webhook-only
integration does not have to call back. Subscribe to one or the other, not both.

**It does not fire on every re-observation.** We compare the row before and after the
write, so a later observation of a transaction we already fully know changes nothing and
sends nothing. `updatedFields` is never empty.

`source` is `GATEWAY_SYNC` today. The payment was re-observed at the gateway. It is named
rather than implied so a future operator-driven correction is distinguishable without a
new event type.

**Saved cards.**

| Event | When |
|---|---|
| `payment_method.attached` | A buyer saved a card for future payments |
| `payment_method.updated` | A saved card changed, usually a new expiry after the issuer re-issued it |
| `payment_method.detached` | A saved card was removed. Stop billing it |

These carry the payment method id, the customer id, and the brand/last4/expiry a saved-card
list renders. They deliberately do **not** carry the vault reference or the card
fingerprint: those are handles to the stored card and belong on our side of the boundary.

**Setup intents** (a card saved and verified with no payment, `POST /v1/setup_intents`).

| Event | When |
|---|---|
| `setup_intent.succeeded` | A setup intent verified and saved a card with no payment. `payment_method` names the saved card |
| `setup_intent.setup_failed` | A setup intent's card could not be verified (declined, refused by fraud screening, or not verifiable right now). Nothing was saved; the buyer can try again |

`setup_intent.succeeded` carries `setup_intent_id`, `customer_id`, `payment_method_id`,
`usage` and the card's brand/last4/expiry; the card itself also sends
`payment_method.attached` (or `payment_method.updated` when the customer already had it).
`setup_intent.setup_failed` carries `last_setup_error` (`code`, `decline_code`, `message`) and
`failed_attempts`. Neither carries the client secret or any card token.

`payment_method.detached` is the one to wire first if you bill on file. It is how you
learn to stop.

**Customers.**

| Event | When |
|---|---|
| `customer.created` | A new customer was created |
| `customer.updated` | A customer was updated |
| `customer.deleted` | A customer was deleted |

`customer.created` and `customer.updated` carry a `detail` object: the customer record as we
stored it, so you can keep your own CRM in step without reading back.

```json
{
  "type": "customer.updated",
  "data": { "object": {
    "customerId": "8ac4796a-…",
    "changedFields": ["first_name", "address_line1"],
    "detail": {
      "email": "ada@example.com",
      "firstName": "Ada",
      "lastName": "Lovelace",
      "phone": "+15551234567",
      "addressLine1": "12 Analytical Way",
      "city": "London",
      "postalCode": "EC1A",
      "country": "GB",
      "isVerified": true,
      "totalTransactions": "12",
      "totalAmountCents": "906000",
      "disputeCount": "1",
      "lastTransactionAt": "2026-08-20T00:00:00Z"
    }
  } }
}
```

`changedFields` still tells you **what** moved; `detail` now tells you what it moved **to**.
Before this, an update named the field and you had to call us for the value.

⚠️ **`changedFields` and `detail` do not always spell a field the same way.** The names in
`changedFields` are field names as the customer API takes them (`first_name`,
`address_line1`); the keys inside `detail` are the record's own (`firstName`,
`addressLine1`). For `email`, `phone`, `city`, `state` and `country` the two coincide, which
is why the difference is easy to miss. If you branch on `changedFields`, compare
case-insensitively with `_` stripped, and always read the new value out of `detail` rather
than trying to index it by the changed-field name.

⚠️ **Sparse where the data is sparse, and honestly so.** Measured across 50,000 live
customers: email and name are present on 100%, phone on 96%, address on 59%, lifetime
totals on 43%. An absent field means the buyer never supplied one. Not that we dropped it.

⚠️ **A zero total is absent, not `"0"`.** The payload omits every field still holding its
default, so a customer created but never charged carries no `totalTransactions`, no
`totalAmountCents`, no `disputeCount` at all, and `isVerified` is absent rather than `false`.
Read counts as `Number(detail.totalTransactions ?? 0)` and booleans as
`detail.isVerified === true`. "No key" and "zero" are the same fact here, so do not use the
key's presence to decide whether a customer has history.

⚠️ **`customer.deleted` carries no detail.** By the time it fires the record is already
anonymised. There is nothing left to send, and that is the point of the event.

⛔ **Amounts and counts are int64 and cross the wire as JSON strings.** `"906000"`, not
`906000`. Parse them as integers, not floats: a lifetime volume past 2^53 loses precision as a
JavaScript number.

**Checkout and orders.**

| Event | When |
|---|---|
| `checkout.session.completed` | A checkout session completed. A payment intent's carries its `metadata` as it stood when paid (absent when it has none) |
| `payment_link.completed` | A payment-link checkout completed |
| `payment_link.created` | A payment link was created |
| `payment_link.updated` | A **live** link was edited. Carries `changedFields`: the names, not the values |
| `payment_link.deactivated` | A payment link was deactivated |
| `inventory.low_stock` | A store product hit its low-stock threshold on a sale |
| `order.created` | An order was placed. **Fires together with `.paid` and `.completed`** (see below) |
| `order.paid` | An order was paid |
| `order.completed` | Alias of `order.paid` |
| `order.fulfilled` | An order was fulfilled |
| `order.cancelled` | An order was cancelled |

> ⚠️ **`order.created`, `order.paid` and `order.completed` are one moment, not three.** An
> order row is written already paid, and that single write emits all three names at once,
> `order.completed` is an alias of `order.paid`, and `order.created` is not an earlier
> stage you can act on before payment lands. An endpoint subscribed to all three receives
> the same order three times, with three event ids and effectively the same timestamp.
> Subscribe to one and treat it as "an order exists and is paid". This is the same
> simultaneous-fan-out rule as `dispute.won` → `dispute.closed` and `subscription.renewed`
> → `subscription.updated`.

> The four `order.*` events carry **`data.object.order`**, the stored order record: the
> line items, the shipping and billing addresses, the buyer's contact details, and, on the
> `.fulfilled` delivery that reports a tracking number, `trackingNumber` and `carrier`.
> ⚠️ 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 it 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. Key
> on the order id and treat the second as an update, not a duplicate; re-marking an
> already-shipped order, or re-sending a tracking number we already hold, emits nothing
> further.  ⚠️ **Two of its fields are always empty on the webhook.** `events` (the
> order's own timeline) is simply not loaded: the event carries the order row, not its
> relations. `digitalDeliveries` is removed on purpose, because a delivery summary carries
> a redeemable code and a signed download link that belong to the buyer rather than in a
> webhook body. Neither is recoverable over the public API, orders are a dashboard
> surface, and there is no `GET /v1/orders/{id}`. Read them in the dashboard.
> **`shippingAddressJson` and `billingAddressJson` are JSON strings**, not objects. Parse
> them.

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

**Subscriptions and invoices.**

| Event | When |
|---|---|
| `subscription.created` | A subscription was created |
| `subscription.updated` | A subscription was updated |
| `subscription.canceled` | A subscription was canceled |
| `subscription.paused` | A subscription was paused |
| `subscription.resumed` | A subscription was resumed |
| `subscription.renewed` | A subscription renewed for a new billing period |
| `subscription.past_due` | A renewal payment failed |
| `subscription.suspended` | A subscription was suspended after a hard decline |
| `subscription.usage_exceeded` | Metered usage exceeded the subscription limit |
| `invoice.created` | An invoice was created |
| `invoice.finalized` | An invoice was finalized. The number and the hosted URLs are stamped here |
| `invoice.sent` | We emailed the invoice to your customer |
| `invoice.overdue` | An invoice passed its due date unpaid |
| `invoice.paid` | An invoice was paid |
| `invoice.payment_succeeded` | An invoice payment succeeded |
| `invoice.voided` | An invoice was voided |

There is **no `invoice.payment_failed`**. A card renewal that declines is
`subscription.past_due`.

> Every `subscription.*` and `invoice.*` event carries **`data.object.detail`**, the full
> stored record. See [The subscription block](./16-webhooks.md#the-subscription-block) and
> [The invoice block](./16-webhooks.md#the-invoice-block).
> **`subscription.usage_exceeded` is the exception.** It is a metering signal rather than
> a lifecycle change, and carries `currentUsage` / `usageLimit` / `percentOver` instead.
> ⚠️ **`detail.amount`, `detail.interval` and `detail.intervalCount` ride one priced line,
> so they arrive or vanish together.** They are on all eight `subscription.*` events that
> carry `detail`; they drop out together only when no priced line is stored against the
> subscription. `amount` is the recurring **base**. The plan price × `quantity`, before
> coupon, downgrade credit and tax. Not the settled charge. An absent `amount` means "not
> stated on this event", never "bills nothing".  ⚠️ **`detail.dunningState` is always
> `"NONE"`, and `detail.dunningAttempts` is always a zero the payload omits.** Billing
> runs no retry clock; decline recovery lives on the `decline_recovery.*` events.
> `detail.nextDunningAt` is absent too except after a retry you trigger yourself, where it
> is the *past* moment that attempt was claimed. Do not read the dunning trio as "nobody
> is retrying".  Two fields carry more than they look like: `detail.cancelAtPeriodEnd` on
> `subscription.canceled` decides whether access ends now or at the period boundary, and
> `detail.amountRemaining` on any invoice event distinguishes a **partially** paid invoice
> from a paid one, read it as `Number(detail.amountRemaining?.minorUnits ?? 0)`, because a
> zero money arrives as `{"currencyCode":"USD"}` with no `minorUnits` key.
> **`invoice.sent` also carries a live `hostedInvoiceUrl` and `checkoutUrl` at the top
> level**, beside `detail` rather than inside it. Both are payable by whoever holds them
> (see [the invoice block](./16-webhooks.md#the-invoice-block).)

**Disputes.**

| Event | When |
|---|---|
| `dispute.created` | A dispute was opened. **Also fires for a pre-dispute alert** (Ethoca/CDRN/RDR), which is a warning, not a chargeback, branch on `pre_dispute_alert.created` if you need to tell them apart |
| `dispute.updated` | Evidence was submitted for a dispute, or the dispute was unlinked from or moved off the sale it named (see below) |
| `dispute.won` | You won a dispute, **also delivers `dispute.closed`** |
| `dispute.lost` | You lost a dispute, **also delivers `dispute.closed`**; the payload adds `fee` |
| `dispute.closed` | A dispute reached a terminal state. Delivered **two ways** (see below) |
| `dispute.funds_withdrawn` | Disputed funds were withdrawn from your balance |
| `dispute.funds_reinstated` | Disputed funds were returned after a win |

**`dispute.closed` arrives from two different producers, with two different shapes.**

| Source | Carries | Tell them apart by |
|---|---|---|
| the second delivery of a `.won` / `.lost` | `amount`, `fee` (on lost), the full `dispute` | `amount` is present |
| a **no-winner close**. The provider reported the case terminal without saying who won (Nuvei never reports a winner) | `provider`, the full `dispute`, **no `amount`** | `provider` is present, `amount` is absent |

Both mean "this dispute is over". Only the first one implies money moved, so branch on the
presence of `amount` before you book anything.

Most `dispute.*` payloads carry the **full `dispute` object** under `data.object.dispute`.
The same one `GET /v1/disputes` returns, from the same mapper, so a pushed event and a
polled one never disagree. That 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 as well. The object is declared
on every `dispute.*` event and filled from the row inside the transaction that wrote it.

⚠️ **`dispute.funds_reinstated` is the one name where the nested object is genuinely
optional.** Six paths return held funds and each raises this event. One of them attaches
the object; the other five send the flat fields alone. Four of those five raise
`dispute.won` in the same transaction *with* the object, so read the card and the gateway
id off that. 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
read the dispute back with `GET /v1/disputes/{disputeId}`.

⚠️ **`nmi_transaction_id` inside `dispute` is best-effort, not a guarantee.** It is filled by
joining the linked sale; an unmatched dispute, or one whose sale has not synced yet, carries an
empty value. Treat it as "present when we could resolve it".

The flat keys beside it are the disputes engine's own event and differ per name:
camelCase, `disputeId` rather than `id`, `amount` as a `{minorUnits, currencyCode}`
object. They stay for subscribers written against them, but they cannot tell you which
charge the dispute names in *your* system, read `dispute` first. `pre_dispute_alert.*`
works the same way, under `data.object.alert`. Field-by-field breakdown in [Webhooks →
Dispute payloads](./16-webhooks.md#dispute-payloads) and [Alert
payloads](./16-webhooks.md#pre-dispute-alert-payloads).
| `dispute_recovery.opened` | A customer crossed the dispute threshold and was invoiced + blocked |
| `dispute_recovery.resolved` | A dispute recovery settled and the block lifted |
| `pre_dispute_alert.created` | A pre-dispute alert (Ethoca / Verifi CDRN / Visa RDR) arrived, before it can become a chargeback |
| `pre_dispute_alert.updated` | A pre-dispute alert changed: linked to a sale, unlinked or moved to another sale, refunded, dismissed or escalated. Carries `outcome`, the link-change keys below when the sale changed, and `refundStatus=SETTLED` once the gateway confirms the refund |

> **`pre_dispute_alert.created` and `dispute.created` can both fire for the same
> event.** They are the same underlying occurrence in one of its flavours, so an
> endpoint subscribed to both receives it twice, with two different `type` values and
> two different event ids. Dedupe on the payload's alert or dispute id rather than on
> `X-Webhook-Id` if you subscribe to both.
>
> `refundStatus` is worth reading carefully: it distinguishes a refund this platform
> **requested** from one the gateway has **confirmed settled**. Acting on the first as
> though it were the second is how a chargeback still lands after you believed it was
> refunded.

**A case that left its sale.** When we unlink a pre-dispute alert or a dispute from a sale,
move it to another one, or give a dispute we delivered without a sale its sale after review,
`pre_dispute_alert.updated` or `dispute.updated` carries three extra keys, on that update
only:

| Key | Meaning |
|---|---|
| `previousTransactionId` | The sale the case was linked to before the change (absent on `LINKED`) |
| `linkChange` | `UNLINKED` (no sale now; `transactionId` is absent), `RELINKED` (moved to `transactionId`) or `LINKED` (a dispute delivered without a sale now names `transactionId`) |
| `linkChangeReason` | `EVIDENCE_CONFLICT`, `DECLINED_ATTEMPT`, `NOT_A_SALE`, `ARN_COLLISION`, `OPERATOR`, `KOUNT_TWIN_SYNC` or `REMATCH` |

If you acted on the old link, for example flagged the customer behind
`previousTransactionId`, undo it for this case. Each real change is delivered once, and a
re-import of historical data never delivers one. What each reason means, and an example
body: [Webhooks → Link changes](./16-webhooks.md#link-changes).

**Decline recovery (Smart Retry).** Terminal outcomes only.

| Event | When |
|---|---|
| `decline_recovery.recovered` | A retry succeeded and the customer was charged |
| `decline_recovery.failed` | A recovery case was exhausted or retired without recovery |
| `decline_recovery.hard_declined` | The decline cannot be retried at all (lost/stolen/expired/fraud); the customer must supply a new card |

**Payouts.**

| Event | When |
|---|---|
| `payout.created` | A payout was created |
| `payout.paid` | A payout was paid |
| `payout.failed` | A payout failed |

The dashboard's **Developers → Webhooks → Add endpoint** dialog lists this same set,
served from the API (`GET /webhooks/events`), so the two cannot drift apart. Creating
an endpoint with a name that is not on this list is rejected rather than silently
accepted.
