StorefrontsCheckout

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: true creates a subscription from the storefront, the request must also carry a subscriptionConsent block: { 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 typed email also rides the order event as customer_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 on order (order.id, order.total_cents); card_charged_cents is what actually hit the card after other tenders.
  • Replayed (200) — the same idempotency key seen again returns the original order with outcome: "already_placed" and replayed: 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.)