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.discountsmaps interval-days ("30","60") to a percent off, andbehavior.subscriptions.autoEnrollBundlesdefaults 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:
- You must render the auto-renewal disclosure to the shopper before the opt-in control.
- The request must carry a
subscriptionConsentblock proving they saw it.
Without recorded consent evidence the checkout is refused with
400 subscription_consent_required.
The consent block
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.
"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.
import { createHash } from "node:crypto";
import { DISCLOSURE_REF, DISCLOSURE_TEXT } from "@/components/subscribe-consent";
const disclosureSha256 = createHash("sha256").update(DISCLOSURE_TEXT).digest("hex");Submit the order with subscribe and the consent block
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).
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.
Renewal reminders — a legal obligation you inherit
Every store sends a pre-billing renewal reminder, and this is not optional. The
renewal sweep covers active and past_due subscriptions.
renewalReminderDaysBeforeBillingis 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.
Send consent with the order
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.
Related
- Checkout — the full submit body and the consent note.
- Subscriptions — the engine, routes, and manifest gates.
- Build a storefront from scratch — the storefront this builds on.
- SDK reference — the storefront client surface.