Checkout

This page is the full checkout knowledge transfer: every listing, the orchestrated Buy-click sequence, the amount-sync matrix, dead-PI recovery, and the completion contract. Amounts are EUR decimal major units and the server totals engine is the only amount authority — the client never supplies an amount anywhere in this flow.

Auth for every endpoint on this page: anon x-client-id (checkout is guest-capable; the one exception is the store setting accounts_mode='required' — see complete).

SDK module: @cartbase/storefront/api/checkout (+ carts.completeCart from @cartbase/storefront/api/carts).

The two checkout paths

Orchestrated (recommended — what production storefronts run):

listShippingOptions(cart_id) ─┐  (render pickers)
listPaymentProviders(cart_id) ┘
        │  Buy click

prepareCheckout(cart, {address, shipping_method_id, carrier_metadata,
                       payment_provider XOR payment_method_id})
        │                         ONE atomic call, compensated on failure
        ├─ pp_stripe → stripe.confirmPayment(client_secret) ─┐
        └─ a payment method (Bank transfer, COD…) ───────────┤

                                            completeCart(cart) → {type:"order", order}

While checkout stays mounted: syncPaymentAmount() after anything that changes the total; refreshPaymentIfTerminal() from Stripe Elements loaderror (never proactively).

Manual (step-by-step, for custom flows): updateCart (address+email) → addShippingMethodcreatePaymentCollectioninitiatePaymentSessioncompleteCart. Both paths are executed against the live server below.


GET /api/store/shipping-options — list (rule-filtered)

  • Purpose — render the shipping picker. Pass cart_id — it prices the options in the cart currency AND gives the checkout-rules engine its evaluation context.
  • Auth — anon x-client-id.
  • RequestGET ?cart_id=cart_… (optional; without it amount is null and cart-dependent hide rules cannot match).
  • Response — list envelope; count/limit = the full filtered list (no pagination):
{
  "shipping_options": [{
    "id": "so_…", "name": "Standard",
    "provider_id": null,          // the carrier, when the merchant bound one
    "service_zone_id": "sz_…", "shipping_profile_id": "sp_…",
    "data": null,
    "type": { "label": "Express" }, // the option's display label, or null
    "amount": 5,                 // the price the cart pays; null without cart_id
    "price_type": "flat"         // calculated-rate carriers not wired yet
  }],
  "count": 1, "offset": 0, "limit": 1
}
  • Eligibility — four gates run server-side before an option lists: the option's shipping profile must be among the cart products' profiles; its zone must cover the shipping address; its visibility rules must pass; and a price must exist for the cart. Conditional rates (tiers by order amount, cart weight, or cart volume) resolve here — amount is always the tier the cart actually satisfies.
  • Errors — 404 cart_not_found (bad cart_id).
  • SDKcheckout.listShippingOptions(client, {cart_id}).
  • Components — shipping picker, carrier/locker pickers (carrier metadata is collected client-side and handed to prepareCheckout).
  • Settings — checkout rules (target_type=shipping_option) hide options server-side; checkout_method_order orders them; fail-open (a broken rule never bricks the listing). Hidden-method enforcement is at complete (checkout_method_hidden), not only here.
# Payable cart for the whole page (seeded Linen Shirt M + address + email).
CART_JSON=$(curl -sf -X POST "$BASE/api/store/carts" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"email":"checkout-doc-'"$RUN"'@example.test","currency_code":"eur",
       "items":[{"variant_id":"variant_01tst000000000000000002","quantity":1}]}')
CART_ID=$(echo "$CART_JSON" | grep -o '"id":"cart_[^"]*"' | head -1 | cut -d'"' -f4)
REGION_ID=$(echo "$CART_JSON" | grep -o '"region_id":"[^"]*"' | head -1 | cut -d'"' -f4)
test -n "$CART_ID" && test -n "$REGION_ID"

OPTS_JSON=$(curl -sf "$BASE/api/store/shipping-options?cart_id=$CART_ID" \
  -H "x-client-id: $CLIENT_ID")
