Events
View as MarkdownEvery 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 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#
curl "https://api.worthypayments.com/v1/events?limit=20" \
-H "x-api-key: sk_test_xxx"
{
"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/eventslists 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/eventsas 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/customersand friends are queryable over the full record, and/v1/pre_dispute_alertstakesupdated_sincefor 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#
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#
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_afteris 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 a200and no gap marker. Store the highestcreatedyou have processed and pass it ascreated_afteron 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 a400rather 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_afterhere is a 1-based PAGE NUMBER, not an object id. Every other list in this API is cursor-paginated and takes the previous page'snext_cursor;/eventsis the exception, andnext_cursoron this endpoint is simply the next page number as a string.This matters because passing an id fails silently: a non-numeric
starting_afteris 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 thenext_cursoryou were given, and stop whenhas_moreisfalse.
Managing webhook endpoints#
Register an endpoint to receive events. The signing secret is returned once on create, so store it to verify deliveries.
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"] }'
{ "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-endpointslists them, with secrets omitted.DELETE /v1/webhook-endpoints/:idremoves one.POST /v1/webhook-endpoints/:id/roll-secretrotates 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.
{
"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.
{
"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.paidandorder.completedare one moment, not three. An order row is written already paid, and that single write emits all three names at once,order.completedis an alias oforder.paid, andorder.createdis 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 asdispute.won→dispute.closedandsubscription.renewed→subscription.updated.
The four
order.*events carrydata.object.order, the stored order record: the line items, the shipping and billing addresses, the buyer's contact details, and, on the.fulfilleddelivery that reports a tracking number,trackingNumberandcarrier. Marking an order shipped and adding a tracking number are separate writes, and each is a fulfilment fact. Marking it shipped emitsorder.fulfilledwithorder.trackingNumberempty; 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.digitalDeliveriesis 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 noGET /v1/orders/{id}. Read them in the dashboard.shippingAddressJsonandbillingAddressJsonare JSON strings, not objects. Parse them.
store.*andproduct.*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.*andinvoice.*event carriesdata.object.detail, the full stored record. See The subscription block and The invoice block.subscription.usage_exceededis the exception. It is a metering signal rather than a lifecycle change, and carriescurrentUsage/usageLimit/percentOverinstead.detail.amount,detail.intervalanddetail.intervalCountride one priced line, so they arrive or vanish together. They are on all eightsubscription.*events that carrydetail; they drop out together only when no priced line is stored against the subscription.amountis the recurring base. The plan price ×quantity, before coupon, downgrade credit and tax. Not the settled charge. An absentamountmeans "not stated on this event", never "bills nothing".detail.dunningStateis always"NONE", anddetail.dunningAttemptsis always a zero the payload omits. Billing runs no retry clock; decline recovery lives on thedecline_recovery.*events.detail.nextDunningAtis 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.cancelAtPeriodEndonsubscription.canceleddecides whether access ends now or at the period boundary, anddetail.amountRemainingon any invoice event distinguishes a partially paid invoice from a paid one, read it asNumber(detail.amountRemaining?.minorUnits ?? 0), because a zero money arrives as{"currencyCode":"USD"}with nominorUnitskey.invoice.sentalso carries a livehostedInvoiceUrlandcheckoutUrlat the top level, besidedetailrather than inside it. Both are payable by whoever holds them (see 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 and 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.createdanddispute.createdcan 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 differenttypevalues and two different event ids. Dedupe on the payload's alert or dispute id rather than onX-Webhook-Idif you subscribe to both.
refundStatusis 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.
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.
