Webhooks
View as MarkdownWebhooks notify your server when something happens, most importantly when a charge succeeds. They let your backend react to events without polling the API.
Registering an endpoint#
Register an endpoint from the dashboard or over the API. Both manage the same objects.
Dashboard: Developers → Webhooks → Add endpoint. Enter your HTTPS URL and select the events you want.
API (requires the webhooks:write scope, or webhooks:read for the reads):
| Method | Path | Purpose |
|---|---|---|
POST | /v1/webhook-endpoints | Create an endpoint |
GET | /v1/webhook-endpoints | List endpoints |
GET | /v1/webhook-endpoints/{id} | Retrieve one endpoint |
DELETE | /v1/webhook-endpoints/{id} | Delete an endpoint |
POST | /v1/webhook-endpoints/{id}/roll-secret | Roll the signing secret |
Either way you get a signing secret used to verify deliveries (see below).
Events#
Subscribe only to events you handle. The charge / capture path commonly emits:
| Event | When | Payload |
|---|---|---|
charge.succeeded | Sale capture, Payment Element success, or manual capture settles | transactionId, amount, instrument, and detail (see below) |
charge.authorized | capture_method: manual (or delayed capture) holds funds | The same set, plus detail |
charge.failed | Decline / hard failure, including a charge our fraud screening blocked (below) | The same set, plus the decline summary and detail |
charge.canceled | Authorization voided | transactionId, amount, instrument, orderReference, metadata. No detail |
charge.expired | An authorization nobody captured ran past the card network's window and was canceled (it also fires charge.canceled) | The same set as charge.canceled. No detail |
charge.refunded | Full or partial refund | The same set, plus refundAmount, parentTransactionId and detail |
dispute.created | A dispute was opened (also fires for a pre-dispute alert) | Dispute event (see below) |
dispute.won / .lost | The dispute was decided (each also fires dispute.closed) | Dispute event; .lost adds fee |
dispute.updated | Evidence was submitted, or the dispute left the sale it named | Dispute event (see link changes) |
dispute.funds_withdrawn / .funds_reinstated | Disputed funds left or returned to your balance | Dispute event |
pre_dispute_alert.created | Ethoca / CDRN / RDR alert received, ahead of any chargeback | Alert event (see below) |
pre_dispute_alert.updated | The alert moved: matched to a sale, unlinked or moved to another sale, refunded, or resolved | Alert 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:
{
"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, somid,processor,gateway,acquirerCode,acquirerLabelanddecline.networkDeclineCodeare absent. An issuer decline reads"NETWORK"and a gateway refusal"GATEWAY".transactionIdis thechargethePOST /v1/charges400 names;GET /v1/charges/{id}reads it back withoutcome.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-Keyreplays the refusal and sends no second webhook. - A fraud pre-screen (velocity) block sends no webhook. It is visible on the charge list, but it raises no event.
Subscriptions, invoices and orders#
These carry the whole record, not just an id and a total, so a webhook-only integration can act without calling back.
| Event | When | Payload |
|---|---|---|
subscription.created / .updated | Created, or plan/quantity/trial changed | detail, see below |
subscription.renewed | A new billing period started | detail, incl. amount, interval and currentPeriodEnd |
subscription.past_due | A renewal charge failed | detail, incl. the recurring amount and currentPeriodEnd |
subscription.suspended | A hard decline ended the retries | detail |
subscription.canceled / .paused / .resumed | Lifecycle change | detail, incl. cancelAtPeriodEnd and cancellationReason |
subscription.usage_exceeded | Metered usage passed the limit | currentUsage, usageLimit, percentOver. No detail |
invoice.created / .finalized | Drafted, or finalized (number and URLs stamped) | detail, see below |
invoice.sent | We emailed it to your customer | detail, plus a top-level hostedInvoiceUrl and checkoutUrl, both live |
invoice.overdue | Past its due date, unpaid | detail, incl. amountRemaining |
invoice.paid / .payment_succeeded | amountRemaining reached zero | detail, incl. the lines |
invoice.voided | Voided | detail. Check amountPaid: a void can still owe a refund |
order.created / .paid / .fulfilled / .cancelled | Order lifecycle | order, the stored order row minus its relations |
payment_link.created / .updated / .deactivated | A link's own lifecycle | Link id, code, and on .updated a changedFields list |
payment_link.completed | A checkout through a link finished | Checkout session |
inventory.low_stock | A store product hit its low-stock threshold on a sale | productId, storeId, quantityAfter, threshold (but see the zero-value rule below) |
There is no invoice.payment_failed. A card renewal that declines is
subscription.past_due. Do not wait for an invoice event that is not in this
catalog.
payment_link.updatednames the fields that changed, not their new values.changedFieldsis["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.*andproduct.*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_dueand.suspendedalso deliver assubscription.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 tocharge.*andtransaction.*.
charge.succeededandcharge.capturedare the same delivery moment, not two stages. One captured sale fans out totransaction.succeeded,charge.succeededandcharge.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. Socharge.capturedis not "the second half of auth-then-capture". An endpoint subscribed to bothcharge.succeededandcharge.capturedreceives every sale twice. Pick one name.charge.authorizedis the event that means funds are held and nothing has settled.
order.created,order.paidandorder.completedall 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.completedis an alias oforder.paid, andorder.createdis 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.fulfilledwithorder.trackingNumberempty; adding a tracking number the order did not already hold emitsorder.fulfilledagain, 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 secondorder.fulfilledas 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_stockomitsquantityAfterwhen 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 noquantityAfterkey at all rather thanquantityAfter: 0. Read it asNumber(o.quantityAfter ?? 0).thresholdbehaves 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.
{
"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.objectkeys are camelCase, and the envelope's are snake_case. The envelope (api_version,livemode) is ours;data.objectis a protobuf message rendered with the canonical JSON mapping, which convertstransaction_idtotransactionId. So it ise.data.object.transactionId, note.data.object.transaction_id. The snake_case form returnsundefined. This applies to every event type, disputes included.amountis an object, not an integer.amount.minorUnitsis the integer cents as a JSON string (protobuf renders 64-bit integers that way), andamount.currencyCodeis uppercase ISO-4217.Number(e.data.object.amount.minorUnits ?? 0)gives you2000. Note this differs from the REST API, whereamountis a plain integer andcurrencyis lowercase. The?? 0is not defensive habit. A zero money really does arrive as{"currencyCode":"USD"}with nominorUnitskey. See the zero-value rule.- The field names are not the REST field names.
data.objectis the event as the engine emits it, not the charge objectPOST /v1/chargesreturns: the id istransactionId, the card isinstrument, there is nostatusfield, and themetadatayou sent onPOST /v1/charges(or on the payment intent the charge was made from) is underdetail(detail.metadata) — at the top level only oncharge.canceled/charge.expired, which have nodetail. The outcome is already in the eventtype.processoris 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 NMIprocessor_idon the sale is that row's charge code (Connector code (processor_id)). Connector codes and that charge code are not this field.midis omitted when the row has none.processorTransactionIdis the gateway's own id (NMItransactionid). Disputes and pre-dispute alerts are the exception: they nest the REST object itself underdata.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 number0,false, and an enum sitting on its first value. So there is no"", no0and nofalseanywhere underdata.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:livemodeis a realfalseon a test-mode delivery.
Matching a delivery back to your order#
On the money events, read data.object.detail.orderReference. It is your own
reference, echoed back. That is the direct join, and it is the one to use on:
| event | |
|---|---|
charge.authorized / transaction.authorized | |
charge.succeeded / charge.captured / transaction.succeeded | |
charge.failed / transaction.failed | a decline carries it too |
charge.refunded / transaction.refunded | matches the refund to the original order |
charge.canceled / transaction.voided | reads it at the top level, not under detail, as is metadata; a void raised from settlement carries neither |
charge.expired / transaction.expired | top level, as on charge.canceled |
detail carries the rest of the record with it: the buyer as you supplied them,
what the gateway answered (authCode, responseCode, avsResult) and the card
expiry. On the refund events it also carries the settlement trail (arn,
settlementBatchId) as it arrives. See The detail block below.
Where
detailrides, exactly. It is on the first four rows of the table above (authorization, capture, decline, refund, under both name families), ontransaction.enriched, on everysubscription.*event exceptusage_exceeded, on everyinvoice.*event, and oncustomer.created/customer.updated. It is not oncharge.canceled/transaction.voidedorcharge.expired/transaction.expired, not ontransaction.updated, and not on eithercharge.three_d_secure.*event. Three families carry a different shape instead, and none of them has adetailkey:dispute.*andpre_dispute_alert.*nest their REST resource (data.object.dispute,data.object.alert) and are joined through thetransactionIdthey name;order.*nests the order row atdata.object.order.customer.deletedcarries neither; by then there is nothing left to send.customer.created/.updatedcarry no transaction id at all: they are keyed on the customer id, anddetailis the customer record, not a payment.
It is a snapshot of what we stored, so it can be absent.
orderReferenceis 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 fordetail.metadata, themetadatayou sent onPOST /v1/chargesor 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 asdetail?.metadata?.order_id. The whole block is optional in the contract, so keep reading it asdetail?.…. 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 declaresdetailnow 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:
- When your server creates the payment intent, store the returned
id(pi_…) andclient_secretagainst your order. - The browser's
onSuccesshands youpaymentIntentId. The samepi_…. That is your first, immediate confirmation, and it is already tied to your order. - To resolve the intent to the transaction the webhook will name, read
GET /v1/payment_intents/{client_secret}(the secret, not thepi_…id) and takelatest_charge. That value is thetransactionIdyou will see indata.object. (latest_chargeis snake_case because it is a REST field; the webhook renders the same value under a camelCase key.) A retrieve with onlypi_…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.
{
"type": "charge.refunded",
"data": { "object": {
"transactionId": "TXN-337b3790-1bc1-436e-baf3-dbe688b393fb",
"parentTransactionId": "cmrze8mzt000101lqu51gwhp9:12480511238",
"amount": { "minorUnits": "7060", "currencyCode": "USD" },
"refundAmount": { "minorUnits": "7060", "currencyCode": "USD" },
"instrument": { "brand": "visa", "last4": "5013" },
"gateway": "NMI", "acquirerLabel": "Payarc", "nmiUsername": "YourGatewayLogin",
"detail": {
"orderReference": "ORD-77431",
"billingName": "Dana Whitfield",
"billingEmail": "dana@example.com",
"billingPhone": "+14155550142",
"billingLine1": "1200 Harrison St",
"billingCity": "San Francisco",
"billingState": "CA",
"billingPostal": "94103",
"billingCountry": "US",
"authCode": "A7X21B",
"responseCode": "100",
"responseMessage": "Refund approved",
"avsResult": "Y",
"cardExpMonth": 11,
"cardExpYear": 2029,
"arn": "74567890123456789012345",
"settlementBatchId": "BATCH-5512"
}
}}
}
What it holds, and how reliably. These are fill rates measured across the stored transaction rows. They say how often we hold a value, not whether the block is on the delivery, and they are not a guarantee for any one transaction:
| Field | On a charge | On a refund | |
|---|---|---|---|
orderReference | ~100% | ~100% | your own key for the order |
metadata | when you sent it | issued here only | your metadata from POST /v1/charges, or from the payment intent the charge was made from |
responseCode / responseMessage | ~99% | ~99% | what the gateway said |
cardExpMonth / cardExpYear | ~99% | ~98% | expiry; brand/last4 ride instrument |
billingName / billingEmail | ~99% | ~95% | the buyer as you supplied them |
billingLine1 / City / Country | ~99% | ~95% | |
billingPhone | ~87% | ~72% | |
authCode | ~59% | ~87% | approvals only |
avsResult / cvvResult | ~94% / ~28% | ~86% / not sent | address and CVV verdicts |
arn | not sent | ~27% | refunds only, see below |
settlementBatchId | not sent | ~43% | refunds only |
arnandsettlementBatchIdare on the REFUND events only. They are not carried oncharge.*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 totransaction.enriched, the event that fires when clearing data lands.
Sparse is not missing. An
arnis 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 usetransaction.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.
refundReasonis 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
paymentMethodTypeare on charge events, not refund events. A refund is not re-authorised, so it has no SCA outcome of its own. Read them from thecharge.*event for the original sale. walletTyperides 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.walletonGET /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 chargeinstrument.last4is the device token's, not the buyer's physical card; the physical card's last four and network ride aswalletCardLast4/walletCardBrandwhen the wallet sheet stated them (omitted otherwise). All three are on the event raised when the charge is decided (charge.succeededon an automatic capture,charge.authorized,charge.failed) and on a later manual capture'scharge.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.
"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.
amountis the recurring base for the whole subscription. The plan's price multiplied byquantity. 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. Readamountas "what this agreement bills before adjustments".
amount,intervalandintervalCountride the same priced line, so all three arrive or vanish together. They are on all eight events that carrydetail,created,updated,renewed,past_due,canceled,paused,resumedandsuspended. 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 absentamountmeans not stated on this event, never this subscription bills nothing, and the plan stays the price of record.
dunningStateis 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 thedecline_recovery.recovered/.failed/.hard_declinedevents. So the trio is inert here:dunningStateships the literal"NONE", anddunningAttemptsis always a zero the payload omits.nextDunningAtis 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 todecline_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 isNONE,ACTIVE,FIRST_NOTICE,SECOND_NOTICE,FINAL_NOTICE,SUSPENDED.
These fields exist in the schema and are never populated:
nextBillingAt,endDate,externalId,invoiceDescriptionandtaxRateId. 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, readcurrentPeriodEnd.
No
checkoutUrl, nodefaultPaymentMethodId, nometadata. 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.
"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" }
]
}
amountRemainingis the field to branch on, read it asNumber(detail.amountRemaining?.minorUnits ?? 0). A partially paid invoice is not a paid one, andstatusalone does not tell you which you have. The?? 0is mandatory, not stylistic: on a fully paid invoice the remaining balance is zero, and a zero money has nominorUnitskey (see below).Number(undefined) === NaN, andNaN === 0isfalse, so the naive read reports a paid invoice as still owing, on everyinvoice.paid/invoice.payment_succeededdelivery.- On
invoice.voided, checkamountPaid. A void after a partial payment leaves money that has to be refunded or credited. lineCountis the true total;linesstops at 200. On the rare invoice longer than that,lines.lengthis less thanlineCountand 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" },minorUnitsis a JSON string, because it is a 64-bit integer. A zero money arrives as{ "currencyCode": "USD" }with nominorUnitskey. Zero is the default for an integer, and the payload omits defaults, so the key is gone rather than"0". Always read money asNumber(m?.minorUnits ?? 0). This is not an edge case in this block.amountRemainingis zero on every paid invoice;taxAmountis zero on an invoice with no tax;discountAmountis zero on an undiscounted one;amountPaidis zero oninvoice.createdand.finalized; a line'staxAmountis zero on an untaxed line. Every one of those arrives withcurrencyCodealone.invoice.overdueis not on that list, and assuming it is costs money. The overdue sweep picks up any invoice that is stillOPENwith a balance left past its due date, so a partially paid one is exactly what it fires on.amountPaidis a real figure there. The same reason theinvoice.voidedbullet above tells you to read it.
linesis present on every invoice event. If it is ever absent, that means not sent on this event, never nothing was billed. Read the invoice if you need them.
Where the payable link is. invoice.sent carries two links at the top level
of data.object, beside detail rather than inside it, and both arrive complete:
| Field | What it opens |
|---|---|
hostedInvoiceUrl | the hosted invoice page, view, download, print, and pay |
checkoutUrl | the checkout session that takes the payment |
These are bearer links, deliberately. They are the same links our own invoice email puts in front of your customer, and they work for whoever holds them. That is what makes an invoice payable without a login. Send them on; that is what they are for.
Therefore treat an
invoice.sentbody, 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
detailit is different, and the two fields differ from each other. On everyinvoice.*event,invoice.sentincluded,detail.hostedInvoiceUrlis the invoice page with its?token=removed (a stable reference you can key on, not a way in) anddetail.checkoutUrlis 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. Oninvoice.sentthat 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 outsideinvoice.sent, re-send the invoice from the dashboard; there is no public REST endpoint that returns one.
Retries and auto-disable#
A failed delivery (non-2xx, timeout, or connection error) is retried on a fixed backoff
ladder, one attempt per rung:
| Retry | Delay after previous attempt |
|---|---|
| 1st | 1 minute |
| 2nd | 5 minutes |
| 3rd | 15 minutes |
| 4th | 1 hour |
| 5th | 3 hours |
| 6th | 6 hours |
| 7th | 12 hours |
| 8th | 12 hours |
Retries stop after the 8th. There are 9 attempts in total, spanning roughly 34 hours from the first failure. After that the delivery is marked permanently failed and is never retried again. An outage longer than that window drops those events for good (see reconciling.)
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 a2xxfrom 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 asPENDINGwith no response, or asFAILEDwith 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 inGET /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_LETTERstraight away, with nothing sent, and Retry still sends it. The event is inGET /v1/eventsfor 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:
| Header | Value |
|---|---|
X-Webhook-Signature | v1=<hex>. HMAC-SHA256, hex digest, prefixed with the literal v1=. After POST /webhook-endpoints/{id}/roll-secret, both the new and previous secrets are valid for 24 hours and the header is v1=<current>,v1=<previous>. Verify by testing each v1= part, do not compare the whole header string. |
X-Webhook-Timestamp | Unix seconds at send time. Re-generated on every retry attempt, so it is not the envelope's created field. |
X-Webhook-Id | The occurrence id. The same value as id in the body. Stable across every retry, and identical on every endpoint you have subscribed to that event. Use it to dedupe. |
X-Webhook-Mode | live or test: the mode of the endpoint that signed this delivery. Use it to pick the secret before you read the body. |
X-Webhook-Endpoint-Id | The id of the endpoint that signed this delivery (the id returned when you created it). If you keep one secret per endpoint id, look the secret up by this. |
X-Webhook-Idis not a handle you can fetch. It identifies the occurrence, andGET /v1/events/{id}is keyed on a delivery (one row per subscribed endpoint) so passing the header value there answers404. On the polled feed, this header's value is the item'sevent_id, not itsid. 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-Modeheader (live→ your live endpoint's secret,test→ your test one; the envelope'slivemodesays the same), or byX-Webhook-Endpoint-Id, or try each endpoint's secret in turn. Trust nothing in the body until the signature verifies.
// 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.
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:
// 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:
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:
// 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.
{
"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.matchEvidencecarries 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 ontransactionId, the ARN or the auth code, never on the card digits.alertis absent only when the emitting path had no row to shape, treat a missingalertas "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:
{
"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"
}
}
}
}
disputeis optional ondispute.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 missingdisputeas "fall back to the flat fields", never as an error.dispute.wonanddispute.lostcarry 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
disputeare prefixed; the flat fields beside it are not. The nested object is generated from the protobufDisputemessage, wherestatus,stage,reason,outcome,escalationLevelandpartnerProviderare enums, so the wire carries"DISPUTE_STATUS_NEEDS_RESPONSE", not"NEEDS_RESPONSE". The flatstatusat the top of the payload is a plain string and stays unprefixed. Match on the prefixed form when you readdispute.status, or strip theDISPUTE_STATUS_/DISPUTE_STAGE_prefix yourself. Timestamps insidedispute(respondBy,dueDate,disputeDate,transactionDate) are RFC3339 strings, whilecreatedon the envelope is unix seconds.pre_dispute_alert.*differs here: its nestedalertdeclaresstate,priority,reasonandslaStatusas plain strings, so those arrive unprefixed. The two objects are not interchangeable on this point.
What each event carries. Every key camelCase, and omitted entirely when the producer had no value for it:
| Event | Fields beyond the common set |
|---|---|
dispute.created | reasonCode, status, provider, customerRef |
dispute.updated | reasonCode, status, provider. Fires on evidence submission, and on a link change, which adds customerRef, previousTransactionId, linkChange and linkChangeReason |
dispute.won | customerRef |
dispute.lost | customerRef, fee (a Money object. The dispute fee you were charged) |
dispute.funds_withdrawn | customerRef |
dispute.funds_reinstated | customerRef |
The common set is disputeId, transactionId, merchantAccountId, envMode, amount
and occurredAt.
Four things that bite:
disputeId, notid. Readingdata.object.idreturnsundefined. That value is the same idGET /v1/disputes/{id}takes.dispute.wonanddispute.losteach also firedispute.closed. One decision delivers twice if you subscribe to both. They are separate deliveries with separate event ids, so dedupe ondisputeId+ your own state, not on the event id alone.dispute.createdalso fires for pre-dispute alerts (Ethoca / CDRN / RDR), which are warnings, not chargebacks. If you only want formal disputes, subscribe topre_dispute_alert.createdas well and branch, or reconcile againstGET /v1/disputes. An alert-sourced row carrieskount_alert_idthere.stageis in the nesteddisputeobject, never among the flat fields, readdata.object.dispute.stageto tell an inquiry from a chargeback, no round-trip needed. It is there ondispute.created,.won,.lost,.closed,.updatedand.funds_withdrawn. Ondispute.funds_reinstatedit rides only the shared terminal-resolution lane; on the other five paths that return held funds it is absent, so that one needs thedispute.wonthat usually accompanies it, orGET /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:
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
});
Link changes: a case left the sale it named#
When we remove a pre-dispute alert's or a dispute's link to a sale, move it to another
sale, or give a dispute we delivered without a sale its sale after review, you receive the
update event you already subscribe to: pre_dispute_alert.updated for an alert,
dispute.updated for a dispute. Three keys are added to data.object, on
that update only:
| Key | Meaning |
|---|---|
previousTransactionId | The sale the case was linked to before this change (absent on LINKED) |
linkChange | UNLINKED: the case names no sale now. RELINKED: it moved to the sale in transactionId. LINKED: a dispute you received without a sale now names the sale in transactionId |
linkChangeReason | Why the link changed (see below) |
transactionId keeps its meaning: the sale linked now. On an UNLINKED update the key
is absent, so read the sale that was cleared from previousTransactionId. A LINKED update
has no previousTransactionId: the dispute had no sale before. It follows a review that
matched a chargeback we first delivered unmatched, for example one whose card digits are the
card network's token for the card rather than the card itself.
linkChangeReason | What happened |
|---|---|
EVIDENCE_CONFLICT | The network's authorization code or card digits contradict the sale |
DECLINED_ATTEMPT | The sale was a declined, voided or otherwise unsuccessful attempt, not a completed sale |
NOT_A_SALE | The sale was a refund, not a completed sale. On RELINKED the case moved to the sale that refund reversed |
ARN_COLLISION | The acquirer reference matched more than one sale, so a link made on it alone was withdrawn or moved |
OPERATOR | Our operations team corrected the link after review |
KOUNT_TWIN_SYNC | The alert and the dispute raised from it were put back on the same sale |
REMATCH | Matching proved a different sale |
What to do with it. If you acted on the old link, for example flagged or blocked the
customer behind previousTransactionId, undo that for this case. On RELINKED, apply it
to the sale in transactionId instead. On LINKED, treat it as the dispute's sale from now
on, as you would a dispute that arrived matched.
{
"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 asoutcome: "MATCHED"without them. outcomeon an alert. An open alert readsMATCHEDafter a move andNEWafter an unlink, neverMATCHEDwithout 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 norefundStatus, except while an automatic refund is in flight: that alert readsAUTO_REFUNDEDwithrefundStatus: "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 (MATCHEDorNEW).- 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.
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:
{
"id": "25758130-bb0d-46bd-b65d-b22bd8bf7ccb",
"object": "dispute",
"transaction_id": "9f1c6a2e-77d1-4f0a-9a3e-2b6c4d8e1a55",
"nmi_transaction_id": "12437218480",
"billing_name": "Ada Lovelace",
"billing_email": "ada@example.com",
"cardholder_name": "A. LOVELACE",
"card_bin": "424242",
"reason_message": "Cardholder does not recognise the charge",
"alert_source": "ETHOCA",
"transaction_date": 1786966800,
"respond_by": 1787656800,
"closed_at": null,
"status": "NEEDS_RESPONSE",
"stage": "INQUIRY",
"outcome": "",
"reason": "FRAUDULENT",
"reason_code": "10.4",
"amount": 13550,
"currency": "usd",
"source_acquirer": "Payarc",
"processor_dispute_id": "cb_9f2210",
"kount_alert_id": "f34a64b7-b8ac-4d4c-9245-efb5437d7261",
"card_brand": "visa",
"card_last4": "4242",
"dispute_date": 1787052000,
"due_date": 1787656800,
"created_at": 1787052300,
"updated_at": 1787066400
}
| Field | Notes |
|---|---|
id | This API's dispute id. What dispute.created calls disputeId. |
transaction_id / nmi_transaction_id | The matched sale, and its gateway id. Both "" until the dispute is matched, alert-sourced rows often arrive unmatched. |
billing_name / billing_email | Cardholder name and email on the matched sale, projected the same way. "" when unmatched, or when the sale itself carries no contact, best-effort, not guaranteed. |
cardholder_name | The name as the provider sent it on the dispute. Not the same field as billing_name, which comes from the sale. They disagree often enough to be worth reading both. ~15% populated. |
card_bin | First six PAN digits. Populated on effectively every dispute. |
reason_message | The provider's own wording, alongside the normalised reason / reason_code. ~71% populated. |
alert_source | The pre-dispute network this originated from (ETHOCA, RDR, VERIFI); "" for an ordinary acquirer chargeback. Distinct from source_acquirer, which is who reported it. ~52% populated. |
transaction_date | Unix seconds. When the underlying sale happened, lets you age a dispute without fetching the charge. ~48% populated. |
respond_by | Unix seconds. The provider's stated evidence deadline. due_date is ours; this is theirs. ~59% populated. |
closed_at | Unix seconds. When the dispute reached a terminal state. ~35% populated. |
status | NEEDS_RESPONSE, NEEDS_MANUAL_MATCH, UNDER_REVIEW, WON, LOST, ACCEPTED, EXPIRED, PROCESSING, BUYER_REVIEW, BANK_REVIEW, APPEALABLE, STALE, CLOSED, RELATED_CASE. A RELATED_CASE is a PayArc related case (reversal, resolution record, pre-arbitration, credit, retrieval fulfilment): not a dispute of its own, its parent_dispute_id carries the family's state. |
parent_dispute_id | Set only when status is RELATED_CASE: the dispute that carries the family's state. "" for every other dispute. |
stage | INQUIRY, CHARGEBACK, PRE_ARBITRATION, ARBITRATION, UNCLASSIFIED. Unprefixed here; the webhook's nested dispute.stage carries the same fact as DISPUTE_STAGE_INQUIRY. Not among the webhook's flat fields at all. |
amount | Integer cents (13550 = $135.50), unlike the webhook's amount.minorUnits string. |
outcome | "" while open. |
source_acquirer | Payarc, Maverick Payments, NMI, Nuvei. |
kount_alert_id | Set when the dispute came from an Ethoca/CDRN alert; joins to pre_dispute_alert.id. |
due_date | Evidence deadline, null when the provider set none. Watch this, not dispute_date. |
updated_at | Your high-water mark. |
Unknown strings are "" and unknown timestamps are null, never missing, so a field
access will not throw. (The webhook payload behaves the opposite way: empty fields are
omitted entirely.)
Filter with status and stage if you only care about part of the book. An unrecognised
value is ignored, not rejected. The filter is dropped and you get the unfiltered list,
so check the spelling if a filtered poll returns more than you expected.
GET /v1/disputes/{id} accepts our id, the processor's dispute id, or the Kount request
id (kount_alert_id). Same disputes:read scope. Dedupe on (id, updated_at). A dispute
belonging to another merchant answers 404, identical to one that does not exist.
A row's updated_at can move without a webhook (matcher sweeps, ingest). Poll is the
source of truth for "what changed"; the webhook is the push hint.
Running it on a schedule#
The whole job, end to end: read the last high-water mark, drain both feeds, store the new mark. Every 5–10 minutes is a sensible cadence. The data is not real-time, and a tighter loop mostly spends your rate limit re-asking a question with no new answer.
// 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:
*/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 byupdated_atguarantees you cannot miss a row, at the cost of sometimes seeing one twice. Upsert onid; do not append.- Start at 0 once, not every run. A first sync with
highWaterMark = 0walks 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.