echo "$OPTS_JSON" | grep -q '"price_type":"flat"'
# Pick a PRICED option — an option without a price row for the cart currency
# lists amount: null and cannot be calculated or prepared. (The listing is
# shared dev-tenant state; never grab blindly the first row.)
SO_ID=$(echo "$OPTS_JSON" | grep -o '"id":"so_[^"]*","name":"Flat Rate (Bulgaria)"' \
  | head -1 | cut -d'"' -f4)
test -n "$SO_ID"

POST /api/store/shipping-options/:id/calculate — price one option

  • Purpose — price a single option for a cart (kept for API parity / future calculated-rate carriers; the listing already returns amount).
  • Auth — anon x-client-id.
  • Request{cart_id, data?}data is provider-specific input, accepted and currently ignored (flat prices only).
  • Response200 {shipping_option} with amount in the cart currency.
  • Errors — 404 cart_not_found | shipping_option_not_found; 400 shipping_price_missing (no price row in the cart currency) | validation_failed.
  • SDKcheckout.calculateShippingOption(client, id, {cart_id}).
curl -sf -X POST "$BASE/api/store/shipping-options/$SO_ID/calculate" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"cart_id":"'"$CART_ID"'"}' | grep -q '"amount"'

GET /api/store/payment-providers — list (rule-filtered)

  • Purpose — render the payment-method picker.
  • Auth — anon x-client-id.
  • RequestGET ?region_id=…&cart_id=…, both optional. Without region_id: the tenant's enabled providers (global catalog ∩ tenant enablement). With it: providers linked to that region. cart_id feeds the rules engine — storefronts SHOULD pass it during checkout.
  • Response — list envelope, full filtered list. Two entry shapes share the array: connected PROCESSORS ({id} — pp_stripe) and merchant payment METHODS ({payment_method_id, name, kind, instructions, fee_amount, fee_label} — "Bank transfer", the COD method). Methods carry no provider id anywhere; a fresh store with nothing configured gets an honestly empty list. With region_id, BOTH families filter by the region's claims (region_payment_provider / region_payment_method — availability is a per-region merchant choice):
{
  "payment_providers": [
    { "id": "pp_stripe", "is_enabled": true, "created_at": "…" },
    // one entry PER enabled, region-claimed merchant method:
    { "payment_method_id": "pm_…", "name": "Bank transfer",
      "kind": "manual", "instructions": "IBAN BG…",
      "fee_amount": null, "fee_label": null },
    { "payment_method_id": "pm_…", "name": "Cash on delivery",
      "kind": "cod", "instructions": null,
      "fee_amount": 4.99, "fee_label": "COD fee" }
    // pp_giftcard is INTERNAL tender and is never listed
  ],
  "count": 3, "offset": 0, "limit": 3
}
  • Selecting a method — initiate the session with the method id alone: POST …/payment-sessions { payment_method_id: "pm_…" }. The server validates it (tenant's, enabled, claimed for the cart's region — 400 otherwise), the session lands with provider_id NULL, and the method's identity is snapshotted into session.data (payment_method_id/name/kind) so payment surfaces say "Bank transfer" without a join. Render instructions to the shopper after selection and on the confirmation screen. fee_amount/fee_label let the checkout PREDICT the fee before the session exists — the authority stays the server totals (payment_method_fee_total).
  • Errors — none beyond the standard envelope (empty list when nothing is enabled); initiate 400s on unknown/disabled/foreign method ids and payment_method_not_in_region for an unclaimed region.
  • SDKcheckout.listPaymentProviders(client, {region_id, cart_id}).
  • Components — payment picker.
  • Settings — Stripe connects in Settings, Payments (provisions pp_stripe + credentials); payment methods live in the same screen (the COD switch + named manual methods, each with optional fee_amount/fee_label and per-region availability); checkout rules (target_type=payment_method, method entries participate under their payment_method_id) + checkout_method_order.
curl -sf "$BASE/api/store/payment-providers?cart_id=$CART_ID" \
  -H "x-client-id: $CLIENT_ID" | grep -q '"payment_method_id"'
