Prevent Repeat Free Trials in Stripe Checkout

A canceled or expired subscription should not reset trial eligibility. Use an organization-level entitlement, server-side Checkout guard, and explicit recovery states.

A SaaS trial expires, but the app sends the user back to the pricing page instead of charging the saved payment method or showing a billing-recovery screen. They click Subscribe, Stripe Checkout creates a new subscription, and the same organization receives another free period. Checkout did what the application requested: it created a second trial.

To prevent repeat free trials, a SaaS product needs separate answers to three questions: Does this organization have product access? Does it have a current subscription? Has it already consumed its trial entitlement? The model below keeps those answers separate and uses them to gate every Checkout Session on the server.

The second trial starts in your application

Stripe Checkout supports subscription trials by accepting trial configuration when the Checkout Session is created. That is useful, but it also means the route that creates the Session is making a consequential decision. If your backend sends trial parameters every time the pricing button is clicked, Stripe has no application-level reason to know that the organization already used its offer.

We recently reviewed a test flow with exactly this shape. The first trial ended, the frontend interpreted the organization as unsubscribed, and it displayed the original signup path again. The user followed it and created a second trial. The app had no durable trial-consumption rule between the UI and the Checkout creation endpoint, so the resubscribe path behaved like a first signup.

Stripe can redirect customers who already have a subscription to a management destination instead of creating another one. Use that protection where it fits. It still should not be your only trial control. Current subscription state and historical trial eligibility answer different questions.

Separate access, subscription, and trial eligibility

A single field such as plan_status cannot safely drive this workflow. Stripe documents subscription states including trialing, active, incomplete, past_due, canceled, unpaid, and paused. Those states describe the billing object. Your one-trial-per-organization rule is a separate commercial entitlement.

Store a durable entitlement alongside the Stripe identifiers. For a B2B SaaS product, the entitlement usually belongs to the organization, not the email address of the person who clicked Checkout. A practical model is:

Application state

What it answers

Example value

stripe_customer_id

Which Stripe customer belongs to this organization?

cus_...

subscription_status

What is the latest known billing state?

trialing, active, past_due

access_until

Should the product grant access right now?

Timezone-aware timestamp

trial_consumed_at

Has this organization already received its trial?

Immutable first-trial timestamp

subscription_id

Which subscription should be managed or recovered?

sub_...

trial_consumed_at should not be cleared when a subscription is canceled, deleted, or becomes unpaid. A returning customer may be eligible to subscribe again, but that is a paid resubscription path. If the business wants exceptions, create an explicit administrative grant with an actor, reason, and expiration instead of silently resetting history.

Gate Checkout on the server

The frontend can hide or label buttons, but it cannot enforce eligibility. Treat POST /billing/checkout as the policy boundary. Before creating a Session, the server should lock the organization record and make one decision from current evidence.

  1. Resolve the authenticated user to an organization. Never accept an arbitrary organization ID without an authorization check.

  2. Load the stored Stripe customer, current subscription reference, and trial_consumed_at.

  3. If local subscription state is missing or stale, retrieve the relevant Stripe customer or subscription before deciding.

  4. If a manageable subscription exists, return a customer-portal or recovery action instead of a new trial Checkout Session.

  5. If no current subscription exists and the trial was consumed, create paid Checkout without trial parameters.

  6. If the organization is truly eligible, create trial Checkout and atomically reserve or record the entitlement.

Trial reservation needs concurrency protection. Two quick clicks, two browser tabs, or two workers can both read "eligible" before either creates a Session. Use a database transaction or organization-scoped lock so only one request can reserve the trial. Also send an idempotency key when creating the Checkout Session. Stripe documents idempotency as protection against performing the same API operation twice during retries. The idempotency key protects the Stripe request; the lock protects your entitlement decision.

Let webhooks update state, not policy

Subscription activity is asynchronous, so the application needs webhooks. Stripe's subscription webhook guide covers events for creation, updates, trial endings, payment failures, and cancellation. These events should update your local subscription snapshot and access decision.

They should not decide whether the organization ever had a trial. If customer.subscription.deleted clears trial_consumed_at, deletion turns into a coupon for another free period. If a delayed webhook briefly makes the local state look empty, the Checkout guard should recheck Stripe before creating a new subscription.

The timing matters too. Marking the trial as consumed only after checkout.session.completed avoids consuming an offer when the user abandons Checkout, but it leaves a window for multiple Sessions. Reserving it before redirect closes that window and requires an expiration for abandoned Sessions. A balanced first version uses a short-lived trial_reserved_until, then writes immutable trial_consumed_at when Checkout completes. Both transitions belong in the same organization-level state machine.

Test the paths that create a second trial

Do not stop at a successful first signup. The acceptance test is whether every later state produces the correct billing action:

Scenario

Expected action

New organization with no trial history

Create one trial Checkout Session

Trialing or active subscription

Open management flow; do not create another subscription

Trial ended and first payment failed

Show payment recovery; do not offer another trial

Canceled subscription with consumed trial

Create paid resubscription without trial parameters

Two concurrent Checkout requests

Return one usable Session and consume or reserve one entitlement

Webhook delayed after Checkout completion

Reconcile with Stripe; do not infer eligibility from an empty local status

Admin-approved exception

Apply a recorded, scoped grant rather than resetting trial history

Run these cases with Stripe test data and inspect both the Stripe objects and your application database. UI screenshots are not proof that the state machine is correct.

A repeat trial usually points to a missing ownership rule between application state and billing state. PASMO helps SaaS teams map that boundary, repair the Checkout guard, and test subscription recovery against the states that ordinary signup testing misses.