Creating Subscriptions
Three patterns map to three integration trade-offs. Pick the one that fits how much control you want over the create call.
plan, planName
, amount, and interval values you pass on the recurring block come
from your application — not from Throttle. We treat planReference as opaque text scoped to your merchant
and never validate it against any list. Two workspaces can both have a
'pro_monthly' reference without collision.
Most teams keep plans in their own database (a
plans
table or a static config in the repo). That source-of-truth is what your storefront pulls
from to render the plan picker, and what your backend forwards into the recurring block. A
managed plan catalog inside Throttle is not part of v1.
Pattern A — Server auto-creates (default)
Set recurring.create: 'auto' (the default) and
Throttle creates the subscription atomically when the card is vaulted. Your onSucceeded callback fires with subscriptionIds (and subscriptionId when exactly one was created).
import { createSubscriptionsClient } from '@usethrottle/subscriptions/server';
const subscriptions = createSubscriptionsClient({
apiKey: process.env.THROTTLE_SECRET_KEY!,
});
// recurring.create defaults to 'auto'.
// Throttle creates the subscription as soon as the card is vaulted.
const session = await subscriptions.createCheckoutSession({
applicationId, externalCartId, returnUrl, cancelUrl,
customer: { externalCustomerId: user.id, email: user.email },
recurring: { plan: 'pro_monthly', interval: 'monthly', amount: 2999 },
});
// In the embed:
<PaymentEmbed
sessionId={session.sessionId}
parentOrigin="https://shop.example.com"
onSucceeded={({ subscriptionId }) => router.push(`/account/${subscriptionId}`)}
/>; Use this when you want the path of least resistance. The merchant writes zero code on the success path — vault, sub create, and notification all happen on Throttle's side.
subscription.create_failed with
a reason code. Listen for it and decide your fallback: retry from your own backend, contact
support, or surface the error to the buyer.
A checkout creates all of its subscriptions together or none of them. If any create fails, none exist, the order still stands (the money was captured), and one
subscription.create_failed fires per subscription line,
carrying its lineItemId.
Pattern B — Merchant backend creates explicitly
Set recurring.create: 'manual' when you want full
control. The embed only vaults; you call subscriptions.create yourself.
// Set create:'manual' to opt out of auto-create.
const session = await subscriptions.createCheckoutSession({
// ...
recurring: { plan: 'pro_monthly', interval: 'monthly', amount: 2999, create: 'manual' },
});
// onSucceeded is a UI signal. Your backend creates the subscription
// after the checkout has vaulted the card for this externalCustomerId.
<PaymentEmbed
sessionId={session.sessionId}
parentOrigin="https://shop.example.com"
onSucceeded={async ({ orderId, paymentId }) => {
await fetch('/api/subscribe', {
method: 'POST',
body: JSON.stringify({ orderId, paymentId, plan: 'pro_monthly' }),
});
}}
/>; // MERCHANT BACKEND — call subscriptions.create yourself
import { createSubscriptionsClient } from '@usethrottle/subscriptions/server';
const subscriptions = createSubscriptionsClient({
apiKey: process.env.THROTTLE_SECRET_KEY!,
});
export async function POST(req: Request) {
const { userId, plan } = await req.json();
const now = new Date();
const periodEnd = new Date(now);
periodEnd.setMonth(periodEnd.getMonth() + 1);
const sub = await subscriptions.create({
externalCustomerId: userId,
planReference: plan,
interval: 'monthly',
amount: 2999,
currentPeriodStart: now.toISOString(),
currentPeriodEnd: periodEnd.toISOString(),
metadata: { source: 'manual_post_checkout' },
});
// Persist your own mapping if needed
await db.userSubscriptions.create({ userId, subId: sub.id });
return Response.json({ subscriptionId: sub.id });
} Use this when you need to look up plan details from your own DB, run business logic before subscribing, or attach merchant-specific metadata that the embed cannot know about.
Pattern C — Webhook-driven creation
Use the payment.vaulted webhook. Survives a buyer
closing their tab between vault and create. Recommended when reliability matters more than
latency.
// MERCHANT BACKEND — webhook-driven creation.
// Trigger fires regardless of create mode; useful for survival across tab-close.
import { createSubscriptionsClient } from '@usethrottle/subscriptions/server';
import { verifyThrottleWebhook } from '@/lib/throttle/webhooks';
const subscriptions = createSubscriptionsClient({
apiKey: process.env.THROTTLE_SECRET_KEY!,
});
export async function POST(req: Request) {
const rawBody = await req.text();
const signature = req.headers.get('x-throttle-signature');
if (!signature || !verifyThrottleWebhook(rawBody, signature, process.env.THROTTLE_WEBHOOK_SECRET!)) {
return new Response('Invalid signature', { status: 400 });
}
const event = JSON.parse(rawBody);
if (event.type !== 'payment.vaulted') return Response.json({ ok: true });
const { customerId, paymentMethodId, recurring } = event.data;
if (!recurring) return Response.json({ ok: true }); // not a subscription intent
// Idempotent: only create if we haven't already (auto mode may have beaten us)
const existing = await subscriptions.list({ customerId, status: 'active' });
if (existing.data.some((s) => s.metadata?.checkoutSessionId === event.data.sessionId)) {
return Response.json({ ok: true });
}
await subscriptions.create({
customerId,
planReference: recurring.plan,
interval: recurring.interval,
amount: recurring.amount,
currentPeriodStart: new Date().toISOString(),
currentPeriodEnd: addInterval(new Date(), recurring.interval).toISOString(),
metadata: { checkoutSessionId: event.data.sessionId },
});
return Response.json({ ok: true });
}
Combine with create: 'manual' on the embed: auto-mode
and webhook-driven creation both racing to call create would double-bill (Throttle
de-dupes, but it's still cleaner to pick one).
Choosing
| Goal | Use |
|---|---|
| Fastest to integrate | Pattern A (auto) |
| Custom plan/business logic | Pattern B (manual) |
| Survives tab-close, weak network | Pattern C (webhook) |
| Server-side only (no embed) | Direct subscriptions.create with a pre-vaulted payment method ID |
Direct creation (no embed)
For B2B flows where you've already collected a card via another mechanism (a sales rep
enters it through your admin tool), call subscriptions.create directly. A subscription created
this way has no card of its own (paymentMethodId: null), so
it renews on the customer's default card. Pre-condition: a customerPaymentMethods row already exists with isDefault: true for the customer — otherwise the
renewal cron has nothing to charge. Subscriptions Throttle creates from a checkout store
the card the buyer paid with instead; see Which
card a renewal charges.
Linking the order that paid for the first period
When Throttle creates the subscription (Pattern A) it links the signup order
itself. When you charge the first period and then create the subscription
(Pattern B, Pattern C, or a proxy checkout you completed yourself), nothing
connects the two — so that order shows no subscription, and the
subscription's Orders tab is missing its own signup charge. Pass orderId to fix it:
// You charged the first period yourself, then created the sub.
// Without orderId the two records never point at each other.
const sub = await subscriptions.create({
customerId,
planReference: 'pro_monthly',
interval: 'monthly',
amount: 9900,
currentPeriodStart: new Date().toISOString(),
currentPeriodEnd: addInterval(new Date(), 'monthly').toISOString(),
// The order that collected the signup charge.
orderId: signupOrder.id,
});
Throttle writes the link both ways: the order gets a subscriptionId of its own (and keeps carrying the
legacy metadata.subscription_id pointer), and the
subscription's first invoice records the orderId that collected it. So the order links to
the subscription and GET /v1/orders?subscriptionId=... returns it. The
order must exist in the same application and environment; if it does not, the call
returns 404 and no subscription is created.
Renewals need no orderId: the renewal cron creates
those orders and stamps them itself.
An order Throttle created with several subscriptions lists them in subscriptionIds; subscriptionId is set only when there is exactly one.
Passing such an order as orderId — one whose lines already
link other subscriptions — creates the subscription but leaves the order's links
unchanged, so the order never appears to have only the new one.
Custom cadence and installment plans
Every create path above accepts a cadence beyond the five named intervals, and an optional fixed number of payments:
-
interval(named), orintervalUnit+intervalCountfor any cadence from 1 hour to 1 year, or both together (they must agree). Full guide: Custom Cadence → -
totalPayments(2–60) makes it an installment plan. The paid signup is payment 1; the plan completes and stops billing the moment payment N succeeds, emittingsubscription.completed. It cannot be combined with a trial (400 invalid_combination), and cannot be changed after creation (400 total_payments_immutable). Full guide: Installment Plans → -
schedule(2–60 rows) makes it an installment plan whose payments differ in amount and in the gap before each. Row 0 is the payment already collected;amountis then optional (if sent it must equalschedule[0].amount) andcurrentPeriodEndis optional too: Throttle derives it as payment 2's due time.schedulecannot be combined withinterval,intervalUnit,intervalCount,totalPaymentsortrialEnd(400 invalid_combination). Mistakes answer400 invalid_scheduleor400 schedule_first_payment_mismatch. Full guide: Custom schedules →
recurring.count is
read-only display data ("· 3 payments") that mirrors the line's totalPayments; it is not accepted on input. Use totalPayments in a cart or quote line's recurring block, on the session's recurring intent, or
on subscriptions.create to cap the number of charges. See Mixed Carts for subscription
lines.
Next
- Customer Identity — how to identify a customer without storing Throttle's ID.
- Subscription Webhooks — full event reference.