Overview / Payments
ADR 0011
Card settlement is server-authoritative, confirmed from the gateway, not the browser
AcceptedPayments
Context
In a 3-D Secure card flow the browser is in the loop: it renders the ACS challenge and is redirected back with a result. The dangerous shortcut is to let the browser tell the server "authentication succeeded, mark it paid" — a forgeable claim. Settlement must instead be driven by something only the server and the gateway share.
There is also the classic double-charge risk: a request that times out after the gateway charged the card but before we recorded it, and a provider webhook or callback that could be replayed or spoofed.
Decision
Only the server marks an order paid, and only from a server-captured gateway result — never from a value the browser could set or relay.
- The settle token never touches the browser (SERVER_SIDE_SETTLE). When the money path is server-side settled, the CDE binds the single-use gateway
spi_tokento its invoice server-to-server and the browser receives only the challenge redirect data — never thespi_token,card_ref, or PanToken. After the ACS, the gateway redirects to a serverMerchantResponseUrl; the authoritative 3-D Secure outcome is read from that server-captured callback (persisted against thespi_token), and only then is the money completed, on the strict Y+CAVV gate (ADR 0010). A broken binding fails closed — no binding, no settle, no money moves. - Idempotency at the point of settle. The live path guards against re-settling with an already-paid check, single-use
spi_tokens (the binding is deleted before completion), and the gateway's own duplicate-transaction rejection. The newer control plane strengthens this with two-layer idempotency (HTTP idempotency key + gateway completion replay) and crash-after-charge reconciliation: it anchors on the order identifier and confirms state with the gateway (getTransaction) before any retry, and records every outcome in the append-only audit ledger (ADR 0007). - Provider callbacks are corroborated, not blindly trusted. The provider's server-to-server callback/webhook triggers settlement, but the money is completed from the server-captured 3DS result behind the Y+CAVV gate rather than on the callback's say-so. The new control plane goes further and treats a webhook as a hint, confirming the true state from the gateway (
getTransaction) rather than mutating the ledger on the webhook's word alone (no provider webhook signature is assumed to exist). (Note: the live FAC path does not re-validate the settled amount/currency against the order — anvalidate_totalguard for that is dead code; see below.)
Consequences
- Positive: a tampered or replayed browser message cannot cause a charge to be recorded as paid; the authentication result and the settle token stay on the server side.
- Positive: the crash-after-charge window is closed by gateway reconciliation in the new control plane, so a timeout after the charge does not become a double charge or a lost sale.
- Neutral / to revisit: the robust two-layer idempotency and
getTransactionreconciliation live incommerce-payments; the still-livecommerce-adapterspath relies on the simpler guards above. Full coverage arrives as paths cut over (ADR 0009). - Neutral / to revisit: the amount charged is made to equal the order total by construction (the signed, server-computed amount of ADR 0012 and webhook re-validation of total/currency), not by a separate post-settlement numeric equality check — an earlier
validate_totalguard is now dead code. A defensive post-hoc compare could be re-added if a path ever settles an unsigned amount.
Alternatives considered
- Trust the browser-relayed 3DS result / hand it the settle token: rejected — both are forgeable; the outcome and the token must stay server-side (server-captured callback + server-to-server bind).
- Mark paid on the provider webhook alone, without corroboration: rejected — no webhook signature is assumed, and a spoofed or replayed webhook could otherwise move an order to paid; the state is confirmed from the gateway.
- No crash-after-charge reconciliation, rely on retries: rejected — a post-charge timeout would double-charge or drop the sale; anchoring on the order id and reconciling via
getTransactionmakes the settle idempotent across crashes.