End-to-end checkout flow
Create a checkout server-side, redirect the customer, and fulfill from a webhook instead of trusting the success page — plus a reconciliation path.
The complete flow has three moving parts: a server route that creates the checkout, a redirect, and a webhook that actually grants access. The success page is a user-experience step, not a fulfillment step.
Create the checkout server-side
Never call the billing provider from the browser — that would mean shipping your API key. Post the
checkoutRef to your own route and create the checkout there.
// POST /api/checkout
import { createClient, RevenueError } from 'revenue-sdk';
import { stripe } from 'revenue-sdk/stripe';
export async function POST(request: Request): Promise<Response> {
const user = await requireUser(request);
const form = await request.formData();
const checkoutRef = String(form.get('checkoutRef') ?? '');
const client = createClient({
provider: stripe({ secretKey: process.env.STRIPE_SECRET_KEY! }),
});
try {
const checkout = await client.checkouts.create({
items: [{ product: checkoutRef }],
customerId: user.billingCustomerId ?? undefined,
customerEmail: user.billingCustomerId ? undefined : user.email,
successUrl: 'https://example.com/billing/return?session={CHECKOUT_SESSION_ID}',
// Bootstraps the mapping from the provider's IDs back to your user.
metadata: { userId: user.id },
});
return Response.redirect(checkout.url, 303);
} catch (error) {
if (error instanceof RevenueError && error.code === 'not_found') {
return new Response('That plan is no longer available', { status: 410 });
}
throw error;
}
}Redirect the customer
On Polar, Lemon Squeezy, Stripe, and Dodo Payments, checkout.url is a provider-hosted page you can
redirect to directly. On Paddle it is not — check the capability:
if (client.capabilities.hostedCheckout) {
return Response.redirect(checkout.url, 303);
}
// Paddle: render your own Paddle.js page and open the transaction there.
return Response.redirect(`/checkout?transaction=${checkout.id}`, 303);successUrl is likewise gated by checkoutSuccessUrl — Paddle configures the post-payment redirect
in Paddle.js instead. See the Paddle page.
Show a pending state on return
The customer lands back on your success URL. Do not grant access here.
// GET /billing/return
export async function GET(request: Request): Promise<Response> {
const sessionId = new URL(request.url).searchParams.get('session');
if (!sessionId) return render('pending');
const checkout = await client.checkouts.get({ id: sessionId });
// Read-only confirmation for the UI. Access is granted by the webhook.
return render(checkout.status === 'complete' ? 'confirmed' : 'pending');
}A page that polls your own API for the entitlement — rather than the provider — gives the customer instant feedback without trusting the redirect:
// The webhook writes this; the page polls it.
const { entitled } = await fetch('/api/me/entitlement').then((r) => r.json());Fulfill from the webhook
The webhook is the authoritative signal. Both checkout.completed and the subscription events are
useful — the checkout event carries your metadata, the subscription events carry the state you’ll
keep in sync afterwards.
import { parseWebhookEvent, verifyWebhook } from 'revenue-sdk/stripe';
export async function POST(request: Request): Promise<Response> {
const headers = request.headers;
const body = await request.text();
if (!(await verifyWebhook({ headers, body, secret: process.env.STRIPE_WEBHOOK_SECRET! }))) {
return new Response('invalid signature', { status: 401 });
}
const event = await parseWebhookEvent({ headers, body });
switch (event.type) {
case 'checkout.completed': {
const checkout = event.checkout!;
const userId = checkout.metadata?.userId;
if (typeof userId === 'string' && checkout.customerId) {
// Bootstrap the mapping so later events don't need metadata at all.
await linkBillingCustomer(userId, checkout.customerId);
}
break;
}
case 'subscription.created':
case 'subscription.updated':
case 'subscription.canceled':
await upsertSubscription(event.subscription!);
break;
case 'order.paid':
await recordPayment(event.order!);
break;
}
return new Response(null, { status: 204 });
}checkout.completed only fires where the provider exposes a checkout event the SDK can confirm as
paid (Polar and Stripe). Subscription events fire everywhere — so make the subscription events the
source of entitlement and treat checkout.completed as the moment you learn which of your users a
provider customer belongs to.
Persisting the link between your user and the provider
The single most valuable thing to store is the mapping:
your user id ⇄ provider customer id ⇄ provider subscription id
Bootstrap it from checkout metadata on the first event, then rely on the IDs:
async function upsertSubscription(subscription: Subscription): Promise<void> {
const userId =
(typeof subscription.metadata?.userId === 'string' ? subscription.metadata.userId : undefined) ??
(await findUserByBillingCustomerId(subscription.customerId));
if (!userId) {
// Store it as orphaned and reconcile later rather than dropping it.
await storeOrphanSubscription(subscription);
return;
}
await db.subscriptions.upsert({
where: { providerSubscriptionId: subscription.id },
data: {
userId,
status: subscription.status,
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
currentPeriodEnd: subscription.currentPeriodEnd,
endsAt: subscription.endsAt,
},
});
}
Reconciling a missed webhook
Webhooks fail. Give yourself a manual path that re-reads the truth from the provider:
export async function reconcile(userId: string): Promise<void> {
const user = await getUser(userId);
if (!user.billingCustomerId) return;
for await (const subscription of client.subscriptions.listAll({
customerId: user.billingCustomerId,
})) {
await upsertSubscription(subscription);
}
}
The customerId filter needs the listSubscriptionsByCustomer capability — Lemon Squeezy lacks it, so
there you walk subscriptions.listAll() and match on customerId yourself.