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;
}
}Pass customerId when you already know the provider customer — it keeps a returning customer’s
payment methods and invoices on one record. Optional fields that not every provider accepts are
gated by a capability, so branch on the flag instead of hard-coding a provider:
customAmount: client.capabilities.checkoutCustomAmount ? pickedAmountInMinorUnits : undefined,
expiresAt: client.capabilities.checkoutExpiresAt ? new Date(Date.now() + 60 * 60 * 1000) : undefined,Where checkoutExpiresAt is false the provider applies its own fixed lifetime. One difference has
no flag: Lemon Squeezy rejects customerId on checkouts and throws unsupported, so send
customerEmail there. See the capability matrix and
Checkouts.
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. Build the endpoint itself — verification, dedupe, fast
204 — from the webhook handler guide; this is the branch that
matters for fulfillment:
// Inside that handler's switch (event.type).
case 'checkout.completed': {
const userId = event.checkout.metadata?.userId;
if (typeof userId === 'string' && event.checkout.customerId) {
// Bootstrap the mapping so later events don't need metadata at all.
await linkBillingCustomer(userId, event.checkout.customerId);
}
break;
}
case 'order.paid':
await recordPayment(event.order);
break;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 resolveUserId(subscription: Subscription): Promise<string | undefined> {
return (
(typeof subscription.metadata?.userId === 'string' ? subscription.metadata.userId : undefined) ??
(await findUserByBillingCustomerId(subscription.customerId))
);
}
Feed the result into the single upsertSubscription defined in
the webhook handler guide — one implementation, shared by the webhook
and every mutating call. When no user matches, store the subscription as orphaned and reconcile it later
rather than dropping it.
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.