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 subscriptionId already populated.
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.
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. Pre-condition: a customerPaymentMethods row already exists with isDefault: true for the customer — otherwise the
renewal cron has nothing to charge.
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 stamps metadata.subscription_id on that
order — the same pointer renewals carry — 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.
Next
- Customer Identity — how to identify a customer without storing Throttle's ID.
- Subscription Webhooks — full event reference.