Why your app still says free after a successful Stripe payment
The charge went through and the account is still on the free plan. Here are the six places the grant goes missing between Stripe and your database, and how to find which one you have.
A customer pays. Stripe shows the charge, the receipt lands in their inbox, the money is real. They come back to your app and it still says Free. Nothing crashed, nothing was logged, and the only person who knows is the customer, who is now writing you an email they resent having to write.
Almost always the cause is the same: Stripe knows about the payment and your database does not. The charge and the grant are two separate events travelling by two different routes, and the grant is the one that can quietly fail. This post walks the six places it goes missing, in the order they are worth checking.
The payment and the grant take different routes
When Checkout completes, two things happen independently. The browser is redirected back to your success URL, which is fast and depends on the customer keeping the tab open. And Stripe sends a checkout.session.completed webhook to your server, which is reliable but arrives whenever it arrives.
Only the second one is trustworthy, and only the second one is authoritative about money. The redirect is a courtesy. If the customer closes the tab at the Stripe receipt screen, or their phone drops the connection, or they paid on a laptop and reopened the app on a phone, the redirect never runs. The webhook still does.
So the redirect usually wins the race, and the webhook usually lands a moment later. In that gap the customer is signed in, has paid, and is looking at a page rendered from a record that still says hasAccess: false. If they refresh once, it fixes itself. If it never fixes itself, one of the next five things is happening.
The webhook is not arriving at all
Check this first, because it is the most common and the easiest to confirm. Open the Stripe dashboard, go to the webhook endpoint, and look at recent deliveries. Stripe records every attempt and the response it got. If there are no attempts, the endpoint is not registered for that event or not registered in that mode.
Test mode and live mode have separate endpoints and separate signing secrets. Wiring the endpoint up while building, then going live without adding it in live mode, produces exactly this bug: every test payment worked, every real one does not.
If there are attempts and they show 4xx or 5xx, you have a handler problem rather than a routing problem, and the response body Stripe recorded will usually say which. A 401 or a redirect to a login page means your auth middleware is intercepting the webhook route. Stripe does not have a session; that route has to be public.
Signature verification fails on a parsed body
Stripe signs the exact bytes it sent. If anything parses the body before you verify it, the bytes you hand to constructEvent are a re-serialised object rather than the original, and verification fails on every single event. The symptom is a webhook log full of 400s and a database that never changes.
In the App Router the fix is to read the body as text and never touch it first:
const body = await req.text();
const signature = (await headers()).get("stripe-signature");
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}Two details matter here. Read req.text(), not req.json(). And keep the 400 hard: a handler that swallows a verification failure and returns 200 is an endpoint anyone on the internet can use to grant themselves a paid account.
The other half of this is the secret itself. The signing secret from stripe listen is not the one from the dashboard endpoint, and they are not interchangeable. If verification started failing the day you deployed, compare the deployed STRIPE_WEBHOOK_SECRET against the endpoint you are actually pointing at.
The event arrives and updates nobody
This is the quiet one. The webhook is delivered, verification passes, your handler returns 200, and no user record changes. Stripe is happy, your logs are clean, and the customer is still on Free.
It happens because the identity in the event does not match the identity in your database. Three ways, in rough order of frequency.
The customer field is an object, not a string. Stripe returns customer as a bare id or as an expanded object depending on the event and your API settings. Querying Mongo or Postgres with the raw value matches nothing and throws nothing. Normalise it before it reaches a query:
const toId = (
value: string | { id: string } | null | undefined
): string | undefined => (typeof value === "string" ? value : value?.id);The session has no reference back to your user. If you did not set client_reference_id when creating the Checkout session, the event carries a Stripe customer and no way to find the row it belongs to. Set it at creation time, and keep an email fallback for payments that start outside your app, such as a payment link.
The email does not match. The fallback path fails when someone signs up as dan@work.com and pays with the card that autofills dan@personal.com. Nothing is wrong with the payment. There is simply no row with that address, so a fallback that creates a user quietly makes a second account instead of upgrading the first.
The price id is not one you recognise
A sensible handler refuses to grant access for a price it does not know, because otherwise any price in your Stripe account, including an old test one or a one-off invoice, becomes a way into the paid area. The cost of that guard is that a price id typo turns every payment into a silent no-op.
Price ids are environment-specific. Test prices start the same way live prices do, so a NEXT_PUBLIC_STRIPE_PRICE_PRO left pointing at the test price in production is invisible until someone pays. Log the rejection rather than dropping it:
if (!priceId || !getPlanByPriceId(priceId)) {
console.warn(`Ignoring checkout for unknown price: ${priceId}`);
break;
}The same mismatch bites on renewals. If your invoice.paid handler only extends access when the invoice price still matches the stored one, a plan change made in the customer portal can leave a paying subscriber failing that check every month.
The same event arrives twice
Stripe retries until it sees a 2xx, and can deliver an event more than once even when nothing failed. For a boolean grant that is harmless. For anything that counts, credits, seats, or a welcome email, it is a double charge of your own making.
The fix is a unique index on the event id and an insert that fails loudly on the second attempt:
try {
await StripeEvent.create({ _id: event.id, type: event.type, at: new Date() });
} catch {
return NextResponse.json({ received: true, duplicate: true });
}Returning 200 on the duplicate is deliberate. The event was handled the first time, so telling Stripe it failed only buys you more copies of it.
Why granting on the success page is not the fix
The obvious workaround for all of this is to set the flag when the browser lands on /success. It removes the gap, it is three lines, and it is a security hole. That URL is guessable, and anyone who visits it hands themselves a paid account.
You can do a safe version of the same idea. On the success page, read the session_id from the query string, retrieve that session from Stripe server-side, and confirm it belongs to the signed-in user and its payment_status is paid. That is a second confirmation from Stripe rather than a claim from the browser.
Even then, keep the webhook as the source of truth. The redirect path exists to make the first thirty seconds feel right; the webhook is what makes the account correct for a customer who closed the tab. Design around the webhook and treat the redirect as an optimisation, never the other way round.
How to reproduce it before a customer does
Every fault above is invisible from the outside. Stripe reports a successful payment in all six cases. Your error tracker sees nothing in five of them. So the only way to know your paywall works is to walk the path with an account that has never been touched by hand.
Locally that is:
stripe listen --forward-to localhost:3000/api/webhook/stripe
stripe trigger checkout.session.completedThat catches the parsing and signature faults. It will not catch the identity faults, because a triggered event carries a fixture customer that was never linked to a user of yours. For those you need a real session created by your own checkout code, for a real account, which means signing up and pressing the upgrade button.
Do that on production, not only on staging. Four of the six causes are environment differences: an endpoint registered in the wrong mode, a secret that did not get deployed, a test price id in a live variable, auth middleware that behaves differently behind the real domain. A staging run that passes tells you nothing about any of them.
Checking it on every deploy
Doing that by hand after every deploy is the kind of chore that survives two weeks. What you want is for it to happen on its own, on production, with a fresh account each time and a screenshot when it fails.
That is the run Mystra performs. After a deploy it signs up to your app in a real browser, confirms the email from its own inbox, presses the upgrade button, reaches Stripe Checkout and reads back the plan name and the amount, then checks the paid area is still gated for that fresh account. It does not pay: it reaches the checkout page and stops, so it is safe against live keys. The step-by-step is in How a run works.
That catches the price mismatch, the broken upgrade button, the checkout that 500s, and the paywall that stopped gating. The webhook faults still need the checks above and Stripe’s own delivery log. Between the two you find out from your own tooling rather than from the customer who paid you and got nothing.
Checklist
- Stripe dashboard, webhook endpoint, recent deliveries. No attempts means the wrong mode or the wrong events.
- Verify over
req.text(), return a hard 400, and confirm the deployed secret matches that endpoint. - Normalise
customerbefore querying, setclient_reference_id, and log the email fallback when it creates rather than finds. - Log every price id you refuse, and check your live price variables point at live prices.
- Deduplicate on
event.idwith a unique index, and answer duplicates with 200. - Grant on the webhook. If the success page grants too, make it retrieve the session server-side first.
- Run a real signup and a real press of the upgrade button against production after every deploy.
See this run on your own app
Mystra walks signup, welcome email, checkout and paid access after every deploy, with proof. One app and 25 runs a month are free, no card.
Keep reading
Guide9 min read
How to test a magic-link signup flow end to end
A magic-link signup has five hops and fails silently at each one. Here is where it breaks, how to test it by hand, in CI, and on production after every deploy.
Read the post →Explainer8 min read
Synthetic monitoring vs uptime checks: what each one actually proves
An uptime check proves your homepage answers. Synthetic monitoring proves a browser could finish a task. Neither proves a customer could sign up and pay, unless you point it at that path.
Read the post →