Subscriptions look simple in a demo and turn brittle in production. The hard part is not creating a Checkout Session, it is keeping your database in agreement with Stripe when webhooks arrive twice, arrive out of order, or fail to arrive at all. This guide covers the architecture we ship for recurring billing in Next.js: Checkout Sessions, signature-verified webhooks, idempotent event handling, a reconciliation cron as a fallback, the customer portal, and proration. It assumes the App Router and a relational database, and reflects how the Stripe API and tooling behave in 2026.
The mental model: Stripe is the source of truth, your DB is a cache
The single most useful decision you can make is to treat Stripe as the system of record for subscription state, and your own database as a denormalized cache of it. Your app should never compute "is this user subscribed" from a tangle of local booleans set across different code paths. It should read one cached row that mirrors the Stripe subscription, and that row should only ever be written by reconciliation logic — never directly by a checkout success handler.
This framing solves most billing bugs before they happen. Webhooks become "invalidate and refresh the cache" signals rather than the only path to correctness. If a webhook is lost, the cache is merely stale, not wrong forever, because a periodic job will repair it.
A minimal cache table looks like this:
CREATE TABLE billing_subscriptions (
id TEXT PRIMARY KEY, -- Stripe subscription id (sub_...)
user_id TEXT NOT NULL REFERENCES users(id),
stripe_customer_id TEXT NOT NULL,
status TEXT NOT NULL, -- active, trialing, past_due, canceled...
price_id TEXT NOT NULL,
current_period_end TIMESTAMPTZ NOT NULL,
cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX ON billing_subscriptions (stripe_customer_id);
-- Dedupe table for webhook idempotency
CREATE TABLE processed_stripe_events (
event_id TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);Store the stripe_customer_id on the user the first time you create a customer, and reuse it forever. Creating a fresh customer per checkout is the most common cause of duplicate billing profiles and broken portals.
Creating the Checkout Session
Checkout is the path we recommend for almost every team. It is PCI-compliant by default, handles 3D Secure, taxes, and local payment methods, and removes a large surface of frontend payment code. Build the session on the server, in a route handler, and always attach a stable customer.
// app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { getUser, getOrCreateStripeCustomer } from "@/lib/billing";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const user = await getUser(req);
if (!user) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
const customerId = await getOrCreateStripeCustomer(user);
const session = await stripe.checkout.sessions.create(
{
mode: "subscription",
customer: customerId,
line_items: [{ price: process.env.STRIPE_PRICE_PRO!, quantity: 1 }],
success_url: `${process.env.APP_URL}/billing?status=success&sid={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/pricing?status=cancelled`,
allow_promotion_codes: true,
subscription_data: { metadata: { user_id: user.id } },
client_reference_id: user.id,
},
{ idempotencyKey: `checkout:${user.id}:${process.env.STRIPE_PRICE_PRO}` }
);
return NextResponse.json({ url: session.url });
}Two details matter. First, put user_id in subscription_data.metadata so every downstream subscription and invoice event can be traced back to a user without a lookup table. Second, pass an idempotencyKey on the create call. If a user double-clicks or a network retry fires, Stripe returns the same session instead of starting two.
Do not grant access on the success_url. The redirect is a UX signal, not a payment confirmation — a user can land there before the charge settles, or close the tab before redirect. Access is granted by reconciliation, described below.
Webhooks: verify the signature, then do nothing clever
Webhooks are how Stripe tells you what actually happened. The first rule is to verify the signature against the raw request body. In the App Router this means reading the body as text, not parsed JSON, because any reserialization breaks the signature.
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { headers } from "next/headers";
import { syncSubscriptionFromStripe, markEventProcessed, alreadyProcessed } from "@/lib/billing";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const secret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: Request) {
const body = await req.text();
const sig = (await headers()).get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, secret);
} catch {
return new Response("invalid signature", { status: 400 });
}
if (await alreadyProcessed(event.id)) {
return new Response("ok", { status: 200 }); // idempotent replay
}
switch (event.type) {
case "checkout.session.completed":
case "customer.subscription.created":
case "customer.subscription.updated":
case "customer.subscription.deleted":
case "invoice.paid":
case "invoice.payment_failed": {
const customerId = resolveCustomerId(event);
if (customerId) await syncSubscriptionFromStripe(customerId);
break;
}
}
await markEventProcessed(event.id);
return new Response("ok", { status: 200 });
}Notice what this handler does not do. It does not read amounts or statuses out of the event payload and write them to your tables. Instead, for any relevant event it calls one function — syncSubscriptionFromStripe — that fetches the current subscription from the API and overwrites the cache row. This is deliberate.
Why a single sync function beats per-event logic
Webhook events do not arrive in order. You can receive customer.subscription.updated before created, or an invoice.paid for a state you have not seen yet. If each handler mutates the database based on its own payload, out-of-order delivery corrupts your state. By always re-reading the canonical object and writing the whole row, the final state is correct regardless of arrival order. The events become triggers, and the API read is the truth.
export async function syncSubscriptionFromStripe(customerId: string) {
const subs = await stripe.subscriptions.list({
customer: customerId,
status: "all",
limit: 1,
});
const sub = subs.data[0];
if (!sub) return;
await db.billingSubscriptions.upsert({
where: { stripe_customer_id: customerId },
create: {
id: sub.id,
user_id: sub.metadata.user_id,
stripe_customer_id: customerId,
status: sub.status,
price_id: sub.items.data[0].price.id,
current_period_end: new Date(sub.items.data[0].current_period_end * 1000),
cancel_at_period_end: sub.cancel_at_period_end,
},
update: {
status: sub.status,
price_id: sub.items.data[0].price.id,
current_period_end: new Date(sub.items.data[0].current_period_end * 1000),
cancel_at_period_end: sub.cancel_at_period_end,
},
});
}Idempotency on both sides
Idempotency is not one mechanism but two, and you need both.
- Outbound (your requests to Stripe): pass an
idempotencyKeyon any create or mutation call that a user or a retry could trigger twice — checkout sessions, subscription updates, refunds. Stripe deduplicates for 24 hours. - Inbound (Stripe's requests to you): Stripe retries webhook deliveries on any non-2xx response or timeout, and may deliver the same event more than once even on success. Record
event.idin aprocessed_stripe_eventstable and short-circuit replays, as shown above. Do the dedupe check and the work inside one database transaction so a crash between them cannot drop an event.
A subtle failure mode: if your handler does real work, then the database commit succeeds but the HTTP response is lost, Stripe retries and you must not re-run side effects like sending a "welcome" email. Gate those side effects on a state transition you can detect in the cache, not on "this event was received."
Reconciliation: the fallback that makes the system durable
Webhooks fail. Endpoints get redeployed mid-delivery, secrets rotate, a deploy returns 500 for ninety seconds, or the webhook gets disabled in the dashboard and nobody notices for a week. If webhooks are your only path to correctness, those gaps become silent revenue and access bugs. We have seen production incidents where a single dropped event left a paying customer locked out for days.
The fix is a reconciliation cron — a scheduled job that periodically re-reads subscriptions from Stripe and repairs the cache. Because your sync function is already idempotent and reads the source of truth, reconciliation is just calling it on a schedule.
// scripts/reconcile-subscriptions.ts — run every 15-30 minutes
export async function reconcile() {
for await (const sub of stripe.subscriptions
.list({ status: "all", limit: 100 })
.autoPagingEach.bind(null) ? [] : []) {
// see note below
}
// Practical form: iterate updated-recently or all customers
const subs = stripe.subscriptions.list({ status: "all", limit: 100 });
for await (const sub of subs.autoPagingEach ? subs as any : []) {
await syncSubscriptionFromStripe(sub.customer as string);
}
}In practice, use the SDK's auto-pagination (for await (const sub of stripe.subscriptions.list({ status: "all" }))) and call syncSubscriptionFromStripe for each customer. For large accounts, narrow the scan to subscriptions created or updated since the last run, or only those whose current_period_end is near. Reconciliation should be cheap, frequent, and boring.
This pattern is also how you survive Stripe's webhook endpoint limits and brief outages. On several of our products the reconciliation cron, not the webhook, is the primary fulfillment mechanism — webhooks make access feel instant, the cron guarantees it is correct.
Webhooks vs reconciliation: when each wins
- Webhooks give low latency. Use them to flip access on within seconds of payment.
- Reconciliation gives durability. Use it to guarantee eventual correctness regardless of delivery failures.
- Together they are belt and suspenders: fast and correct. Neither alone is sufficient for a billing system you can sleep through.
Customer portal and proration
Do not build your own UI for upgrades, cancellations, payment-method updates, and invoice history. Stripe's Billing Customer Portal does all of it and stays in sync automatically. Create a portal session server-side and redirect.
const portal = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.APP_URL}/billing`,
});
return NextResponse.json({ url: portal.url });Configure allowed actions (which plans users can switch to, whether cancellations are immediate or at period end) in the Stripe dashboard, not in code. When a user changes plans through the portal, Stripe emits customer.subscription.updated and your sync function updates the cache — no special handling required.
Proration is what happens when a plan changes mid-cycle. By default Stripe credits the unused portion of the old price and charges the prorated amount of the new one, applied as a line item on the next invoice. For programmatic upgrades you control it with proration_behavior:
create_prorations(default): credit unused time, charge the difference. Best for upgrades.always_invoice: prorate and invoice immediately rather than at next cycle. Use when you want money to move now.none: switch the plan with no proration. Use for downgrades you want to take effect cleanly at renewal.
Preview the exact amount before charging with the upcoming-invoice preview API so you can show the customer "$14.30 today" instead of a surprise. Surprise prorations generate support tickets and chargebacks.
Production checklist
- Reuse one Stripe customer per user; store the id and never recreate it.
- Verify webhook signatures against the raw body; reject anything that fails.
- Dedupe inbound events by
event.id; dedupe outbound calls withidempotencyKey. - Re-read the subscription from the API on every relevant event instead of trusting payload fields.
- Run a reconciliation cron and alert if it finds drift or if no webhook has arrived in N hours.
- Grant access from the cached row, never from the checkout redirect.
- Use the customer portal for self-service; configure proration behavior intentionally.
Frequently Asked Questions
Do I still need webhooks if I have a reconciliation cron?
Yes, for latency. Reconciliation guarantees your database eventually matches Stripe, but a cron running every fifteen minutes means a paying customer could wait that long for access. Webhooks flip access on within seconds. Run both: webhooks for speed, reconciliation for correctness when a webhook is lost or arrives out of order.
Why verify webhooks against the raw body in Next.js?
Stripe signs the exact bytes it sent. If the App Router parses the request as JSON and you reserialize it, key order and formatting change, and the signature no longer matches. Always read the body with await req.text() and pass that string to stripe.webhooks.constructEvent. Parse the JSON only after verification succeeds.
How do I prevent double charges from duplicate requests?
Pass an idempotencyKey on every create or mutation call to Stripe. A stable key derived from the user and intent means a retried or double-clicked request returns the original result instead of creating a second session or charge. Stripe honors idempotency keys for 24 hours, which covers virtually all retry scenarios.
Should I grant access on the Checkout success_url redirect?
No. The redirect is a UX convenience and can fire before payment settles, or never fire if the user closes the tab. Grant access based on the subscription status in your cached database row, which is updated by webhooks and repaired by reconciliation. Treat the success page as "thanks, we're activating your account," not as proof of payment.
How does proration work when a customer upgrades mid-cycle?
Stripe credits the unused portion of the current plan and charges the prorated cost of the new one. With the default create_prorations the adjustment lands on the next invoice; with always_invoice it bills immediately. Preview the amount with the upcoming-invoice API and show it to the customer before confirming to avoid surprise charges.
Working with CodeAustral
We build and operate billing systems like this across our own products and for clients, where a dropped webhook is a real support ticket and a real refund. If you are adding subscriptions to a Next.js app, migrating off a fragile webhook-only setup, or just want a second pair of eyes on your reconciliation strategy, send us a short brief at https://codeaustral.com/contact and we will tell you honestly what we would change.