PAY_JSON=$(curl -sf "$BASE/api/store/payment-providers?region_id=$REGION_ID&cart_id=$CART_ID" \
  -H "x-client-id: $CLIENT_ID")
echo "$PAY_JSON" | grep -q '"payment_method_id"'
# The seeded Bank transfer method carries the rest of this page.
PM_ID=$(echo "$PAY_JSON" | grep -o '"payment_method_id":"pm_[^"]*"' | head -1 | cut -d'"' -f4)
test -n "$PM_ID"

POST /api/store/carts/:id/prepare-checkout — the atomic Buy click

ONE call writes everything the customer toggled on /checkout, in the only safe order: address first (option pricing reads the destination) → shipping methodpayment collection at the shipped total → payment session LAST at the FINAL amount (real Stripe PaymentIntent, idempotency key = session id; a plain NULL-provider row for a payment method). A method's fee only applies once its session exists, so amounts are re-synced after it. Fully compensated: any failure rolls back session → collection → shipping method → addresses/metadata to the pre-call snapshot (recorded in the execution ledger, workflow prepare-checkout, states done/reverted; failures also land in checkout_error_logs, step prepare-checkout).

  • Auth — anon x-client-id.
  • Request (.strict(); DTO verbatim from src/lib/checkout-orchestration/prepare.ts) — all address fields required except address_2/company/province; phone is required (courier recovery channel). The tender is exactly ONE of payment_provider (a connected processor — pp_stripe, never pp_giftcard) or payment_method_id (a merchant method from the listing):
{
  "shipping_address": {
    "first_name": "Jane", "last_name": "Dow",
    "address_1": "Vitosha 1", "address_2": "",
    "company": "", "province": "",
    "city": "Sofia", "postal_code": "1000",
    "country_code": "bg", "phone": "+359888123456"
  },
  "shipping_method_id": "so_…",          // a shipping-option id
  "shipping_method_data": {},            // optional, stored on the method row
  "carrier_metadata": {                  // optional, opaque per-carrier keys
    "office_code": "X1", "office_name": "Center"
  },
  "payment_method_id": "pm_…",           // XOR payment_provider: "pp_stripe"
  "save_payment_method": false           // optional — subscription carts only;
                                         // same semantics + consent duty as on
                                         // payment-sessions below
}

Billing mirrors shipping (own row). carrier_metadata merges into cart.metadata; keys written by the PREVIOUS prepare call are removed first (tracked under the reserved _prepared_carrier_keys marker) — switching carriers can never leak the old carrier's fields into the order.

  • Response (verbatim PrepareCheckoutResult):
{
  "cart_id": "cart_…",
  "payment_collection_id": "pc_…",
  "client_secret": "pi_…_secret_…",   // Stripe only; null for method sessions AND zero-remainder carts
  "provider_id": null,                // the processor, when one was chosen
  "payment_method_id": "pm_…"         // the method, when one was chosen
}
  • Zero-remainder gift path — when applied gift cards cover the whole total, the tender session is skipped entirely (client_secret, provider_id and payment_method_id come back null) and the cart completes on the gift session alone — no Stripe involved (gift-cards.md).
  • Errors — 404 cart_not_found | shipping_option_not_found; 409 cart_completed; 400 validation_failed | invalid_provider (pp_giftcard) | shipping_price_missing | stripe_not_configured.
  • SDKcheckout.prepareCheckout(client, cartId, input).
  • Components — the checkout form's Buy button; carrier/locker pickers feed carrier_metadata.
  • Settings — Stripe credentials (admin integrations), COD fee, gift cards; checkout rules are enforced at the listings and at complete, not here.
PREP_JSON=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/prepare-checkout" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"shipping_address":{"first_name":"Doc","last_name":"Run",
        "address_1":"Vitosha 1","city":"Sofia","postal_code":"1000",
        "country_code":"bg","phone":"+359888123456"},
       "shipping_method_id":"'"$SO_ID"'",
       "carrier_metadata":{"office_code":"X1"},
       "payment_method_id":"'"$PM_ID"'"}')
