Express checkout
POST /api/v1/checkout/express
A single-call endpoint that creates an order, charges a returning customer’s saved card, and returns either success, a 3DS challenge, or a clean fallback. Use it from a “Buy Now” button on product pages, from the AI shopping agent, or from a partner integration that wants to skip the cart flow.
Eligibility
The customer must have a saved card on file before this endpoint can charge them. They opt in by ticking “save this card” on a regular checkout — see save-card consent. Specifically, the customer needs:
- A
stripe_customer_id(created automatically when they first opt in) - A
default_payment_method_id(the card they saved) - At least one address (the default address ships the order)
The endpoint returns { kind: 'fallback', reason: '…' } when any
of these is missing — the storefront should branch on kind and
render the standard checkout flow.
Request
POST /api/v1/checkout/express
x-store-id: 11111111-1111-4111-8111-111111111111
x-customer-id: 22222222-2222-4222-8222-222222222222
Authorization: Bearer sf_live_xxx
Content-Type: application/json
{
"productId": "33333333-3333-4333-8333-333333333333",
"variantId": null,
"quantity": 1,
"requestId": "tap-2026-04-30-abc"
}
requestId is optional — when provided, it forms the Stripe
idempotency key (express:<productId>:<requestId>) so duplicate
taps don’t double-charge.
Response shapes
The HTTP code maps to the outcome.
Completed (200)
{
"kind": "completed",
"orderId": "ord_xxx",
"orderNumber": "EXP-XXX-001",
"chargedAmount": 4900
}
The customer’s saved card was charged successfully. The order is
in confirmed + paid state. chargedAmount is in minor units
(pence/cents).
3DS challenge required (200)
{
"kind": "requires_action",
"orderId": "ord_xxx",
"clientSecret": "cs_3ds_xxx"
}
The bank requires a 3DS challenge. The storefront should mount
Stripe Elements with clientSecret to render the challenge, then
call stripe.handleCardAction(clientSecret) from the browser.
After it resolves, Mercentia’s webhook handler will mark the
order paid (or failed).
Fallback (200)
{
"kind": "fallback",
"reason": "no_saved_payment_method_or_address"
}
The customer isn’t eligible for express checkout. Render the standard checkout flow.
| Reason | Cause |
|---|---|
no_customer_session |
No x-customer-id header — caller is anonymous |
no_saved_payment_method_or_address |
Customer has no saved card or no default address |
product_not_found |
productId doesn’t exist in the calling store (or RLS filtered it) |
zero_amount |
Computed total is <= 0 |
stripe_not_connected |
Merchant hasn’t linked a Stripe account |
Failed (402)
{
"kind": "failed",
"orderId": "ord_xxx",
"reason": "Your card was declined."
}
Stripe rejected the charge. The order row exists in failed state
so the merchant has an audit trail; the customer should be shown
the failure reason and given a path to retry.
Save-card consent
To populate the saved-card fields, pass saveCard: true and the
authenticated customerId to POST /api/v1/checkout/create-payment-intent:
{
"cartId": "cart_xxx",
"amount": 4900,
"currency": "GBP",
"customerId": "22222222-2222-4222-8222-222222222222",
"saveCard": true
}
The gateway:
- Resolves or creates a Stripe Customer for the storefront customer (the cus_xxx id is persisted).
- Adds
customer: cus_xxxandsetup_future_usage: 'off_session'to the PaymentIntent so Stripe retains the payment method. - After
POST /completesucceeds, retrieves the PaymentIntent’spayment_methodid and persists it as the customer’sdefault_payment_method_id.
From the customer’s next visit, express checkout works.
Idempotency
A duplicate tap on the Buy Now button shouldn’t double-charge. Two defenses:
- The endpoint accepts
requestIdand passes the derived key to Stripe’s idempotency header. - The order row carries the resulting PaymentIntent ID in
paymentIntentId— webhook handlers see the same ID twice and no-op the second time.
PCI scope
Express checkout doesn’t change PCI scope. Mercentia never sees a
PAN — only opaque Stripe handles (cus_xxx, pm_xxx,
pi_xxx). The platform stays at SAQ-A.
Observability
Every express attempt writes a row to order_events with type
express_checkout_attempted carrying the resulting Stripe status.
Filter the order timeline for this event to see what happened.
Failures land in Sentry via the gateway’s standard observability
pipeline; the failure reason from Stripe (e.g. card_declined,
insufficient_funds) is included as the event message.