GuidesIntegrate checkout

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_TOKEN in 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 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; 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.

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.

  • 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.