Wallet express checkout
Mercentia’s wallet express checkout renders an Apple Pay / Google Pay / Stripe Link button anywhere the shopper is ready to buy. It is the platform’s “Shop Pay-class” path — one tap, no form, native wallet UI, ~2× the conversion rate of standard card checkout.
This is distinct from POST /api/v1/checkout/express (the saved-card endpoint, documented in express-checkout.md). The wallet path is anonymous — the buyer doesn’t need a Mercentia customer record. The wallet sheet supplies email, shipping, and a single-use Stripe PaymentMethod inline.
Where it renders
| Surface | Component | Endpoint | Multi-line? | Inline 3DS? |
|---|---|---|---|---|
| Product page | apps/storefront/components/PdpExpressBuy.vue |
POST /api/v1/checkout/express/wallet |
No (one product × qty) | No (falls back) |
| Cart page | apps/storefront/components/CartExpressBuy.vue |
POST /api/v1/checkout/express/wallet/cart |
Yes (up to 50 lines) | Yes (stripe.handleNextAction) |
| Checkout page | Inline in apps/storefront/pages/checkout.vue |
POST /api/v1/checkout/express/wallet |
No (uses the canonical Cart at the end) | No |
Each component renders only when paymentRequest.canMakePayment() returns truthy on the shopper’s device — no fake buttons, no broken integrations on browsers that can’t fulfil them.
Architecture
┌────────────────┐ ┌──────────────────────┐ ┌─────────┐
│ Storefront │ fetch │ Gateway │ charge │ Stripe │
│ (CartExpressBuy│─────────▶│ POST /checkout/ │────────▶│ │
│ .vue) │ │ express/wallet/ │ │ │
│ │ │ cart │ │ │
│ Stripe.js PRB │ │ │ │ │
└────────┬───────┘ │ walletCartCheckout()│ │ │
│ │ - resolveLineTotal │ │ │
│ │ - INSERT order │ │ │
│ │ - INSERT N items │ │ │
│ │ - paymentIntent │ │ │
│ └──────────┬───────────┘ └─────────┘
│ │
│ outcome │
│◀────────────────────────────┘
│
│ if requires_action:
│ stripe.handleNextAction({clientSecret})
│ → wallet web-view shows 3DS challenge
│ → on success: navigate to /order-confirmation
│
│ if completed:
│ navigate to /order-confirmation
The cart-level endpoint — POST /api/v1/checkout/express/wallet/cart
Request
{
"lines": [
{ "productId": "uuid", "variantId": "uuid|null", "quantity": 2 },
{ "productId": "uuid", "variantId": null, "quantity": 1 },
],
"paymentMethodId": "pm_xxx", // From ev.paymentMethod.id (Stripe.js)
"email": "[email protected]", // From ev.payerEmail
"shipping": {
"firstName": "Ada",
"lastName": "Lovelace",
"address1": "10 Downing St",
"address2": null,
"city": "London",
"state": null,
"postcode": "SW1A 2AA",
"country": "GB",
"phone": null,
},
"requestId": "uuid", // Client-generated; idempotency seed
"sessionId": "...", // Optional; falls back to sf_session_id cookie
}
Limits
lines.length∈ [1, 50]. Carts bigger than 50 distinct lines returnkind: 'fallback', reason: 'cart_too_large'— Apple Pay’s wallet sheet doesn’t render readably past about 8 items anyway.quantityper line ∈ [1, 100].- All lines must be the same currency. Mixed-currency carts return
kind: 'fallback', reason: 'mixed_currency_cart'— Stripe PaymentIntents are single-currency.
Authoritative pricing
The client-supplied amount is a hint only. For every line, the server re-runs resolveLineTotal(storeId, productId, variantId, quantity) and sums to the canonical cart total. If a product price changed mid-tap, the server charges the new price — the wallet sheet’s displayed total can briefly drift, but the merchant is never under-charged.
Idempotency
The Stripe idempotency key is derived from a sha256-able fingerprint of the sorted line product ids + the client’s requestId. Two taps of the same cart within seconds collapse into one PaymentIntent. If the merchant is on Stripe Connect, the request is scoped to the connected account.
Response shape
type Outcome =
| { kind: 'completed'; orderId; orderNumber; chargedAmount: number }
| { kind: 'requires_action'; orderId; clientSecret: string }
| { kind: 'failed'; orderId?; reason: string }
| { kind: 'fallback'; reason: string };
completed → HTTP 200. Order is paid; storefront navigates to /order-confirmation.
requires_action → HTTP 200. 3DS challenge required. Storefront calls stripe.handleNextAction({ clientSecret }). The wallet sheet’s inline web-view renders the bank’s challenge; on success the storefront polls or directly navigates.
failed → HTTP 402. Order row is marked failed; storefront tells the wallet ev.complete('fail') and shows a “try standard checkout” hint.
fallback → HTTP 200. Soft failure (Stripe not connected, mixed currency, etc.). The wallet is not invoked.
Why we deprecated the pre-2026 placeholder
Before this release, a 3DS challenge on a wallet tap surfaced as a hard failure — the comment in PdpExpressBuy.vue was:
3DS challenge inside a wallet sheet is unreliable — tell the wallet we couldn’t complete and surface a soft fall-back hint to the shopper.
Apple’s and Google’s 2024-25 wallet SDKs now render bank challenges in an inline web-view controlled by Stripe.js. Calling stripe.handleNextAction({ clientSecret }) after the wallet has handed back the PaymentMethod is the supported path. Shop Pay (Shopify) uses the same flow. Our cart-level component adopts it; PDP keeps the older fallback because PDP latency budgets are stricter.
Per-tap data flow
- Component mount — fetch
GET /api/v1/checkout/express/wallet/configto get{ publishableKey, stripeAccount? }. - Stripe.js boot —
Stripe(pk, { stripeAccount })andstripe.paymentRequest({ ... }). - Wallet probe —
paymentRequest.canMakePayment(). If falsy → render nothing. - Mount PRB —
elements.create('paymentRequestButton', { paymentRequest }). - Tap — wallet sheet opens. Shopper authorises (Face ID / fingerprint / device PIN).
paymentmethodevent fires —ev.paymentMethod.id+ev.payerEmail+ev.shippingAddressarrive.- POST to gateway — body above. Server creates order, items, PaymentIntent.
- Outcome — completed (navigate), requires_action (handleNextAction → navigate), failed (show error).
Operations
- Apple Pay domain verification — required before the PRB renders for Apple Pay. Mint the association file in Stripe Dashboard → Settings → Payments → Apple Pay, paste into
APPLE_PAY_DOMAIN_ASSOCIATIONenv var. Storefront serves it at/.well-known/apple-developer-merchantid-domain-association. See Dashboard → Settings → Express checkout for the full walkthrough. - Stripe Link — automatically available when the Stripe installation has
cardinenabled_methodsand the publishable key is from a Link-enrolled account. No domain verification. - Google Pay — no domain verification when used via Stripe (Stripe signs the request server-side).
Testing
Unit-level coverage lives in:
apps/gateway/src/routes/express-checkout.wallet.test.ts— single-product (PDP) wallet endpoint.apps/gateway/src/services/payments/methodRouter.test.ts— eligibility filter (UPI never to US, etc.).
The cart-level endpoint inherits resolution + idempotency behaviour from the PDP test suite plus the line-fan-out logic in walletCartCheckout(). Full-fixture integration tests (Stripe mock + Postgres) land in apps/gateway/tests/integration in a follow-up.
Migration notes for theme authors
Themes do not need to wire any wallet code themselves — the platform mounts PdpExpressBuy and CartExpressBuy in the page wrappers, so all 21+ themes inherit the wallet path automatically. A theme that deliberately wants to suppress wallets can override the page wrapper and omit the slot — but the default for every theme is “wallet enabled”.
Related
- Payment providers settings — how merchants connect Stripe + toggle methods.
- Method router — how the platform decides which methods to show per (country × currency).
- Express checkout (saved-card) — the sibling endpoint for non-wallet one-tap.