echo "$PREP_JSON" | grep -q '"payment_method_id":"'"$PM_ID"'"'
echo "$PREP_JSON" | grep -q '"client_secret":null'
PC_ID=$(echo "$PREP_JSON" | grep -o '"payment_collection_id":"pc_[^"]*"' | cut -d'"' -f4)
test -n "$PC_ID"

# Error contract: pp_giftcard is never a selectable provider.
GC_RES=$(curl -s -X POST "$BASE/api/store/carts/$CART_ID/prepare-checkout" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"shipping_address":{"first_name":"Doc","last_name":"Run",
        "address_1":"Vitosha 1","city":"Sofia","postal_code":"1000",
        "country_code":"bg","phone":"+359888123456"},
       "shipping_method_id":"'"$SO_ID"'",
       "payment_provider":"pp_giftcard"}')
echo "$GC_RES" | grep -q '"code":"invalid_provider"'

POST /api/store/carts/:id/sync-payment-amount — align amounts in place

Aligns the pending provider session with the cart's CURRENT total, in place when possible — the happy path returns the same client_secret so <Elements> never remounts (the fix for the "InitiateCheckout fires four times when I change shipping" bug class). Call after anything that changes the total while checkout is mounted (quantity change, gift card applied/removed, shipping switch).

  • Auth — anon x-client-id.
  • Request{provider_id? | payment_method_id?} (.strict(); empty object fine, never both). Passing a DIFFERENT tender than the pending session's forces rotation to it.
  • Response matrix (verbatim src/lib/checkout-orchestration/sync.ts):
state response
completed cart {"synced":false,"reason":"cart-completed"}
no payment collection {"synced":false,"reason":"no_payment_collection"}
no pending provider session (gift sessions excluded) {"synced":false,"reason":"no_pending_session"}
tender matches, amount current {"synced":true,"rotated":false,"client_secret":…,"provider_id":…,"payment_method_id":…} (no-op)
tender matches, amount drifted in-place update (Stripe paymentIntents.update; plain field for method sessions) → {"synced":true,"rotated":false,…} — same secret
tender mismatch OR update refused (terminal PI) rotation: old session retired (+ PI voided best-effort), fresh session at the new remainder → {"synced":true,"rotated":true,…}

A session's tender identity is its provider_id for processors and its snapshot payment_method_id for NULL-provider method sessions. Rotation retires the old session BEFORE recomputing so session-dependent totals (the method fee) settle for the NEW tender; a final resync pass aligns collection + session + PI. client_secret is null for method sessions.

  • Errors — 404 cart_not_found; 400 validation_failed | stripe_not_configured. Failures land in checkout_error_logs (step sync-payment-amount).
  • SDKcheckout.syncPaymentAmount(client, cartId, {provider_id?, payment_method_id?}).
  • Components — checkout totals watcher (debounced), payment-method switcher (pass the new tender id).
# No-drift no-op on the prepared method cart: same-session, not rotated.
SYNC_JSON=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/sync-payment-amount" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
echo "$SYNC_JSON" | grep -q '"synced":true'
echo "$SYNC_JSON" | grep -q '"rotated":false'
echo "$SYNC_JSON" | grep -q '"payment_method_id":"pm_'

POST /api/store/carts/:id/refresh-payment-if-terminal — dead-PI recovery

