Hire Lovable Xperts
Integrations & APIs

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.

Stripe Activation Failure Taxonomy
SymptomRoot CauseSelf-fixable?
Card charged, account stays free, nothing in webhook logActivation runs in the browser on the success page, not via webhookYes — move activation to a server webhook
Stripe webhook log shows 400 on every deliverySTRIPE_WEBHOOK_SECRET missing or wrong in productionYes — set the live signing secret and redeploy
Webhook returns 200 but plan never changesHandler reads the wrong field or writes to the wrong user rowYes — log the session, map by customer/email
First payment activates, a retry double-charges the upgradeHandler is not idempotent — no event-ID guardYes — store and check the Stripe event ID
Webhook returns 500 intermittentlyHandler awaits a DB call that times out before the upgradePartially — add error handling and a retry-safe write
Test payments activate, live payments do notLive-mode key/secret mismatch in production envYes — verify sk_live_ and the live whsec_
SubtleCryptoProvider cannot be used in a synchronous contextSynchronous constructEvent used inside an async Edge FunctionYes — 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.

  1. In Stripe, open Developers, then Webhooks, and click your endpoint.
  2. Look at the most recent checkout.session.completed delivery.
  3. If there are no events at all, the endpoint URL is wrong or no webhook exists — fix the endpoint first.
  4. If deliveries show 400, signature verification is failing — your STRIPE_WEBHOOK_SECRET is wrong or missing in production.
  5. If deliveries show 200 but the account never upgraded, the bug is in your handler logic after verification.
A 400 means Stripe reached your server and your server rejected the event — almost always a signing-secret mismatch. A total absence of events means Stripe never reached you at all, usually because the endpoint points at the lovable.app preview URL instead of your production domain.

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 NOT to do: do not skip verification, do not hard-code the secret in source, and do not reuse a test endpoint secret on a live endpoint. Each Stripe endpoint has its own whsec_ value. Set STRIPE_WEBHOOK_SECRET in your production host's settings panel and redeploy — not just in the Lovable editor.

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.

  1. 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.
  2. Create a processed_stripe_events table keyed on the Stripe event ID for idempotency.
  3. In the handler, verify the signature, then handle checkout.session.completed only.
  4. Update the user's plan row, then insert the event ID to mark it processed.
  5. 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.

If your only activation code lives in a useEffect on the success page, assume some paying customers are stuck on free right now. Move the write to the webhook, then use the Stripe dashboard to resend past checkout.session.completed events to retroactively provision anyone who slipped through.

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.

  1. Set test-mode keys (sk_test_ and the test endpoint's whsec_) in your environment.
  2. Run a real checkout in your app using Stripe test card 4242 4242 4242 4242, any future expiry and any CVC.
  3. In Stripe, open Developers, then Webhooks, and confirm the checkout.session.completed event returned 200.
  4. Open your database and confirm the user's plan row flipped to paid with the customer and subscription IDs stored.
  5. Resend the same event from the Stripe dashboard and confirm the plan does not change again — that proves your idempotency guard works.
  6. Sign out and back in as the test user to confirm the paid status persists across sessions.

Related: stop clicking Fix and burning credits on this

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?
The charge and the upgrade are separate steps. Stripe collected the money, but your app only upgrades when it receives and processes the checkout.session.completed webhook. Open Developers, then Webhooks, in the Stripe dashboard and read the delivery log. No events means the endpoint is wrong; a 400 means signature verification failed; a 200 with no upgrade means the bug is in your handler logic.
Why does the webhook activation work in Lovable preview but not on my live site?
Almost always because STRIPE_WEBHOOK_SECRET exists in the Lovable editor but was never added to your production host. The deployed handler cannot verify the signature, so it returns 400 and the upgrade never runs. This is the Vanishing Env-Var pattern. Add the live endpoint's signing secret to your production environment variables and redeploy, then resend a past event to confirm.
Should the subscription activate on the success page or in the webhook?
In the webhook, always. The success_url redirect is not guaranteed — if the user closes the tab or loses connection, any activation code on that page never runs, even though the charge cleared. Stripe retries webhooks until your server confirms receipt, which makes the webhook the only reliable signal. Use the success page only to show a confirmation message, not to grant access.
What does it mean to make a Stripe webhook idempotent and why do I need it?
Stripe can deliver the same event more than once, especially if your server is slow or returns a non-200 response. Without a guard, processing it twice could double-upgrade an account or create duplicate records. Store each Stripe event ID in a table and check it before activating — if you have already seen that ID, return 200 immediately and do nothing. That makes replays safe.
How do I map a Stripe webhook event back to the right user?
Set client_reference_id to the logged-in user's ID when you create the Checkout Session. The webhook then reads session.client_reference_id and updates exactly that user. If you did not set it, you can fall back to session.customer_email, but that is fragile if emails differ between Stripe and your auth system. Setting client_reference_id at session creation is the reliable approach.
I get 'SubtleCryptoProvider cannot be used in a synchronous context' — how do I fix it?
This appears when a Stripe handler runs in an Edge Function runtime like Supabase Edge Functions or Cloudflare Workers. The synchronous stripe.webhooks.constructEvent() cannot run there because crypto must be async. Replace it with await stripe.webhooks.constructEventAsync(body, signature, secret). This is a common Lovable generation error in Stripe integrations and is a one-line fix once you know it.
My test payments activate the plan but live payments do not — why?
You are almost certainly running test-mode keys in production. Live-mode events are signed with live-mode secrets, so a server validating against sk_test_ and the test whsec_ rejects every real event with a 400. Confirm that both STRIPE_SECRET_KEY (sk_live_) and STRIPE_WEBHOOK_SECRET (the live endpoint's whsec_) in your production environment are the live-mode values.
Can I recover customers who paid while the webhook was broken?
Yes. Once the handler is fixed, open Developers, then Webhooks, in the Stripe dashboard, find each failed checkout.session.completed event, and click Resend. Your now-correct, idempotent handler will provision each user without double-charging or duplicating records. For a large backlog, Stripe also supports batch resend via the API. We routinely run this recovery pass as part of a fix.
Could a misconfigured webhook let people get a paid plan without paying?
Yes, in two ways. If activation runs on the client success page, a user can visit that URL directly and upgrade themselves. If the webhook does not verify the Stripe signature, anyone can POST a forged event. The fix for both is the same: activate only inside a signature-verified server webhook, keep the success page display-only, and protect the plan column with row-level security.
How fast can you fix a Stripe charge-but-no-activation bug?
If it is a missing STRIPE_WEBHOOK_SECRET or a success-page activation that needs moving server-side, we usually fix and verify it within the same day. If the bug is buried in handler logic — wrong field mapping, a race condition, a missing idempotency guard — it takes longer to trace and test, but a senior engineer can still diagnose the exact failure point fast. Book an urgent review and we triage immediately.

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.

Get emergency help