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 { subscriptionId, customerId, paymentMethodId }. Persist customerId to your user table if you want lookups by Throttle ID, or skip it and continue using your own ID via externalCustomerId.
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) => {
// result.subscriptionId is populated when recurring + create:'auto'
router.push(`/account/subscriptions/${result.subscriptionId}`);
}}
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.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.