Stripe Charged the Card but the Subscription Never Activated
The card was charged, Stripe shows a paid invoice, but the user is still on the free plan. The charge and the upgrade are two separate steps: Stripe takes the money, then a webhook is supposed to flip the account to paid. If that webhook is unverified, never configured, or not idempotent, the money lands and the activation never runs. Here is the exact handler that fixes it.
By Founder Name · Last verified: 2026-06-25
Why did Stripe charge the card but the subscription never activate?
Because payment and provisioning are decoupled. Stripe Checkout collects the money and redirects the user back to your success URL, but it does not tell your database anything. The only signal that the purchase completed is the checkout.session.completed webhook. If your app never receives that event, never verifies it, or fails while handling it, Stripe records a successful payment and your app records nothing.
In a Lovable-generated app this almost always traces to one of three gaps. First, the success page upgrades the account in the browser instead of on the server, so anyone who closes the tab before redirect never gets activated. Second, the webhook handler exists but STRIPE_WEBHOOK_SECRET was never set in production, so every event fails signature verification and returns a 400. Third, the handler runs but is not idempotent, so a retried event throws, the activation half-completes, and the user is left in limbo.
This is a textbook case of The Vanishing Env-Var — the secret exists in the Lovable editor sandbox but was never added to the production host, so the deployed handler silently rejects every real event. The preview looks wired up; production is deaf.
| Symptom | Root Cause | Self-fixable? |
|---|---|---|
| Card charged, account stays free, nothing in webhook log | Activation runs in the browser on the success page, not via webhook | Yes — move activation to a server webhook |
| Stripe webhook log shows 400 on every delivery | STRIPE_WEBHOOK_SECRET missing or wrong in production | Yes — set the live signing secret and redeploy |
| Webhook returns 200 but plan never changes | Handler reads the wrong field or writes to the wrong user row | Yes — log the session, map by customer/email |
| First payment activates, a retry double-charges the upgrade | Handler is not idempotent — no event-ID guard | Yes — store and check the Stripe event ID |
| Webhook returns 500 intermittently | Handler awaits a DB call that times out before the upgrade | Partially — add error handling and a retry-safe write |
| Test payments activate, live payments do not | Live-mode key/secret mismatch in production env | Yes — verify sk_live_ and the live whsec_ |
| SubtleCryptoProvider cannot be used in a synchronous context | Synchronous constructEvent used inside an async Edge Function | Yes — switch to constructEventAsync |
Related: if the webhook is not even firing, start here · the SubtleCryptoProvider sync-context fix
How do I confirm whether the webhook ever reached my server?
Open the Stripe dashboard and read the delivery log before touching any code. Go to Developers, then Webhooks, then your endpoint. The Events tab shows every attempt, the HTTP status your server returned, and the full request and response body. This single log tells you whether the problem is delivery (no events, or 4xx) or logic (200 returned but no upgrade) — and that split decides everything you do next.
- In Stripe, open Developers, then Webhooks, and click your endpoint.
- Look at the most recent checkout.session.completed delivery.
- If there are no events at all, the endpoint URL is wrong or no webhook exists — fix the endpoint first.
- If deliveries show 400, signature verification is failing — your STRIPE_WEBHOOK_SECRET is wrong or missing in production.
- If deliveries show 200 but the account never upgraded, the bug is in your handler logic after verification.
How do I verify the Stripe signature so the event is trusted?
You must reconstruct the event from the raw request body and the Stripe-Signature header using your webhook signing secret. Never parse the JSON yourself and trust it — without verification, anyone can POST a fake checkout.session.completed and upgrade themselves for free. The verification step is also what fails silently when STRIPE_WEBHOOK_SECRET is absent, so getting it right closes both a security hole and the activation gap.
Two details break Lovable-generated handlers here. First, signature verification needs the raw, unparsed request body — if a middleware already parsed it to JSON, the signature will never match. Second, in Supabase Edge Functions and other Deno or Workers runtimes, the synchronous constructEvent throws SubtleCryptoProvider cannot be used in a synchronous context. Use the async variant instead.
A correct Supabase Edge Function verification block looks like this:
import Stripe from "https://esm.sh/stripe@14?target=deno"; const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, { apiVersion: "2024-06-20" }); const secret = Deno.env.get("STRIPE_WEBHOOK_SECRET")!; Deno.serve(async (req) => { const signature = req.headers.get("stripe-signature")!; const body = await req.text(); // RAW body — do not JSON.parse first let event; try { event = await stripe.webhooks.constructEventAsync(body, signature, secret); } catch (err) { console.error("Signature verification failed:", err.message); return new Response("Invalid signature", { status: 400 }); } // event is now trusted — continue to activation });
What is the correct handler that actually activates the subscription?
After verification, handle checkout.session.completed: read the customer and subscription IDs off the session, find the matching user, and flip their plan to paid. Wrap it in an idempotency guard so a replayed event cannot double-activate. The activation must happen here on the server — never on the browser success page — because the webhook is the only signal Stripe guarantees will arrive even if the user closes the tab.
The session object carries everything you need: client_reference_id (the user ID you passed when creating the Checkout Session), customer, subscription, and customer_email. Map by client_reference_id if you set it; fall back to customer_email only as a last resort. Here is the activation block that runs after the verification block above:
if (event.type === "checkout.session.completed") { const session = event.data.object; const userId = session.client_reference_id; // set this when you create the session const eventId = event.id; // Idempotency guard: skip if we already processed this event const { data: seen } = await supabase .from("processed_stripe_events").select("id").eq("id", eventId).maybeSingle(); if (seen) return new Response("Already processed", { status: 200 }); const { error } = await supabase.from("profiles").update({ plan: "pro", stripe_customer_id: session.customer, stripe_subscription_id: session.subscription, subscribed_at: new Date().toISOString(), }).eq("id", userId); if (error) { console.error("Activation write failed:", error.message); return new Response("DB error", { status: 500 }); // let Stripe retry } await supabase.from("processed_stripe_events").insert({ id: eventId }); } return new Response("ok", { status: 200 });
Note the deliberate use of status codes: return 500 on a database failure so Stripe retries the event later, and return 200 only after the upgrade is committed. Returning 200 too early tells Stripe to stop retrying a payment it already collected.
- When you create the Checkout Session, pass client_reference_id with the logged-in user's ID so the webhook can map the payment to a user.
- Create a processed_stripe_events table keyed on the Stripe event ID for idempotency.
- In the handler, verify the signature, then handle checkout.session.completed only.
- Update the user's plan row, then insert the event ID to mark it processed.
- Return 200 on success and 500 on a write failure so Stripe retries instead of dropping the event.
Why does activating on the success page instead of the webhook lose paid users?
The success_url is a redirect Stripe shows after payment, not a guarantee. If the user closes the tab, loses connection, or the redirect is blocked, the success page never loads and any upgrade logic there never runs — yet the charge already cleared. Webhooks are the only mechanism Stripe retries until your server confirms receipt, which is why activation belongs there and nowhere else.
Lovable frequently generates the convenient-but-wrong version: a success page that calls an update on mount to set the plan to paid. It demos perfectly because in a demo nobody closes the tab. In production it leaks revenue silently — every dropped redirect is a customer who paid and stayed free, and you only find out when they email asking why they still cannot access the product.
Keep the success page for user experience — a thank-you message, a link into the app — but treat the webhook as the source of truth. If you want the success page to feel instant, have it poll your own backend for the plan status that the webhook sets, rather than performing the upgrade itself.
How do I test the full charge-to-activation flow end to end?
A 200 in the Stripe log only proves the signature verified — it does not prove the user was upgraded. Run a complete test-mode payment with Stripe's test card, then confirm the plan actually changed in your database and persists across a fresh login. Always test with sk_test_ keys; never run live charges to validate configuration. Switch to live keys only after the test-mode round-trip is clean.
- Set test-mode keys (sk_test_ and the test endpoint's whsec_) in your environment.
- Run a real checkout in your app using Stripe test card 4242 4242 4242 4242, any future expiry and any CVC.
- In Stripe, open Developers, then Webhooks, and confirm the checkout.session.completed event returned 200.
- Open your database and confirm the user's plan row flipped to paid with the customer and subscription IDs stored.
- Resend the same event from the Stripe dashboard and confirm the plan does not change again — that proves your idempotency guard works.
- Sign out and back in as the test user to confirm the paid status persists across sessions.
Could a missing webhook be exposing my activation to abuse?
Yes — the same misconfiguration that loses paying users can let non-payers in. If activation runs client-side on the success page, a user can navigate to that URL directly and upgrade themselves without paying. And if your webhook does not verify the Stripe signature, anyone can POST a forged event to grant themselves a paid plan. Both are common misconfiguration risks in vibe-coded payment flows.
The fix for both is the same architecture: activation happens only inside a signature-verified webhook, and the success page is display-only. Verification proves the event came from Stripe; server-side activation proves the user cannot self-promote. If your Supabase tables are also exposed to the client, pair this with row-level security so a user cannot simply update their own plan column directly.
Related: lock down who can write the plan column with RLS · keep Stripe secrets out of the browser
When should I hand this to a senior engineer?
Hand it over the moment money is involved and the fix is not obvious from the webhook log. If Stripe shows successful 200 deliveries but accounts still are not provisioning, the bug is in your handler logic — the hardest code for the AI to generate correctly and the most expensive to get wrong. Every failed activation is a customer who paid and got nothing, so speed and correctness both matter here.
A specialist traces the event end to end: from Stripe delivery, through signature verification, to the exact database write, and finds whether the failure is a wrong field mapping, a missing idempotency check, a race condition in the upgrade, or an async crypto bug in the Edge Function. We also resend the backlog of failed events so customers who paid during the broken window get provisioned without manual database edits.
Do not iterate on payment code with more Lovable prompts. Re-prompting a charge handler risks introducing a second silent failure on top of the first — the Bug Doom Loop, applied to the one part of your app where a mistake costs real money.
Frequently asked questions
Stripe shows the payment as paid but my user is still on the free plan — what is actually wrong?
Why does the webhook activation work in Lovable preview but not on my live site?
Should the subscription activate on the success page or in the webhook?
What does it mean to make a Stripe webhook idempotent and why do I need it?
How do I map a Stripe webhook event back to the right user?
I get 'SubtleCryptoProvider cannot be used in a synchronous context' — how do I fix it?
My test payments activate the plan but live payments do not — why?
Can I recover customers who paid while the webhook was broken?
Could a misconfigured webhook let people get a paid plan without paying?
How fast can you fix a Stripe charge-but-no-activation bug?
App down or leaking data? Get an expert on it within 24–48h.
Book a free 30-minute audit call. We'll diagnose what's wrong and tell you exactly what it costs to fix.