Subscriptions

Creating Subscriptions

Three patterns map to three integration trade-offs. Pick the one that fits how much control you want over the create call.

Throttle does not own a plan catalog
The 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).

tsx
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.

What happens if sub creation fails after vault succeeds?
Throttle treats sub-create-after-vault as a single transactional unit. If the sub create fails, the vault row is still written (the buyer's card is on file), but no subscription exists. Throttle emits 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.

frontend
// 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' }),
    });
  }}
/>;
backend
// 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.

ts
// 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

GoalUse
Fastest to integratePattern A (auto)
Custom plan/business logicPattern B (manual)
Survives tab-close, weak networkPattern 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:

ts
// 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), or intervalUnit + intervalCount for 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, emitting subscription.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; amount is then optional (if sent it must equal schedule[0].amount) and currentPeriodEnd is optional too: Throttle derives it as payment 2's due time. schedule cannot be combined with interval, intervalUnit, intervalCount, totalPayments or trialEnd (400 invalid_combination). Mistakes answer 400 invalid_schedule or 400 schedule_first_payment_mismatch. Full guide: Custom schedules →
Send totalPayments, not recurring.count
A cart or order line item's 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