# Subscriptions A subscription is a Checkout Session in `subscription` mode with a recurring inline price. Stripe bills the card every period and tells the app through the webhook. ## The Table ```sql create table subscriptions ( id uuid primary key default uuidGenerateV7(), createdAt timestamptz not null default now(), updatedAt timestamptz not null default now(), userId uuid not null unique references users (id), stripeSubscriptionId text not null unique, stripeCustomerId text not null, status text not null, currentPeriodEnd timestamptz, cancelAtPeriodEnd boolean not null default false ); ``` `status` mirrors Stripe's: `active`, `past_due`, `canceled` and the rest. Gate paid features on `status in ('active', 'past_due')`, and keep access until `currentPeriodEnd` after a cancel. ## Start a Subscription ```ts let checkout = await stripe().checkout.sessions.create({ mode: "subscription", customer_email: email, client_reference_id: userId, line_items: [{ quantity: 1, price_data: { currency: "usd", unit_amount: plan.priceCents, recurring: { interval: "month" }, product_data: { name: plan.name }, }, }], success_url: `${getAppUrl()}/checkout/return?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${getAppUrl()}/pricing`, }); ``` ## Sync From Stripe One function copies a subscription's state into the row. The return page, the webhook and the cancel rpc all call it. Only a call that knows the user creates the row; a webhook for a subscription the app has not seen yet only updates, and the return page or `checkout.session.completed` creates it. ```ts // app/shared/subscriptions.ts import Stripe from "stripe"; import { sql } from "@elements/app"; import { stripe } from "#app/shared/stripe"; export async function syncSubscription(subscriptionId: string, userId?: string) { let sub: Stripe.Subscription = await stripe().subscriptions.retrieve(subscriptionId); let periodEnd = sub.items.data[0]?.current_period_end; let status = sub.status; let end = periodEnd ? new Date(periodEnd * 1000) : null; if (!userId) { sql(` update subscriptions set status = ${status}, currentPeriodEnd = ${end}, cancelAtPeriodEnd = ${sub.cancel_at_period_end} where stripeSubscriptionId = ${sub.id} `); return; } sql(` insert into subscriptions (userId, stripeSubscriptionId, stripeCustomerId, status, currentPeriodEnd, cancelAtPeriodEnd) values (${userId}, ${sub.id}, ${sub.customer as string}, ${status}, ${end}, ${sub.cancel_at_period_end}) on conflict (stripeSubscriptionId) do update set status = excluded.status, currentPeriodEnd = excluded.currentPeriodEnd, cancelAtPeriodEnd = excluded.cancelAtPeriodEnd `); } ``` A session in subscription mode carries `subscription`, and its `client_reference_id` is the user, not an order. Branch on it at the top of `fulfillCheckout` (`payments/checkout`), right after the paid check, so both the return page and `checkout.session.completed` create the row: ```ts if (checkout.mode === "subscription") { await syncSubscription(checkout.subscription as string, checkout.client_reference_id); return true; } ``` In the webhook route, add: ```ts case "customer.subscription.updated": case "customer.subscription.deleted": await syncSubscription(event.data.object.id); break; case "invoice.paid": // A renewal. Send a receipt here if the app sends its own. break; ``` ## Cancel From the App Cancel through the API, so the owner configures nothing in the dashboard: ```ts /** @rpc */ export async function cancelSubscription() { let userId = session.getOrThrow("userId"); let row = sql<{ stripeSubscriptionId: string }>( `select stripeSubscriptionId from subscriptions where userId = ${userId}`, ).firstOrThrow(); await stripe().subscriptions.update(row.stripeSubscriptionId, { cancel_at_period_end: true }); await syncSubscription(row.stripeSubscriptionId); } ``` Resume is the same call with `cancel_at_period_end: false`. The rpc syncs right away, so the page updates without waiting for the webhook. In development, without `stripe listen`, renewals never arrive; the row is still right for everything the app itself does. ## Related - `payments/webhook`: the route these cases go in.