Events

View as Markdown

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 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.

FieldWhat it identifiesUse it to
idthis delivery. One row per subscribed endpoint, and the only value GET /v1/events/:id resolvesignore a retry of the same delivery
event_idthe occurrence. One value shared by every delivery of it. This is the X-Webhook-Id header, and the id inside the delivered envelopeignore 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.

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.

EventWhen
charge.succeededA charge was captured successfully
charge.authorizedA capture_method: manual charge was authorized (funds held)
charge.capturedA previously authorized charge was captured
charge.failedA charge was declined
charge.refundedA charge was refunded
charge.canceledAn authorized charge was voided
charge.expiredAn 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.succeededDashboard-naming equivalent of charge.succeeded
transaction.authorizedDashboard-naming equivalent of charge.authorized
transaction.failedDashboard-naming equivalent of charge.failed
transaction.refundedDashboard-naming equivalent of charge.refunded
transaction.voidedDashboard-naming equivalent of charge.canceled
transaction.expiredDashboard-naming equivalent of charge.expired
transaction.updatedA transaction you already know about learned new facts. Not a money event (see below)
transaction.enrichedThe 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.challengedThe issuer required a challenge and it was shown to the buyer
charge.three_d_secure.abandonedA 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.

EventWhen
payment_method.attachedA buyer saved a card for future payments
payment_method.updatedA saved card changed, usually a new expiry after the issuer re-issued it
payment_method.detachedA 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).

EventWhen
setup_intent.succeededA setup intent verified and saved a card with no payment. payment_method names the saved card
setup_intent.setup_failedA 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.

EventWhen
customer.createdA new customer was created
customer.updatedA customer was updated
customer.deletedA 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.

EventWhen
checkout.session.completedA checkout session completed. A payment intent's carries its metadata as it stood when paid (absent when it has none)
payment_link.completedA payment-link checkout completed
payment_link.createdA payment link was created
payment_link.updatedA live link was edited. Carries changedFields: the names, not the values
payment_link.deactivatedA payment link was deactivated
inventory.low_stockA store product hit its low-stock threshold on a sale
order.createdAn order was placed. Fires together with .paid and .completed (see below)
order.paidAn order was paid
order.completedAlias of order.paid
order.fulfilledAn order was fulfilled
order.cancelledAn 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.

EventWhen
subscription.createdA subscription was created
subscription.updatedA subscription was updated
subscription.canceledA subscription was canceled
subscription.pausedA subscription was paused
subscription.resumedA subscription was resumed
subscription.renewedA subscription renewed for a new billing period
subscription.past_dueA renewal payment failed
subscription.suspendedA subscription was suspended after a hard decline
subscription.usage_exceededMetered usage exceeded the subscription limit
invoice.createdAn invoice was created
invoice.finalizedAn invoice was finalized. The number and the hosted URLs are stamped here
invoice.sentWe emailed the invoice to your customer
invoice.overdueAn invoice passed its due date unpaid
invoice.paidAn invoice was paid
invoice.payment_succeededAn invoice payment succeeded
invoice.voidedAn 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 and 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.)

Disputes.

EventWhen
dispute.createdA 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.updatedEvidence was submitted for a dispute, or the dispute was unlinked from or moved off the sale it named (see below)
dispute.wonYou won a dispute, also delivers dispute.closed
dispute.lostYou lost a dispute, also delivers dispute.closed; the payload adds fee
dispute.closedA dispute reached a terminal state. Delivered two ways (see below)
dispute.funds_withdrawnDisputed funds were withdrawn from your balance
dispute.funds_reinstatedDisputed funds were returned after a win

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

SourceCarriesTell them apart by
the second delivery of a .won / .lostamount, fee (on lost), the full disputeamount 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 amountprovider 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.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:

KeyMeaning
previousTransactionIdThe sale the case was linked to before the change (absent on LINKED)
linkChangeUNLINKED (no sale now; transactionId is absent), RELINKED (moved to transactionId) or LINKED (a dispute delivered without a sale now names transactionId)
linkChangeReasonEVIDENCE_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.

EventWhen
decline_recovery.recoveredA retry succeeded and the customer was charged
decline_recovery.failedA recovery case was exhausted or retired without recovery
decline_recovery.hard_declinedThe decline cannot be retried at all (lost/stolen/expired/fraud); the customer must supply a new card

Payouts.

EventWhen
payout.createdA payout was created
payout.paidA payout was paid
payout.failedA 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.