A Stripe PaymentIntent can die out-of-band (canceled in the Dashboard, Stripe's 24h auto-cancel, captured externally) while the local session stays pending; mounting Elements on the dead client_secret fails with "PaymentIntent is in a terminal state". This route reconciles against Stripe's ACTUAL PI and rotates a fresh session/PI only when the intent is truly dead. Call it reactively — Elements loaderror / page mount for aged carts — never proactively per render (the proactive variant caused a production reload loop).

  • Auth — anon x-client-id. No body.
  • Response (verbatim src/lib/checkout-orchestration/refresh.ts) — rotated: {"rotated":true,"reason":"pi-terminal"|"pi-missing", "previous_status":…} (terminal = succeeded/canceled/ requires_capture, or a resource_missing PI). Not rotated: cart-completed | no-stripe-session (also when the pending session is non-Stripe) | no-pi-id | stripe-not-configured | still-usable (+ status) | stripe-error (+ error) — any transient Stripe error refuses to rotate (rotating on transient failures was the loop bug). Every rotation writes an audit row (checkout_error_logs step refresh-payment, code rotated).
  • Errors — 404 cart_not_found.
  • SDKcheckout.refreshPaymentIfTerminal(client, cartId).
  • Components — Stripe Elements mount error handler.
# Method session ⇒ documented no-op reason (Stripe-specific rotation
# needs STRIPE credentials — proven by tests/store/checkout-orchestration-sync.test.ts).
curl -sf -X POST "$BASE/api/store/carts/$CART_ID/refresh-payment-if-terminal" \
  -H "x-client-id: $CLIENT_ID" | grep -q '"reason":"no-stripe-session"'

POST /api/store/carts/:id/complete — place the order

The last call of every checkout. Sequence server-side: idempotency check → CAS lock → validation → checkout-rules completion guard → inventory reservation (kit-aware) → order creation (rows copied cart→order) → subscription contracts (carts with plan lines: one contract per plan, cycle 1 tied to this order, cycle 2 scheduled at the next CHARGE date; guests are refused with 400 customer_required) → payment authorization LAST (gift tender redeemed atomically first; real Stripe authorize for pp_stripe; best-effort stub for method sessions) → order.placed (+ subscription.created per contract) on the durable bus. Any failure before authorize compensates fully (order deleted, contracts deleted, inventory released, gift tender reversed, cart unlocked) — the cart stays open and retryable. Note: subscription carts auto-save the card at the payment-session step (see save_payment_method above) — by complete time the mandate already exists.

  • Auth — anon x-client-id (guest checkout). Store setting accounts_mode='required' → guest carts (no attached customer) get 403 account_required; disabled/optional leave guests untouched.
  • RequestPOST, empty body.
  • Response200 {"type":"order","order":{…}} — the order with summary and flattened items. Idempotent: re-calling returns the SAME order; concurrent completes are serialized by the CAS lock (the loser returns the winner's order or 409 cart_locked).

    Contract note (code wins over store-api.md): the documented {type:"cart", cart, error} failure union is never returned — failures throw the standard error envelope.

  • Errors
    • 400 validation: cart_email_required | cart_empty | shipping_address_required | shipping_method_required (only when a line requires_shipping — digital-only carts, e.g. digital gift cards, skip it) | payment_collection_required | payment_session_required | insufficient_inventory | customer_required (plan lines on a guest cart — subscribing needs an account; normally already refused at the payment-session step).
    • 400 checkout_method_hiddenthe checkout-rules security boundary: every live payment session's chosen tender (the method's payment_method_id for NULL-provider sessions, else the provider id; internal pp_giftcard exempt) and every chosen shipping option is re-validated against the live rules with the full cart context. A stale session that picked a method before a rule started matching, or a hostile client that skipped the filtered listings, is rejected here and the rejection is recorded in checkout_error_logs (step complete).
    • 402 payment family: requires_action (3DS — details.client_secret carries the intent to confirm) | payment_not_authorized | payment_not_initiated (Stripe session without a PI — re-initiate) | payment_incomplete (gift tender no longer covers a session-less total) | gift_card_insufficient_balance (lost double-spend race) | gift_card_not_redeemable.
    • 403 account_required; 409 cart_locked.
  • SDKcarts.completeCart(client, cartId) (module @cartbase/storefront/api/carts).
  • Components — Buy button (orchestrated path), order-confirmation page.
  • Settings — checkout rules; accounts_mode; the payment method fee (timing: a method's fee exists only while ITS live session does — it appears on the cart at prepare as payment_method_fee_total, rides cart.total, and is carried onto the order via order_summaries.totals, where waybill COD amounts read it); gift-card tender (zero-remainder carts complete on the gift session alone).
# Complete the prepared method cart → a real order, no Stripe env needed.
ORDER_JSON=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/complete" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
echo "$ORDER_JSON" | grep -q '"type":"order"'
ORDER_ID=$(echo "$ORDER_JSON" | grep -o '"id":"order_[^"]*"' | head -1 | cut -d'"' -f4)
test -n "$ORDER_ID"

# Idempotency: completing again returns the SAME order.
ORDER_ID2=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/complete" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}' \
  | grep -o '"id":"order_[^"]*"' | head -1 | cut -d'"' -f4)
test "$ORDER_ID" = "$ORDER_ID2"

# Post-completion contracts: mutations 409, sync/refresh report the reason.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
  "$BASE/api/store/carts/$CART_ID/line-items" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"variant_id":"variant_01tst000000000000000003","quantity":1}')
test "$STATUS" = 409
curl -sf -X POST "$BASE/api/store/carts/$CART_ID/sync-payment-amount" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}' \
  | grep -q '"reason":"cart-completed"'
curl -sf -X POST "$BASE/api/store/carts/$CART_ID/refresh-payment-if-terminal" \
  -H "x-client-id: $CLIENT_ID" | grep -q '"reason":"cart-completed"'

POST /api/store/checkout-errors — browser-side error reporting

The browser half of checkout error capture. The server logs every money-path failure into the merchant's checkout error log on its own; failures that happen only in the customer's browser — a Stripe.js confirm error, a 3DS return that comes back not-succeeded, a place-order rejection — are reported through this endpoint. useCheckoutOrchestration reports them by default; you only call this yourself if you replaced the hook's logError and still want the platform log.

Body (.strict()): error_type (≤64 chars), message (≤2000), optional cart_id, optional context object (redacted values only — ids, codes, flags; never card data, never addresses; oversized context is stored as {truncated: true}). Answers 204 always on accepted input; 400 malformed; 429 past 60 reports/store/minute. Fire-and-forget: the SDK's reportCheckoutError() swallows every failure — reporting an error must never take a checkout down.

Modifying checkout — the laws

Checkout is locked space (LOCK_BOUNDARIES): critical-to-function code is closed; customize through props, slots and tokens, never by editing the package's files. What that means in practice:

  • Fork the layout, never the logic. useCheckoutOrchestration is the one brain — session guard, amount sync, dead-PI recovery, 3DS return, compensation-aware completion. A fork of the hook once dropped the session guard and produced zombie Stripe sessions; the hook exists so that class of bug is structurally impossible. Build your own screens on top of the hook; do not reimplement it.
  • The server owns every amount. The client never sends an amount; sessions charge total − gift_card_total computed server-side. Any checkout change that puts a number in a request body is wrong by construction.
  • Logging is not optional. Money failures must reach the merchant: the hook's default sink does this. If you override logError, either call reportCheckoutError() yourself or accept that browser failures vanish — and that is a defect, not a preference.
  • Debug output is opt-in. useCheckoutOrchestration({ debug: true }) turns on verbose [buy-click] console output for local work. It prints the full prepare payload (name, phone, email, address), so it must never ship enabled.
  • Test against the real wire. Every request/response shape on this page is executable against a store; a checkout change ships with its route driven end to end, error branches included.

Manual path — collections + sessions (step-by-step)

POST /api/store/payment-collections

  • Purpose — ensure the cart's payment collection (ONE per cart, idempotent; the amount is refreshed to the CURRENT decorated total on every call).
  • Auth — anon x-client-id.
  • Request{cart_id}.

    Contract note (code wins over store-api.md): the contract's provider_id/data fields are ignored — the provider is chosen when initiating the session.

  • Response201 {payment_collection} when created, 200 when the existing one was refreshed. {id, amount, currency_code, status:"not_paid", payment_sessions:[…]}. The moment a collection exists, applied gift-card tender is composed as an internal pp_giftcard session.
  • Errors — 404 cart_not_found; 400 validation_failed.
  • SDKcheckout.createPaymentCollection(client, {cart_id}).

POST /api/store/payment-collections/:id/payment-sessions

  • Purpose — mint (or repair) the tender session — idempotent per tender. For Stripe the PaymentIntent is minted FIRST (idempotency key = session id) so a Stripe session row can never exist without its intent; amount drift syncs the PI in place; terminal PIs self-heal by rotation. Method sessions share ONE NULL-provider row per collection — switching methods updates its snapshot in place.
  • Auth — anon x-client-id.
  • Request{provider_id? XOR payment_method_id?, data?, save_payment_method?} — exactly one tender. Keys the server owns (payment_intent_id, client_secret, status, stripe_customer_id, setup_future_usage, and the method snapshot payment_method_id/name/kind) are stripped from data — they cannot be forged from the client.
  • save_payment_method — saves the card for future off-session renewal charges: the server resolves the CART'S customer (never a client-supplied id), ensures a Stripe Customer for them, and mints the PaymentIntent with setup_future_usage: "off_session". Automatic for subscription carts: when the cart carries plan lines the server applies the mandate even without the flag (a subscription cannot renew without it — the server owns the decision). Requires a logged-in customer — a guest cart is a 400 customer_required (subscribing needs an account), raised HERE, before any payment. Consent: the mandate moment — the storefront MUST render the saved-card consent text with the plan selection / next to the payment element (e.g. "Your card will be saved for future subscription charges"). Re-initiating an existing session with the flag (or after a plan line appears) upgrades the live intent in place (same client_secret). No card to save on method sessions (COD/manual subscriptions renew offline) — the account gate still applies. Never set the flag on ordinary checkouts.
  • Response201 {payment_session} when created, 200 when the existing one was returned/repaired: {id, provider_id, amount, currency_code, status:"pending", authorized_at:null, data} — for Stripe, data carries payment_intent_id + client_secret (mount Elements with it); with save_payment_method it also carries setup_future_usage: "off_session" + stripe_customer_id. For a method, provider_id is null and data carries the snapshot (payment_method_id, payment_method_name, payment_method_kind). The session amount is collection.amount − gift_card_total — the remainder.
  • Errors — 404 payment_collection_not_found; 400 invalid_provider (pp_giftcard, or an id the catalog doesn't know — the dead pp_ ids land here) | payment_provider_disabled | payment_provider_not_in_region | payment_method_not_in_region | stripe_not_configured | customer_required | validation_failed.
  • SDKcheckout.initiatePaymentSession(client, pcId, {provider_id?, payment_method_id?, save_payment_method?}).

POST /api/store/carts/:id/shipping-methods

  • Purpose — set the cart's shipping method manually (single-method model: the previous method rows are replaced).
  • Auth — anon x-client-id.
  • Request{option_id, data?} (.strict()).
  • Response200 {cart} (decorated; shipping_total now non-zero).
  • Errors — 404 cart_not_found | shipping_option_not_found; 409 cart_completed; 400 shipping_price_missing | validation_failed.
  • SDKcheckout.addShippingMethod(client, cartId, {option_id}).
# The whole manual path, executable: cart → method → collection → session → order.
CART2_JSON=$(curl -sf -X POST "$BASE/api/store/carts" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"email":"checkout-doc-manual-'"$RUN"'@example.test",
       "items":[{"variant_id":"variant_01tst000000000000000002","quantity":1}],
       "shipping_address":{"first_name":"Doc","last_name":"Manual",
         "address_1":"Vitosha 2","city":"Sofia","postal_code":"1000",
         "country_code":"bg","phone":"+359888123457"}}')
CART2_ID=$(echo "$CART2_JSON" | grep -o '"id":"cart_[^"]*"' | head -1 | cut -d'"' -f4)

curl -sf -X POST "$BASE/api/store/carts/$CART2_ID/shipping-methods" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"option_id":"'"$SO_ID"'"}' \
  | grep -q '"shipping_option_id":"'"$SO_ID"'"'

# Fresh cart, no collection yet → sync reports why it can't sync.
curl -sf -X POST "$BASE/api/store/carts/$CART2_ID/sync-payment-amount" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}' \
  | grep -q '"reason":"no_payment_collection"'

