DEV Community

Israr Kundi
Israr Kundi

Posted on Originally published at israr-kundi.web.app

Stripe Payment Successful but App Still Shows Unpaid

Stripe payment successful but app still shows unpaid — your customer paid, and Stripe shows the money. The message that was meant to tell your app about it never arrived, or arrived and got turned away.

Stripe payment successful but app still shows unpaid: the problem

A customer writes in: "I paid, but I still can't use the app." You open Stripe and the payment is right there, marked succeeded. You open your app and the same customer is still on the free plan — no error, nothing in the logs. The payment is not the problem — the gap between Stripe and your app is.

The cause: your app only learns about payments from a webhook

With hosted Checkout, the payment page is Stripe's, not your app's. When a payment succeeds, Stripe sends your server a webhook — a message posted to a web address you registered — to say "this customer paid." Your app switches the customer to the paid plan only when that message arrives and your code acts on it.

So there are only two ways this breaks: the message never reaches your code, or it reaches your code and gets rejected. Your logs may show the sign of it: "No signatures found matching the expected signature for payload."

The minimum fix

1. Read the delivery log first

In the Stripe Dashboard, open Workbench, then Webhooks, pick your endpoint, and open Event deliveries. It shows every event Stripe tried to send and the status your server answered with.

  • No event listed at all: start with step 2 — check the endpoint address and which events it is subscribed to.
  • Event listed with an error status: the break is your endpoint. A 400 usually means a signature problem, 401 or 403 means something is blocking Stripe, 404 a wrong address, 500 an error in your code.
  • Event delivered with a 2xx status but the plan never changed: your code answered OK but the plan still did not save. Go to step 6.

2. Check the endpoint address is your live one

Use your real production address, not a preview deployment or old domain. Stripe does not follow redirects — if your address redirects, Stripe counts that as a failed delivery. Put the final address in the endpoint settings.

Also check the endpoint is switched on and subscribed to the events your code waits for. An endpoint only gets the event types you pick — if your code waits for checkout.session.completed but the endpoint only gets payment_intent.succeeded, your code never runs.

3. Use the right signing secret

Every webhook endpoint has its own signing secret, starting with whsec_. Test and live secrets differ, and the Stripe CLI's secret differs again from the Dashboard's. Use the wrong one in live mode and every event fails its signature check.

Copy the live endpoint's secret from the Dashboard into your production settings, and redeploy.

4. Check the signature against the raw body

Stripe signs the exact bytes it sends. If your code reads the body as JSON first and then checks the signature, the check fails every time. Verify the signature against the raw body, before your code parses it, with Stripe's own library.

5. Grant access only from the webhook, never from the success page

Do not switch on the paid plan from the page the customer lands on after paying. If they close the tab before that page loads, nothing is recorded — and if your code switches on the plan when that page loads, anyone who types that address into a browser gets the paid plan for free.

Do the real work in the webhook handler, when checkout.session.completed arrives and payment_status is paid. The success page should only read the status from your database.

6. Make sure the handler knows who paid — and can save it

This step has two separate problems. First, your handler cannot tell which user paid, because the checkout session was created without a user ID. Save the user ID in client_reference_id or the session metadata when you create the checkout.

Second, the handler runs but cannot write to your database. It runs on your server with no logged-in user, so it needs a server-only key. With Supabase, a write with the anon key gets blocked by row-level security — use the service role key, kept out of your frontend code.

If the handler is a Supabase Edge Function, turn JWT verification off: Stripe sends no Supabase token, so every call would come back 401.

For subscriptions, one more event matters: checkout.session.completed fires once, at signup. Handle customer.subscription.updated and invoice.paid too, or the plan never updates again.

7. Replay what was missed

Once the endpoint is fixed, resend the failed events from the Dashboard. In live mode, Stripe retries a failed delivery for up to three days, and you can resend one by hand for up to 15 days. Fix the cause first.

When to get help instead of fixing it yourself

If the delivery log shows your own code answering with a 500, or the app takes subscriptions, get a developer to look at the handler. A bug that hands out paid access for free, or leaves paying customers locked out, costs more than the fix.

If this is your app, contact me through https://israr-kundi.web.app

Top comments (1)

Collapse
 
fintechrailsdigest profile image
Fintech Rails Digest •

the success-page-grants-access trap is the one that catches everyone. the customer closes the redirect, the webhook never fired, and you end up with a paid invoice in stripe and a free plan in the database with no error anywhere. reading the event deliveries log before touching any code is the right first step too: most of these turn out to be a test-mode signing secret in prod or an endpoint subscribed to the wrong events. the supabase edge function jwt detail is a genuinely niche catch.