Guides · Updated October 17, 2026
How to find Stripe customers who paid but don't have access in your app
"I paid but I'm still on the free plan" is one of the most common support emails for SaaS apps on Stripe. The payment went through, but your app never recorded it. This guide shows how to find every customer in that state: first with no code at all, then with a short script, and then how to stop it happening.
Why it happens
- The webhook never updated your database. The endpoint returned an error, timed out, was pointed at an old URL, or Stripe disabled it after repeated failures. Your app keeps the old plan while Stripe shows the customer as active.
- The handler ran but couldn't find the user. It looked the user up by email, and the customer paid with a different email than the one they signed up with.
- The handler only listens for one event. Example: it handles
checkout.session.completedbut notcustomer.subscription.updated, so renewals, plan changes and reactivations never reach your database. Stripe's guide lists the events a subscription integration should handle (Subscription webhooks). - Delayed payment methods. With bank debits, the Checkout Session can complete before the money arrives. Stripe sends
checkout.session.async_payment_succeededlater, and a handler that ignores it never grants access (Fulfill orders). - A second Stripe customer for the same person. Without passing an existing customer to Checkout, one person can end up with several Stripe customers, and the paying one isn't linked to their account.
What counts as "paid" in Stripe
Before comparing anything, decide which Stripe states should mean "has access". Stripe documents the subscription statuses (How subscriptions work):
| Status | Usually means |
|---|---|
trialing, active | Should have access. |
past_due | A payment failed. Grant or keep access according to your grace-period policy. |
unpaid, canceled, incomplete_expired | Shouldn't have paid access. Stripe recommends revoking access when a subscription is unpaid. |
incomplete, paused | No successful payment yet, or invoicing is paused. Usually no access. |
If you also sell one-time purchases (lifetime deals, credits), those don't have a subscription status. A successful, unrefunded payment for an access-granting product is what counts. See the guide to one-time purchases and per-seat plans.
Method 1: no code (Stripe exports + a spreadsheet)
- Export subscriptions. In the Stripe Dashboard, go to Billing → Subscriptions, filter by status (Active and Trialing, plus Past due if you give a grace period), and click Export. Include the customer ID, customer email, status and plan/price columns.
- Export customers if you need more emails. Customers → Filter → Export, and include the ID and Email columns (Stripe: export customer data).
- Export one-time payments if you sell them. Transactions/Payments can be filtered and exported too (Stripe Dashboard basics). Keep only succeeded, unrefunded payments for products that unlock access.
- Export your users. From your database or admin panel (in Supabase: Table Editor → Export to CSV), with at least
emailand whatever marks paid access (plan,is_pro,active). Addstripe_customer_idif you store it. - Normalize emails in both sheets with a helper column such as
=LOWER(TRIM(B2)). Mixed case and stray spaces cause a surprising number of misses. - Look up each paying customer in your users sheet. Match on Stripe customer ID if both sides have it; otherwise on the normalized email:
=IFERROR(XLOOKUP(E2, Users!E:E, Users!C:C), "NOT FOUND")
Here column E holds the normalized email andUsers!Cholds your paid-access flag. Filter forNOT FOUND(paid, no account) and for rows where the flag says free or false (paid, but not upgraded). - Do the reverse check too. Look up every user your app marks as paid in the Stripe export. Users with no active Stripe subscription are getting paid access for free (cancelled or failed payments that your app never downgraded), unless they're comps or teammates.
- Check every miss by hand before acting. Open the customer in Stripe and search for other emails on their payments before you email anyone or change access.
Use live mode for this: test-mode exports contain only test data.
Method 2: a short script
If you'd rather automate it, list active subscriptions with the API (List subscriptions) using a read-only restricted key, then compare with your users table:
// Node, stripe-node. Restricted key with Read on Customers + Subscriptions.
const paying = new Map();
for await (const sub of stripe.subscriptions.list({ status: "all", expand: ["data.customer"] })) {
if (!["active", "trialing"].includes(sub.status)) continue;
const c = sub.customer;
paying.set(c.id, (c.email || "").trim().toLowerCase());
}
// users: rows from your DB with { email, stripe_customer_id, is_paid }
for (const [customerId, email] of paying) {
const u = users.find((x) => x.stripe_customer_id === customerId)
?? users.find((x) => x.email?.trim().toLowerCase() === email);
if (!u || !u.is_paid) console.log("paid, no access:", customerId, email);
}The list endpoint returns non-canceled subscriptions by default, which is why the example asks for status: "all" and filters itself.
Stop it happening again
- Link users to Stripe up front. Pass your user ID as
client_reference_idwhen creating the Checkout Session, and pass an existingcustomerwhen the user already has one (Create a Checkout Session). Store the Stripe customer ID on the user record. - Handle the full set of subscription events (
customer.subscription.created,.updated,.deleted, pluscheckout.session.completed) and set access from the subscription's status, not from the event type (Subscription webhooks). - Make fulfillment idempotent. Stripe notes that your fulfillment function may be called more than once for the same session, even concurrently (Fulfill orders).
- Watch your webhook endpoint. In the Dashboard's webhook settings you can see failed deliveries and resend them. A disabled endpoint means every new payment is affected.
- Consider Stripe Entitlements if you want Stripe to hold the product → feature mapping (Entitlements). Your app still has to check them.
Doing this with PaidCheck
The free leak report runs the comparison above in one step:
- CSV upload: export your users as
email,plan,active(optionalstripe_customer_id,user_id) and upload it. For the free report the CSV is processed in memory and never stored. - Matching beyond email: Stripe customer ID first, then customer metadata such as
user_idorsupabase_user_id, then email (trimmed, case-insensitive), then emails seen on the customer's charges and invoices. Each finding says how it matched. - Stripe-only checks: with no user list at all you still get failed webhook events (last 30 days), disabled or misconfigured endpoints, likely double charges and duplicate subscriptions. Finding paid-but-no-access users needs a user list or an access endpoint.
It uses a Stripe restricted key with read-only permissions and refuses full secret keys. The exact permissions are on the setup page.
FAQ
Doesn't Stripe already know who should have access?
Stripe knows who paid and the state of each subscription, but access lives in your own database (a plan or is_pro column, a role, a team record). The two only stay in sync if your webhook handler or checkout success code updates your database every time. Stripe Entitlements can model features per product, but your app still has to read them and act on them.
Should past_due customers keep access?
That's your policy. Stripe may retry the payment while a subscription is past_due, and your Dashboard settings decide whether it then becomes canceled, unpaid or stays past_due. Stripe's docs recommend revoking access when a subscription becomes unpaid. Many SaaS apps keep access during past_due as a grace period.
Why does matching by email give false positives?
People often pay with a different email than they signed up with: a work vs personal address, a +tag, or a typo in Checkout. The most reliable link is to store the Stripe customer ID on your user record (or put your user ID in Stripe customer metadata or the Checkout client_reference_id) and match on that first.
Can I do this without giving anyone my Stripe secret key?
Yes. The spreadsheet method below only uses Dashboard exports. If you use a tool or script, create a restricted key with read-only permissions on just the resources it needs instead of using your secret key.
Check your own account
PaidCheck's free leak report runs these comparisons with a read-only Stripe restricted key. Upload a CSV of your users or connect nothing for Stripe-only checks. Nothing is stored.
Run a free leak report →