Payment states
Payment states mirror the underlying provider (card processor, Net30 for invoice). Throttle normalises them so your handler is provider- agnostic.
State machine
stateDiagram-v2 [*] --> pending: POST /payments or buyer click pending --> processing: provider needs more time (3DS, redirect, bank debit) pending --> authorized: Provider/Net30 authorizes pending --> failed: provider declines processing --> authorized: challenge cleared processing --> captured: settled processing --> failed: challenge abandoned or declined authorized --> captured: capture call authorized --> voided: void before capture authorized --> expired: authorization outlives its TTL captured --> refunded: full refund captured --> partially_refunded: partial refund partially_refunded --> refunded: subsequent refunds total to full refunded --> [*] voided --> [*] expired --> [*] failed --> [*]
States
-
pending— payment row created, no provider response yet. UI shows a spinner; back-end has the row id but no auth/capture proof. -
processing— the provider has neither approved nor declined yet: the buyer is mid-3DS challenge, away on a redirect method, or the debit is still settling. Nothing has failed, so there is noerror.code— treat this as "keep waiting", not as a decline. Firespayment.processing, and a resolution event (payment.authorized,payment.capturedorpayment.failed) always follows. -
authorized— provider authorised the amount (funds reserved). Most card flows auto-capture so this is brief; manual-capture flows stay here until the merchant explicitly captures. -
captured— funds moved. Triggerspayment.capturedoutbound event. Order can now transition topaid. Capture may be partial: pass anamountbelow the authorized total toPOST /v1/payments/{id}/capture. The captured value is tracked oncapturedAmount(the event carries bothcapturedAmountandauthorizedAmount); the uncaptured remainder is released by the processor, and refunds are capped at the captured amount. -
failed— provider declined OR a network/timeout error during processing. Carrieserror.code+error.message. Terminal. A merchant can also declare it by hand withPOST /payments/{id}/mark-failed— the promised bank transfer that never arrived, the cheque that bounced — which writeserror.code: "manually_marked_failed"and the supplied reason. Only apending,processingor already-failedpayment is failable by hand; anything resolved returns422 payment_not_failable. -
voided— authorisation released before capture. Funds never moved. Terminal. -
expired— the authorization outlived its usable life and was never captured, so the hold has lapsed at the provider. Throttle sweeps these itself because providers do not reliably notify on expiry, and firespayment.expiredwhen it does. To collect this money you must charge again; capture will not work. Terminal. -
partially_refunded— at least one refund processed but total refunded is less than captured amount. Still accepts further refunds. -
refunded— total refunded equals captured amount. Terminal.
Refunds
-
POST /v1/payments/{id}/refundreturns money from one payment.POST /v1/orders/{id}/refundworks on the order: when an order was paid more than once — a deposit and a balance, an instalment plan, a retried charge that settled on a second row — the refund is spread across its captured payments, newest capture first, andamountmay exceed any single one of them. Omitamountto return everything still refundable on the order. -
Asking for more than remains returns
400 refund_amount_exceeds_balance, whose message names the amount that is still refundable. The check runs before any money moves, so a refund that cannot be completed in full sends none of it. -
A refund claims its amount before the provider is called, so a second
refund of the same payment arriving at the same moment gets
409 refund_already_in_progressrather than passing a stale balance check and returning the money twice. Wait for the first to resolve, then re-read what is refundable.
Net30 special cases
-
Net30 payments go through
pending→authorizedon invoice issue, then →capturedwhen the buyer pays the invoice (manual mark-paid OR ACH webhook). -
invoice.overduefires when an authorized Net30 payment passes itsdueDate; the payment row staysauthorizeduntil paid or voided.
Disputes & chargebacks
Disputes are tracked out-of-band from the status machine, on the disputed boolean plus disputeReason / disputeOpenedAt (all exposed on
the payment object). A payment keeps its underlying status (usually captured) while disputed.
- Card chargebacks are ingested automatically from the
payment provider: an inbound
dispute.*/chargeback.*webhook setsdisputed = trueand firespayment.disputed. A merchant-favourable resolution (won / reversed) clears the flag and firespayment.dispute_cleared. - Net30 disputes are opened/cleared manually from the dashboard and fire the same two events.