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
- Fulfill from
checkout.session.completedand check the session'spayment_status. With delayed payment methods such as bank debits the session can complete before the money arrives; Stripe sendscheckout.session.async_payment_succeededwhen it does (Fulfill orders). - Make fulfillment idempotent. Stripe warns that your fulfillment function may run more than once for the same session, possibly concurrently.
- Don't rely only on the success-page redirect. Users close tabs. The webhook is the dependable path; the redirect is a fast path.
Know who bought it
- Guest checkouts. In payment mode, Checkout only creates a Customer object when needed unless you set
customer_creation=alwaysor pass an existingcustomer(Create a Checkout Session). Without a Customer, the buyer's email is only on the session'scustomer_details, and a Customers export won't list them. - Link the purchase to your user. Pass your user ID as
client_reference_idso the webhook handler doesn't have to guess from an email. - No invoice by default. One-time Checkout payments only generate an invoice if you enable
invoice_creation, so code that grants access oninvoice.paidnever fires for them.
Common bugs
| Bug | Symptom | Fix |
|---|---|---|
| Any payment counts as access | Buying credits or an add-on unlocks the paid plan | Grant access only for specific price or product IDs in the session's line items |
| Refunds ignored | Refunded lifetime buyers keep access | Handle charge.refunded and decide what a full or partial refund does (Refunds) |
| Disputes ignored | Charged-back buyers keep access | Watch for disputes and decide your policy (Disputes) |
| Lookup by email only | The buyer used a different email and was never upgraded | client_reference_id or an existing customer on the session |
| Subscription-only access check | Lifetime users get locked out by code that checks for an active subscription | Store 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
- Decide what a seat is. Active members only? Does the owner count? Do pending invites? Write it down and count the same way everywhere.
- Enforce on invite. When a team at its limit invites someone, either block it or increase the item quantity. Quantity changes create prorations according to your proration settings (Prorations).
- Sync changes made outside your app. The customer portal can let customers change quantities if you enable it (Configure the customer portal). Handle
customer.subscription.updatedso your app learns the new seat count (Subscription webhooks). - Check team status, not just the payer's. When the team's subscription is canceled or unpaid, every member loses paid access, not only the person whose card was on file.
Quick audit without code
- 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.
- Export a count of active members per team from your database (team ID, billing customer ID, member count).
- 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:
- One-time purchases count as paid access when they come from a paid Checkout Session in
mode=paymentor from a charge with no invoice. Spotting Checkout purchases needs the optional Read permission on Checkout Sessions, and spotting charges with no invoice needs the optional Read on Invoices. It looks back 12 months. Map your price or product IDs to plans so add-ons and credits don't count; without a mapping, any paid one-time purchase counts and findings are marked medium severity. Refunded purchases whose user still has access are flagged separately. - Seats: if your access feed or CSV includes
team_id(orseats_used), it compares seats in use with the subscription item quantity. It flags teams using more seats than they pay for, with the estimated monthly amount, and lists unused paid seats as informational. - Metered items count as access like any active subscription, but usage amounts aren't audited.
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.
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 →