Integrate checkout
Goal: take a shopper’s cart plus card and place a real order against the commerce API, safely — no double charges, honest declines, an authoritative total you never compute on the client.
Audience: a developer building a headless storefront with @dscodotco/sdk
and the narrow x-storefront-token.
Checkout is one shot: you submit the cart and the card together and the platform authorizes payment, runs the fraud floor, prices the authoritative total, and places the order — or returns an honest decline. There is no separate “create cart -> hosted checkout” step.
checkout.submit(...) sends raw card fields, so it must run server-side —
a Route Handler, a server action, an edge function. The shopper’s browser posts
the cart and card to your server; your server calls the SDK. The
FLIGHTDECK_STOREFRONT_TOKEN never reaches the browser.
Before you start
- A store (tenant) that is live, with active products and variants.
FLIGHTDECK_STOREFRONT_TOKENin a server-only env var.- Your tenant ref (the store’s
stores.tenant_ref).
Construct the tenant-pinned client
Every call is scoped to one tenant, injected into the URL path.
import { createStorefrontClient, FlightdeckError } from "@dscodotco/sdk";
const store = createStorefrontClient({
apiUrl: "https://api.ruo.pro",
tenant: process.env.FLIGHTDECK_TENANT!, // e.g. "ruo-demo"
storefrontToken: process.env.FLIGHTDECK_STOREFRONT_TOKEN!,
});Build the cart body
Items reference variant_id (not product); quantities are positive integers.
You never send a total — the platform prices it from its own catalog.
const body = {
customer_ref: "guest:shopper@example.com", // or an authenticated shopper ref
items: [{ variant_id: "var_123", quantity: 1 }],
card: { ccnumber: "4111111111111111", ccexp: "12/28", cvv: "123" },
email: "shopper@example.com", // order-confirmation address
ip: "203.0.113.9", // BFF-forwarded shopper IP (fraud velocity)
// optional tenders / attribution:
// coupon_code: "WELCOME10",
// apply_store_credit: true,
// gift_card_code: "GIFT-…",
// referral_code: "lori-h",
// shipping_address: { label, line1, line2, city, state, postal_code, country },
};card is required only when a card charge is actually needed — store credit
plus a gift card may cover the whole total. Field names are the API’s
snake_case and are fully typed by the SDK, so a wrong field is a compile
error.
Send the Idempotency-Key header
The Idempotency-Key header is required — the API answers
400 missing_idempotency_key without it. The SDK sends it from the second
argument. Make it deterministic — derive it from the business event and
reuse it across retries of the same attempt; never a fresh random value per
call.
const result = await store.checkout.submit(body, {
idempotencyKey: `cart-${cartId}:attempt-1`, // -> the Idempotency-Key header
});Read the outcome — every branch
try {
const result = await store.checkout.submit(body, { idempotencyKey });
// Placed (201) or replayed (200) both resolve here.
return confirm(result.order.id, result.card_charged_cents);
} catch (err) {
if (err instanceof FlightdeckError) {
// e.g. err.body.error.code === "payment_declined"
return declined(err);
}
throw err; // a real failure — never a fake success
}The three success shapes
- Placed (201) — the body is
{ order, items, outcome: "placed", card_charged_cents, store_credit_applied_cents, gift_card_applied_cents, charge_id, issued_gift_cards, subscriptions, replayed, … }. The order id and totals live onorder(order.id,order.total_cents);card_charged_centsis what actually hit the card after other tenders. - Replayed (200) — the same idempotency key seen again returns the original
order with
outcome: "already_placed"andreplayed: true. Treat it as success; nothing re-charged. - The tender split always sums to the total:
card_charged_cents + store_credit_applied_cents + gift_card_applied_cents.
Declines and the fraud floor
A payment or fraud decline is a 4xx, so the SDK throws FlightdeckError
carrying { error: { code, message, correlationId? } }. Branch on
err.body.error.code:
payment_declined— the gateway or the fraud floor refused. Show a “try another card” state; do not retry automatically (that risks a double charge on a card that in fact captured).- Before capture the platform enforces the store’s fraud policy — AVS/CVV matching, velocity, bans. AVS/CVV defaults to enforce (fail-closed): a card whose gateway returns no address/CVV match is declined. This is invisible to you except that some cards decline; it is protecting the store.
Coupons at checkout
Pass coupon_code on the body. A refused coupon throws with the same stable
codes preview uses: coupon_not_found (404), coupon_inactive (412),
coupon_exhausted (412). Preview the totals first with
POST /v1/tenants/{tenant}/checkout/preview — it runs the identical pricing
without writing anything or consuming a redemption. See
Discounts and coupons.
Subscriptions require consent evidence
When subscribe: true starts a subscription from the storefront, the request
must also carry a subscriptionConsent block —
{ disclosureRef, disclosureSha256, ipHash?, userAgent? } — or the checkout is
refused with 400 subscription_consent_required. Render the auto-renewal
disclosure before the opt-in control and supply the same fields. See
Checkout for the full requirement.
PCI scope
Any server you run that touches card: { ccnumber, ccexp, cvv } is inside
PCI-DSS scope (SAQ D territory). Keep card fields flowing browser -> your HTTPS
handler -> the API, never logged or stored, and confirm your obligations with
your acquirer before going live.
Related
- Checkout — the deeper reference, including authoritative totals and the consent block.
- Money invariants — integer cents and deterministic idempotency, enforced everywhere money moves.
- Order lifecycle — what happens to the order after it is placed.