PC2_JSON=$(curl -sf -X POST "$BASE/api/store/payment-collections" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"cart_id":"'"$CART2_ID"'"}')
echo "$PC2_JSON" | grep -q '"status":"not_paid"'
PC2_ID=$(echo "$PC2_JSON" | grep -o '"id":"pc_[^"]*"' | head -1 | cut -d'"' -f4)

SES_JSON=$(curl -sf -X POST \
  "$BASE/api/store/payment-collections/$PC2_ID/payment-sessions" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"payment_method_id":"'"$PM_ID"'"}')
echo "$SES_JSON" | grep -q '"provider_id":null'
echo "$SES_JSON" | grep -q '"payment_method_name"'
echo "$SES_JSON" | grep -q '"status":"pending"'

# Error contract: the internal gift tender is not initiable.
GCS_RES=$(curl -s -X POST \
  "$BASE/api/store/payment-collections/$PC2_ID/payment-sessions" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"provider_id":"pp_giftcard"}')
echo "$GCS_RES" | grep -q '"code":"invalid_provider"'

curl -sf -X POST "$BASE/api/store/carts/$CART2_ID/complete" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}' \
  | grep -q '"type":"order"'
# save_payment_method on a GUEST cart is refused — subscribing needs an
# account (provider-independent: fires before any Stripe call).
CART3_JSON=$(curl -sf -X POST "$BASE/api/store/carts" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"email":"checkout-doc-guest-sub-'"$RUN"'@example.test",
       "items":[{"variant_id":"variant_01tst000000000000000002","quantity":1}]}')
