Guides · Updated October 17, 2026

One-time purchases and per-seat subscriptions in Stripe: getting access control right

Most Stripe access-control code assumes one rule: active subscription means access. That rule breaks for two common setups: one-time purchases (lifetime deals, one-off plans) and per-seat subscriptions, where paying isn't yes-or-no but "paid for N people". This guide covers what to check for each, with the bugs that cause most of the problems.

Part 1: one-time purchases (Checkout mode=payment)

A lifetime deal sold through Stripe Checkout uses mode=payment instead of mode=subscription (Checkout Session object). There's no subscription status to read later, so the moment of fulfillment is everything.

Grant access on the right signal

Know who bought it

Common bugs

BugSymptomFix
Any payment counts as accessBuying credits or an add-on unlocks the paid planGrant access only for specific price or product IDs in the session's line items
Refunds ignoredRefunded lifetime buyers keep accessHandle charge.refunded and decide what a full or partial refund does (Refunds)
Disputes ignoredCharged-back buyers keep accessWatch for disputes and decide your policy (Disputes)
Lookup by email onlyThe buyer used a different email and was never upgradedclient_reference_id or an existing customer on the session
Subscription-only access checkLifetime users get locked out by code that checks for an active subscriptionStore the purchase on the user record (for example plan="lifetime") and have the access check accept either

Part 2: per-seat subscriptions (item quantity)

In Stripe, per-seat pricing is a subscription item whose quantity is the number of seats; the cost scales linearly with quantity (Quantities, Per-seat pricing). The seat count lives on the subscription item, so on subscriptions with several items, read the quantity of the per-seat price's item.

Keep the count honest

Quick audit without code

  1. Export active subscriptions from Billing → Subscriptions, including the customer and quantity if your export offers it. Otherwise open each team's subscription in the Dashboard.
  2. Export a count of active members per team from your database (team ID, billing customer ID, member count).
  3. Compare in a spreadsheet: members > quantity means unpaid seats; members < quantity means paid seats nobody uses. That's not a leak, but it's a churn risk worth an onboarding nudge.

Doing this with PaidCheck

PaidCheck's reconciliation covers both cases, as long as you give it the data:

Start with the free leak report (a CSV upload works), or read how to find customers who paid but don't have access first.

FAQ

How do I know whether a Stripe customer bought lifetime access?

There's no subscription to look at. Check for a completed Checkout Session in payment mode (or a succeeded payment) whose line items include your lifetime price or product, and that hasn't been fully refunded. Store the result on your user record when you fulfill the order, so you don't have to reconstruct it later.

Where is the seat count stored on a Stripe subscription?

On the subscription item, as its quantity. A subscription can have several items; the seat count is the quantity of the licensed (per-seat) price's item. Metered items don't use quantity in the same way.

Should I block invites when a team is out of seats?

Either block the invite or raise the subscription item quantity when the invite is accepted. What matters is picking one rule and enforcing it in your app. Otherwise teams quietly end up with more members than seats they pay for.

Do refunds automatically remove access?

No. Stripe records the refund and sends events such as charge.refunded, but your app decides what happens to access. Decide whether a full refund revokes access (usually yes) and whether a partial refund does (usually no), and handle the event.