GuidesTutorialsAdd subscriptions

Add subscribe-and-save

This tutorial adds subscriptions to a storefront you already have — for example the one from Build a storefront from scratch. Subscriptions on Flightdeck are a real recurring-billing engine (a schedule, an exactly-once renewal sweep, a dunning ladder). You do not run any of that; you opt a shopper in at checkout with proper consent, and the platform bills on the schedule. Your job on the storefront is three things: render the auto-renewal disclosure, send the consent block with the order, and honor the reminders.

The engine internals — the schedule, the exactly-once renewal invariant, the MIT credential — are described in Subscriptions. This page is only the storefront-facing consent-and-consume flow.

Prerequisites: the manifest gate

Subscribe-and-save is off unless the store’s manifest enables it. Two behavior keys gate the surfaces you are about to build:

  • behavior.subscriptions.enabled — the Subscribe & Save switch. Default false. behavior.subscriptions.discounts maps interval-days ("30", "60") to a percent off, and behavior.subscriptions.autoEnrollBundles defaults false.
  • behavior.accounts.subscriptionSelfServe — the account-page self-serve cancel/pause surface. Defaults true.

These are set operator-side / through the site builder, not the storefront SDK. Read the active manifest with store.site.get() and gate your UI on it — do not show a Subscribe control the backend will refuse.

⚠️

Fail-closed: store.site.get() throws FlightdeckError (404) for an unknown or non-live tenant — never an empty manifest. Render the subscribe UI from the manifest’s actual flags; do not hard-code “subscriptions on”.

How a subscription is created

There is no separate “create subscription” storefront call. A subscription is created as part of an order by passing subscribe: true in the checkout body. The platform starts a subscription for every subscription-eligible line in that order.

Two things are non-negotiable when subscribe: true originates from a storefront:

  1. You must render the auto-renewal disclosure to the shopper before the opt-in control.
  2. The request must carry a subscriptionConsent block proving they saw it.

Without recorded consent evidence the checkout is refused with 400 subscription_consent_required.

subscriptionConsent: {
  disclosureRef: "arl-disclosure-v1",   // the disclosure VERSION the shopper saw
  disclosureSha256: "<hex>",            // SHA-256 of the rendered disclosure text
  ipHash?: "<hex>",                     // optional: SHA-256 of the shopper IP
  userAgent?: "<ua string>",            // optional
}
⚠️

This block is enforced at the checkout route but is not yet part of the generated request types. Include it alongside the typed checkout fields; the compiler will not prompt you for it, but the API requires it whenever subscribe: true comes from a storefront. Verified against Checkout and Subscriptions.

Build the flow

Gate the subscribe control on the manifest

const manifest = await store.site.get();
const subsEnabled = manifest.behavior?.subscriptions?.enabled === true;
// Only render the Subscribe & Save opt-in when subsEnabled is true.

Render the disclosure, then the opt-in

The disclosure must appear before the checkbox, and you must hash the exact text you rendered. Keep the disclosure text versioned so disclosureRef and the hash refer to the same thing.

components/subscribe-consent.tsx
"use client";
import { useState } from "react";
 
// The exact text the shopper sees. Bump DISCLOSURE_REF when this text changes.
export const DISCLOSURE_REF = "arl-disclosure-v1";
export const DISCLOSURE_TEXT =
  "By subscribing, you authorize recurring charges at the interval shown until you cancel. " +
  "We will email you a reminder before each renewal. Cancel anytime from your account.";
 
export function SubscribeConsent({ onChange }: { onChange: (optIn: boolean) => void }) {
  const [checked, setChecked] = useState(false);
  return (
    <fieldset>
      {/* Disclosure FIRST, opt-in control SECOND. */}
      <p>{DISCLOSURE_TEXT}</p>
      <label>
        <input
          type="checkbox"
          checked={checked}
          onChange={(e) => { setChecked(e.target.checked); onChange(e.target.checked); }}
        />
        Subscribe and save
      </label>
    </fieldset>
  );
}

Hash the disclosure server-side

Compute the SHA-256 of the exact disclosure text on the server so the hash the API records matches what you rendered. Do this in the checkout Route Handler, not the browser.

