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.

Next