Checkout
Checkout on Flightdeck is one shot: you submit the cart and the card together, and the platform authorizes payment, runs the fraud floor, prices the authoritative total (shipping + tax), and places the order — or returns an honest decline. There is no separate “create cart -> hosted checkout” step.
Where checkout runs
store.checkout.submit(...) sends card fields, so it must run server-side —
a Route Handler, a server action, an edge function. The shopper’s browser posts
the cart + card to your server; your server calls the SDK. The
FLIGHTDECK_STOREFRONT_TOKEN never leaves the server. (This is exactly what the
starter’s /api/checkout route does — it’s the reference implementation.)
Placing an order
// In a server Route Handler — NEVER in the browser.
const result = await store.checkout.submit(
{
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" },
// optional:
// coupon_code: "WELCOME10",
// apply_store_credit: true,
// gift_card_code: "GIFT-…",
// subscribe: true, // subscribe-and-save, if eligible
// expedited: true, // pick the expedited shipping rate
// shipping_address: { … }, // ship-to for shipping/tax
// email: "shopper@example.com", // order-confirmation address
// ip: "203.0.113.9", // BFF-forwarded shopper IP (fraud velocity)
// referral_code: "lori-h", // ?ref= attribution, if you carry it
// shipping: { … }, tax: { … }, // store POLICY block — see below
},
{ idempotencyKey: `cart-${cartId}:attempt-1` }, // -> the Idempotency-Key header
);Subscriptions require consent evidence. When
subscribe: truecreates a subscription from the storefront, the request must also carry asubscriptionConsentblock:{ disclosureRef, disclosureSha256, ipHash?, userAgent? }— the auto-renewal disclosure version the shopper saw (e.g.arl-disclosure-v1), a SHA-256 hex of the rendered disclosure text, and optionally a SHA-256 hash of the shopper’s IP plus their user agent. The hosted checkout sends this automatically; a custom frontend must render the disclosure before the opt-in control and supply the same fields, or the checkout is refused (400 subscription_consent_required). This block is enforced at the route but is not yet part of the generated request types — include it alongside the typed fields. The shopper’s typedcustomer_contact_email— it is what the order-confirmation (and its auto-renewal terms acknowledgment) delivers to.
Field names are the API’s (
snake_case) and fully typed by the SDK — your editor will complete them and reject a wrong one. The exact accepted fields come from the generated types; the above are the common ones.
The authoritative total
You never send a client-computed total — there is no such field. The platform
prices the authoritative total server-side from its own catalog prices,
plus shipping (standard vs expedited) and tax from the store’s policy. The
optional shipping / tax blocks in the request carry the store’s policy
(forwarded server-side by the BFF from the resolved manifest), never a number
the browser computed — so a tampered client can’t underpay. A store on a real
tax provider that can’t compute tax fails closed (a real error), never a
silent $0-tax order.
Idempotency — avoid double charges
The Idempotency-Key header is required — the API returns 400 without it.
The SDK sends it from the second argument:
store.checkout.submit(body, { idempotencyKey }). It makes a retried submit
safe: the same key replays the original outcome instead of charging twice. Make
it deterministic — derive it from the business event (e.g.
`cart-${cartId}:attempt-${attempt}`), never a fresh random value per
call, and reuse it across retries of that attempt. Don’t regenerate it on
every button click.
PCI scope — a plain statement
The checkout API accepts raw card data (card: { ccnumber, ccexp, cvv }).
Any server you run that touches those fields — including the BFF handler in
the sample above — is inside PCI-DSS scope (SAQ D territory), because
cardholder data transits a system you operate. That’s a fact of this
integration shape, not a defect: keep the sample’s server-side-only design
(card fields go browser -> your HTTPS handler -> the API, and are never logged
or stored), and confirm your compliance obligations with your acquirer before
going live.
The fraud floor
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 codes is declined. This is invisible to you except that some cards decline; it’s protecting the store.
Reading the outcome
Handle every branch:
- Placed (201) — the body is
{ order, items, outcome: "placed", card_charged_cents, store_credit_applied_cents, gift_card_applied_cents, 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. - Declined — payment or fraud declines are a 4xx, so the SDK throws
FlightdeckError(body{ error: { code: "payment_declined", message } }). Show a “try another card” state with the surfaced reason; do not retry automatically (risking a double charge). - Error — any other thrown failure is real. Surface it; don’t pretend the order placed.
try {
const result = await store.checkout.submit(body, { idempotencyKey });
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
}After the order
- Cancel / edit / refund a placed order with
store.checkout.orders.cancel(id, { idempotencyKey }),.edit(id, { idempotencyKey }, body),.refund(id, { idempotencyKey }, body)— every money-mutating call takes the same required idempotency options. - Order history for a signed-in shopper -> Accounts & orders.
Post-order marketing (optional)
If you fire an ad-platform conversion (a “Purchase” beacon) after an order, gate it on cookie consent and never let it fail the checkout. The order is already placed; a marketing fire that throws must be caught and ignored, not bubbled into a 500 the shopper sees after they’ve paid. (The starter does this.)