app/api/checkout/route.ts (excerpt)
import { createHash } from "node:crypto";
import { DISCLOSURE_REF, DISCLOSURE_TEXT } from "@/components/subscribe-consent";
 
const disclosureSha256 = createHash("sha256").update(DISCLOSURE_TEXT).digest("hex");

Add the two fields to the same one-shot checkout.submit you already run. The consent block rides alongside the typed fields (it is enforced at the route, not in the generated types).

app/api/checkout/route.ts (excerpt)
const result = await store.checkout.submit(
  {
    customer_ref: `guest:${email}`,
    email,
    items,
    card,
    subscribe: true, // start a subscription for every eligible line
    // Enforced at the route; not in the generated types yet.
    subscriptionConsent: {
      disclosureRef: DISCLOSURE_REF,
      disclosureSha256,
      // Optional hardening — hash the BFF-forwarded shopper IP, pass the UA.
      // ipHash: createHash("sha256").update(shopperIp).digest("hex"),
      // userAgent: req.headers.get("user-agent") ?? undefined,
    },
  } as any, // consent block is not in the generated body type yet
  { idempotencyKey: `cart-${cartId}:attempt-1` },
);

The as any is only to carry subscriptionConsent past the generated type until it is added to the spec. Keep every typed field honest; do not use the cast to slip past real type errors on the rest of the body.

Read the created subscriptions from the response

A successful placement returns a subscriptions array on both the 201 (placed) and 200 (replayed) bodies. Each entry is the freshly created subscription:

// result.subscriptions: Array of…
// { id, plan_ref, interval, interval_count, unit_price_cents, currency, status, next_bill_at }
for (const sub of result.subscriptions) {
  // e.g. confirm "Renews every {interval_count} {interval} — next on {next_bill_at}"
  console.info(sub.id, sub.interval, sub.next_bill_at);
}

interval is one of day | week | month | year; unit_price_cents is integer cents; next_bill_at is an ISO date-time. This response is your confirmation screen’s source of truth for what the shopper just started.

Every store sends a pre-billing renewal reminder, and this is not optional. The renewal sweep covers active and past_due subscriptions.

  • renewalReminderDaysBeforeBilling is per-store, default 7, with an enforced floor of 3. Values below the floor clamp to the default and are logged. Reminders cannot be configured off.
  • Reminders are transactional: a shopper’s marketing unsubscribe never blocks them. A hard bounce records the reminder as undeliverable (visibly), never as sent.

For your storefront this means: the email you collect at checkout (email, which rides the order as customer_contact_email) is what the confirmation and the auto-renewal terms acknowledgment and every renewal reminder deliver to. Collect a real, deliverable address, and make the auto-renewal terms clear at opt-in — the platform enforces the reminder cadence, but the disclosure quality is yours.

⚠️

Do not treat reminders as marketing you can suppress. They are compliance machinery (auto-renewal law). The platform will send them regardless of marketing preferences; your part is a correct email and an honest disclosure.

Reading and managing subscription state

Here is the honest boundary. The storefront SDK (createStorefrontClient) does not wrap shopper subscription list/cancel/pause/resume today — those methods are not on the client, and the routes are not in the generated types. What you do get from the SDK is the subscriptions array on the checkout response above.

For ongoing management you have two real options:

When behavior.accounts.subscriptionSelfServe is true (the default), the platform’s account surface gives shoppers self-serve cancel / pause / resume. If you use the hosted account experience, you do not build this at all — it is already there, including the cancellation-confirmation email and the resume-recomputes-forward (never back-bills) behavior.

Recap

Gate on the manifest

Only show Subscribe & Save when behavior.subscriptions.enabled is true.

Disclose, then opt in

Render the auto-renewal disclosure before the checkbox; version its text.

Pass subscribe: true and the subscriptionConsent block (hashed server-side) to checkout.submit. No consent means 400 subscription_consent_required.

Confirm from the response

Read the subscriptions array off the placement result for your confirmation UI.

Let the platform bill and remind

The renewal sweep charges on schedule and sends the mandatory reminders. Manage ongoing state via the hosted account page or the shopper-scoped routes.