CART3_ID=$(echo "$CART3_JSON" | grep -o '"id":"cart_[^"]*"' | head -1 | cut -d'"' -f4)

PC3_JSON=$(curl -sf -X POST "$BASE/api/store/payment-collections" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"cart_id":"'"$CART3_ID"'"}')
PC3_ID=$(echo "$PC3_JSON" | grep -o '"id":"pc_[^"]*"' | head -1 | cut -d'"' -f4)

curl -s -X POST "$BASE/api/store/payment-collections/$PC3_ID/payment-sessions" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"payment_method_id":"'"$PM_ID"'","save_payment_method":true}' \
  | grep -q '"code":"customer_required"'

Stripe specifics (needs STRIPE credentials — not executable here)

# doc-noexec — requires the store's Stripe integration (admin-configured
# credentials); the mocked equivalents run in
# tests/store/checkout-orchestration.test.ts + checkout-stripe.test.ts.
PREP=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/prepare-checkout" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"shipping_address":{…},"shipping_method_id":"so_…","payment_provider":"pp_stripe"}')
# → {"cart_id":…,"payment_collection_id":"pc_…","client_secret":"pi_…_secret_…","provider_id":"pp_stripe"}
# Storefront: stripe.confirmPayment({clientSecret}) → POST …/complete.
# 3DS never-returned / redirect flows: the payment_intent.succeeded webhook
# (POST /api/webhooks/payment/complete-on-success, configured in Stripe)
# completes the cart server-side through the SAME complete flow.

Sync/refresh behavior with Stripe follows the matrices above: in-place paymentIntents.update keeps the secret stable; provider mismatch or a terminal PI rotates (old PI voided best-effort); refreshPaymentIfTerminal rotates only on succeeded/canceled/requires_capture/missing.

Admin-config-dependent contracts (documented, proven by the suite)

  • checkout_method_hidden (400) — requires an admin checkout rule; exercised by tests/store/checkout-rules.test.ts.
  • account_required (403) — requires accounts_mode='required' on the store; exercised by tests/store/customer-accounts-policy.test.ts.
  • Payment method fee — any method may carry fee_amount/fee_label (the COD method included); exercised by tests/store/payment-method-fee.test.ts (method-switch switches the fee) and tests/store/checkout-orchestration.test.ts (fee-inclusive session on prepare with the COD method).

Cleanup / accretion note

This page creates two carts and completes two method-tender orders on the shared dev tenant — the same inert accretion the checkout test suites produce (no store-facing delete exists for either; suites always create their own carts/orders and never re-read foreign ones).