Webhooks

View as Markdown

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):

MethodPathPurpose
POST/v1/webhook-endpointsCreate an endpoint
GET/v1/webhook-endpointsList endpoints
GET/v1/webhook-endpoints/{id}Retrieve one endpoint
DELETE/v1/webhook-endpoints/{id}Delete an endpoint
POST/v1/webhook-endpoints/{id}/roll-secretRoll 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:

EventWhenPayload
charge.succeededSale capture, Payment Element success, or manual capture settlestransactionId, amount, instrument, and detail (see below)
charge.authorizedcapture_method: manual (or delayed capture) holds fundsThe same set, plus detail
charge.failedDecline / hard failure, including a charge our fraud screening blocked (below)The same set, plus the decline summary and detail
charge.canceledAuthorization voidedtransactionId, amount, instrument, orderReference, metadata. No detail
charge.expiredAn 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.refundedFull or partial refundThe same set, plus refundAmount, parentTransactionId and detail
dispute.createdA dispute was opened (also fires for a pre-dispute alert)Dispute event (see below)
dispute.won / .lostThe dispute was decided (each also fires dispute.closed)Dispute event; .lost adds fee
dispute.updatedEvidence was submitted, or the dispute left the sale it namedDispute event (see link changes)
dispute.funds_withdrawn / .funds_reinstatedDisputed funds left or returned to your balanceDispute event
pre_dispute_alert.createdEthoca / CDRN / RDR alert received, ahead of any chargebackAlert event (see below)
pre_dispute_alert.updatedThe alert moved: matched to a sale, unlinked or moved to another sale, refunded, or resolvedAlert event (see below)

That is the payments subset. The rest covers customers, checkout sessions, orders, subscriptions, invoices, disputes, decline recovery, and payouts. See the full catalogue. 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.

EventWhenPayload
subscription.created / .updatedCreated, or plan/quantity/trial changeddetail, see below
subscription.renewedA new billing period starteddetail, incl. amount, interval and currentPeriodEnd
subscription.past_dueA renewal charge faileddetail, incl. the recurring amount and currentPeriodEnd
subscription.suspendedA hard decline ended the retriesdetail
subscription.canceled / .paused / .resumedLifecycle changedetail, incl. cancelAtPeriodEnd and cancellationReason
subscription.usage_exceededMetered usage passed the limitcurrentUsage, usageLimit, percentOver. No detail
invoice.created / .finalizedDrafted, or finalized (number and URLs stamped)detail, see below
invoice.sentWe emailed it to your customerdetail, plus a top-level hostedInvoiceUrl and checkoutUrl, both live
invoice.overduePast its due date, unpaiddetail, incl. amountRemaining
invoice.paid / .payment_succeededamountRemaining reached zerodetail, incl. the lines
invoice.voidedVoideddetail. Check amountPaid: a void can still owe a refund
order.created / .paid / .fulfilled / .cancelledOrder lifecycleorder, the stored order row minus its relations
payment_link.created / .updated / .deactivatedA link's own lifecycleLink id, code, and on .updated a changedFields list
payment_link.completedA checkout through a link finishedCheckout session
inventory.low_stockA store product hit its low-stock threshold on a saleproductId, 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), 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"
    }
  }
}

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.
  • 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 and 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.faileda decline carries it too
charge.refunded / transaction.refundedmatches the refund to the original order
charge.canceled / transaction.voidedreads it at the top level, not under detail, as is metadata; a void raised from settlement carries neither
charge.expired / transaction.expiredtop 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 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:

FieldOn a chargeOn a refund
orderReference~100%~100%your own key for the order
metadatawhen you sent itissued here onlyyour 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 sentaddress and CVV verdicts
arnnot sent~27%refunds only, see below
settlementBatchIdnot 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:

FieldWhat it opens
hostedInvoiceUrlthe hosted invoice page, view, download, print, and pay
checkoutUrlthe 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:

RetryDelay after previous attempt
1st1 minute
2nd5 minutes
3rd15 minutes
4th1 hour
5th3 hours
6th6 hours
7th12 hours
8th12 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.)

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.

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, 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 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 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:

HeaderValue
X-Webhook-Signaturev1=<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-TimestampUnix seconds at send time. Re-generated on every retry attempt, so it is not the envelope's created field.
X-Webhook-IdThe 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-Modelive 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-IdThe 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.

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.

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, walking the pages as described in Pagination. 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).

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.

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:

EventFields beyond the common set
dispute.createdreasonCode, status, provider, customerRef
dispute.updatedreasonCode, status, provider. Fires on evidence submission, and on a link change, which adds customerRef, previousTransactionId, linkChange and linkChangeReason
dispute.woncustomerRef
dispute.lostcustomerRef, fee (a Money object. The dispute fee you were charged)
dispute.funds_withdrawncustomerRef
dispute.funds_reinstatedcustomerRef

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
});

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:

KeyMeaning
previousTransactionIdThe sale the case was linked to before this change (absent on LINKED)
linkChangeUNLINKED: 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
linkChangeReasonWhy 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.

linkChangeReasonWhat happened
EVIDENCE_CONFLICTThe network's authorization code or card digits contradict the sale
DECLINED_ATTEMPTThe sale was a declined, voided or otherwise unsuccessful attempt, not a completed sale
NOT_A_SALEThe sale was a refund, not a completed sale. On RELINKED the case moved to the sale that refund reversed
ARN_COLLISIONThe acquirer reference matched more than one sale, so a link made on it alone was withdrawn or moved
OPERATOROur operations team corrected the link after review
KOUNT_TWIN_SYNCThe alert and the dispute raised from it were put back on the same sale
REMATCHMatching 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
}
FieldNotes
idThis API's dispute id. What dispute.created calls disputeId.
transaction_id / nmi_transaction_idThe matched sale, and its gateway id. Both "" until the dispute is matched, alert-sourced rows often arrive unmatched.
billing_name / billing_emailCardholder 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_nameThe 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_binFirst six PAN digits. Populated on effectively every dispute.
reason_messageThe provider's own wording, alongside the normalised reason / reason_code. ~71% populated.
alert_sourceThe 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_dateUnix seconds. When the underlying sale happened, lets you age a dispute without fetching the charge. ~48% populated.
respond_byUnix seconds. The provider's stated evidence deadline. due_date is ours; this is theirs. ~59% populated.
closed_atUnix seconds. When the dispute reached a terminal state. ~35% populated.
statusNEEDS_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_idSet only when status is RELATED_CASE: the dispute that carries the family's state. "" for every other dispute.
stageINQUIRY, 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.
amountInteger cents (13550 = $135.50), unlike the webhook's amount.minorUnits string.
outcome"" while open.
source_acquirerPayarc, Maverick Payments, NMI, Nuvei.
kount_alert_idSet when the dispute came from an Ethoca/CDRN alert; joins to pre_dispute_alert.id.
due_dateEvidence deadline, null when the provider set none. Watch this, not dispute_date.
updated_atYour 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.