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:

  1. Resolves or creates a Stripe Customer for the storefront customer (the cus_xxx id is persisted).
  2. Adds customer: cus_xxx and setup_future_usage: 'off_session' to the PaymentIntent so Stripe retains the payment method.
  3. After POST /complete succeeds, retrieves the PaymentIntent’s payment_method id and persists it as the customer’s default_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 requestId and 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.