Metered billing for an AI SaaS: the 402 pattern and the Stripe gotcha that breaks renewals
How metered AI billing works end to end: return HTTP 402 to drive upgrades, sync subscriptions from Stripe webhooks, and the renewal-date gotcha.
Metering tells you what a user cost you. Billing is turning that number into "upgrade or stop" — and the mechanism for that is smaller than most Stripe tutorials suggest. It comes down to one HTTP status code, a table you keep in sync with webhooks, and one Stripe API detail that will silently break every renewal date if you get it wrong.
Enforce with 402, and put the upgrade in the body
When a user is over their monthly allowance, the route returns HTTP 402 — "Payment Required," the conventional signal for "you need to pay to continue." The status alone is not enough, though: the response body carries everything the client needs to render a real upgrade prompt instead of a dead error.
const quota = await checkQuota(userId);
if (!quota.ok) {
return Response.json(
{
error: `You've used all ${quota.limit} requests on the ${quota.plan.label} plan this month.`,
plan: quota.plan.id,
used: quota.used,
limit: quota.limit,
upgradeUrl: "/pricing",
},
{ status: 402 },
);
}
Order matters in the route: rate limit first (429), then validate the body (400), then the quota check (402), then model gating. Cheapest rejections first, so you never pay to parse a request you were going to block anyway. The quota internals — the monthly cap and model gating behind checkQuota — are covered in keeping a free tier from bankrupting you.
Mirror subscription state into your own table
Stripe is the source of truth for whether a card was charged, but you do not want a Stripe API call on the path of every chat request. So mirror the state you need into your own table and keep it fresh with webhooks. Every subscription lifecycle event upserts one row, keyed on the Stripe subscription id:
await db()
.insert(subscriptions)
.values({ userId, stripeCustomerId, stripeSubscriptionId, plan, status, currentPeriodEnd })
.onConflictDoUpdate({
target: subscriptions.stripeSubscriptionId,
set: { plan, status, currentPeriodEnd, updatedAt: new Date() },
});
Then the request path is a single indexed read: resolve the user's plan from that table, treating only "active" and "trialing" as paid — anything else (past_due, canceled) falls back to the free plan. No Stripe round-trip, no latency tax on every message.
The gotcha: current_period_end moved onto the item
Here is the one that cost me an afternoon. In current Stripe API versions, current_period_end is no longer on the subscription object — it lives on the subscription ITEM. Read it from the old location and it comes back undefined, so every renewal date you store is null, and any "your access resets on X" or grace-period logic silently does nothing. Nothing errors; the field is just quietly empty.
The fix is not clever, it is disciplined: read the period end off the item, and verify the shape against the Stripe SDK version actually in your node_modules, not against a tutorial (including this one — by the time you read it, the field may have moved again). API-shape assumptions from memory are how integrations rot.
Things that bite
- Verify the webhook signature. Construct the event with Stripe's signing-secret check. Skip it and anyone who finds your endpoint can POST a fake "upgraded to Scale" event and grant themselves the top plan for free.
- Decide fail-open vs fail-closed. Without a database the kit fails open so the demo runs; a billing system in production should fail closed when it cannot verify entitlement.
- A 402 with an empty body is a dead end. Return plan, used, limit, and an upgrade URL so the client renders a prompt, not a stack trace.
- Honor trialing, not just active. A trialing user is a paying-intent user; drop them to the free plan mid-trial and you have manufactured churn.
Shipwright — the Next.js and Claude starter kit this blog documents — ships this whole path: the 402-gated chat route, a signature-verified Stripe webhook, and the subscriptions table it keeps in sync, all reading the same per-request metering the quota depends on. Watch a request get priced in the live demo.