Subscribe a Buyer in Five Minutes
End-to-end recurring billing with the Throttle embed. Three files: backend session route, frontend embed, webhook handler.
Flow
Create a checkout session with a recurring block
Your backend POSTs to /api/v1/checkout/sessions. The recurring block tells Throttle to vault the card and seed a subscription on payment success. Pass externalCustomerId so you can reconcile the subscription to your customer record.
Render the embed with the recurring prop
<PaymentEmbed sessionId={sessionId} recurring={{ plan: 'pro_monthly' }} /> handles the buyer-facing card form. Card collection, 3DS, and vault all happen inside the iframe — your storefront does not see card data.
Read the success payload
onSucceeded fires with { orderId, paymentId, subscriptionIds }: one id per subscription line, so read result.subscriptionIds[0] for a single plan (subscriptionId is set only when there is exactly one). Keep using your own customer ID via externalCustomerId to reconcile.
Subscribe to the webhook stream
Listen for subscription.created, subscription.renewed, subscription.past_due, and subscription.payment_failed. Your DB stays in sync without polling.
1. Backend route — create the session
The recurring block is the only difference from a
one-time payment session. Throttle stores it on the session metadata and uses it to create
the subscription when the embed reports a successful vault.
// MERCHANT BACKEND (e.g. /api/checkout/start)
import { createSubscriptionsClient } from '@usethrottle/subscriptions/server';
const subscriptions = createSubscriptionsClient({
apiKey: process.env.THROTTLE_SECRET_KEY!,
});
export async function POST(req: Request) {
const { user, planId } = await req.json();
const session = await subscriptions.createCheckoutSession({
applicationId: process.env.THROTTLE_APPLICATION_ID!,
cartId: undefined, // skip cart for plan-based subs
externalCartId: `sub-${user.id}-${Date.now()}`,
customer: {
externalCustomerId: user.id, // your own user ID
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
},
returnUrl: 'https://yourstore.com/account/subscriptions',
cancelUrl: 'https://yourstore.com/pricing',
recurring: {
plan: planId, // your own plan reference
interval: 'monthly',
amount: 2999, // cents; defaults to cart total if omitted
trialDays: 14, // optional
create: 'auto', // default — Throttle creates the sub
},
});
return Response.json({ sessionId: session.sessionId });
} 2. Frontend — render the embed
The recurring prop on the embed is optional; when
omitted, the session's recurring metadata still drives the backend. Pass it explicitly
only if you want type-safe disambiguation in your client code.
// MERCHANT FRONTEND
'use client';
import { PaymentEmbed } from '@usethrottle/checkout-react';
import { useRouter } from 'next/navigation';
export function SubscribeForm({ sessionId }: { sessionId: string }) {
const router = useRouter();
return (
<PaymentEmbed
sessionId={sessionId}
parentOrigin="https://yourstore.com"
baseUrl="https://checkout.usethrottle.dev"
onSucceeded={(result) => {
// one per subscription line; subscriptionId is set only when there is exactly one
router.push(`/account/subscriptions/${result.subscriptionIds?.[0]}`);
}}
onFailed={(err) => console.error('Subscribe failed:', err)}
/>
);
} 3. Webhook handler — react to lifecycle events
Webhooks are the source of truth for state. The embed callback is convenient but not durable — a buyer who closes their tab still triggers webhooks.
// MERCHANT BACKEND (/api/webhooks/throttle)
import { verifyThrottleWebhook } from '@/lib/throttle/webhooks';
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);
switch (event.type) {
case 'subscription.created':
// Provision Pro features for event.data.subscription.customerId
break;
case 'subscription.payment_failed':
// Send a "your card was declined" email
break;
case 'subscription.cancelled':
// De-provision; check event.data.reason for 'dunning_exhausted'
break;
case 'subscription.renewed':
// Optional: ledger entry for the new billing period
break;
}
return Response.json({ ok: true });
} recurring.create: 'manual' if you'd rather call subscriptions.create from your own backend after vault
success. See Creating Subscriptions
for the three patterns.
recurring.interval: 'monthly' above is the common case.
Send intervalUnit + intervalCount
instead (or as well) for any cadence from 1 hour to 1 year, and totalPayments (2–60) to make it an installment plan that ends
itself after N payments — no trial allowed together with it. See Lifecycle and States
for the bounds and
Creating Subscriptions
for the installment rules.
Next
- Customer Identity — why externalCustomerId beats storing Throttle's customer ID.
- Lifecycle and States — what happens between renewals and what dunning looks like.
- Buyer Portal — give buyers a way to cancel without emailing support.