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 subscriptionId already populated.

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.

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

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