Payment links and hosted checkout are server-owned intents with a server-authoritative amount
Context
A buyer pays either through a payment link a merchant shares, or through the hosted checkout on a storefront. In both, the client (a browser, possibly a hostile one) is the thing that submits the pay request — so the central risk is the same as discounts (ADR 0002): if the client can name the amount, the price is forgeable. The amount a card is charged has to be decided by the server and be tamper-proof by the time it reaches the gateway.
There is also a lifecycle question for hosted checkout: does an order exist before payment (so every abandoned cart is a junk order), or only after?
Decision
Represent "an intent to pay X to merchant M" as a server-owned object that fixes the amount server-side; the client only ever references it by an opaque id and can never set the amount.
- Payment link.
Api.Purchase.PaymentLinkis a merchant-owned, publicly payable handle keyed by an opaqueuid, carrying a fixedtotal, currency, expiry, and optionalusage_limit.total,uid, and currency are schema-immutable after creation. It is redeemed at public routes under/api/v1/payments/link/:uid. - Hosted checkout is session-first; the order is created after payment.
SessionBasedCheckoutcreates apayment_sessionsrow holding the server-computed, fee'd total first (page/checkouts/:id); the durable order is materialised only once the provider confirms payment, so abandoned checkouts never become orders. (The older order-first path still exists, where an invoice-style link is created from an already-created order.) - The amount is server-authoritative, in layers. (1) It is stored server-side and referenced only by opaque id — never carried in a client field or URL parameter. (2)
PaymentLink.totalandOrder.totalare schema-immutable. (3) The figure actually charged is the server-recomputedfees.customer_totalfrom the sameTransactionCalculatorfee-group engine as discounts (ADR 0002). (4) For the CDE card flow, that amount is HMAC-signed into a short-lived checkout-intent (<amount@2dp>.<currency>.<expiry>[.<ref>.<mode>], ~10-minute TTL) that the CDE re-verifies byte-for-byte before charging, so a tampered blob is rejected. (5) Settlement is then corroborated against the gateway server-side (ADR 0011) rather than trusting the client. (Note: there is no post-settlement numeric re-check of the charged amount against the order — see ADR 0011.)
Consequences
- Positive: across payment links and hosted checkout, the buyer can only pay the amount the server decided; tampering fails at verification, and the signed-intent canonical is the single contract the edge and the API must agree on.
- Positive: session-first checkout keeps the orders table clean — an order means a real, paid (or genuinely pending) purchase, not an abandoned cart; it also composes with discounts, which re-resolve at order-create (ADR 0002).
- Neutral / to revisit: the signed checkout-intent couples the CDE and commerce-api to one exact canonical string; it must stay byte-identical on both sides, and the signing secret is a shared deploy-time secret. The verifier is intentionally lenient about optional fields so a one-sided rollout/rollback never breaks the money path.
- Neutral / to revisit: two order lifecycles coexist (order-first for links, session-first for hosted checkout); which one applies depends on the entry path.
Alternatives considered
- Client sends the amount (validate only shape): rejected — identical to the discount tampering risk (ADR 0002); the price would be forgeable. The amount is server-decided and, for cards, server-signed.
- Amount in a URL query parameter or hidden form field: rejected — tamperable and leaky (it would put money in logs/history); the amount lives server-side behind an opaque id.
- Create the order up front for hosted checkout: rejected — every abandoned cart becomes a junk order to reconcile and sweep; a session holds the priced intent and the order is materialised on confirmed payment instead.