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

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):

StatusUsually means
trialing, activeShould have access.
past_dueA payment failed. Grant or keep access according to your grace-period policy.
unpaid, canceled, incomplete_expiredShouldn't have paid access. Stripe recommends revoking access when a subscription is unpaid.
incomplete, pausedNo 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)

  1. 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.
  2. Export customers if you need more emails. Customers → Filter → Export, and include the ID and Email columns (Stripe: export customer data).
  3. 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.
  4. Export your users. From your database or admin panel (in Supabase: Table Editor → Export to CSV), with at least email and whatever marks paid access (plan, is_pro, active). Add stripe_customer_id if you store it.
  5. 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.
  6. 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 and Users!C holds your paid-access flag. Filter for NOT FOUND (paid, no account) and for rows where the flag says free or false (paid, but not upgraded).
  7. 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.
  8. 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

Doing this with PaidCheck

The free leak report runs the comparison above in one step:

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.