Subscriptions and trials

Plans people subscribe to, free trials, changing plan, and where checkout returns.

Subscriptions

With Stripe connected and your plans priced, your app has two jobs:

// 1. Send the person to checkout. One call; Gemmein does the rest
await g.subscriptions.checkout("pro")   // redirects to Stripe; omit the arg for the paid plan

// 2. Unlock paid features by reading the managed subscription:
const sub = await g.subscriptions.mine()   // { plan, status } or null
if (sub?.plan === "pro") { /* unlock */ }

Each person has one subscription (matched case-insensitively on email). The payment itself creates it, cancellation downgrades it to your default plan, and out-of-order Stripe events resolve to the newest.

On the Payments page, choose how each plan is sold:

  • Stripe. A price, with Stripe connected; Gemmein makes the Payment Link. checkout navigates to the Payment Link. Stripe's webhooks write the subscription, and its cancellations and lapses end it. A checkout paid by bank debit opens the plan once the payment clears.
  • Relay. Any provider whose webhook you map (GoCardless, Lemon Squeezy, Paddle, bank transfer) grants the plan with the grant_plan relay action and ends it with revoke_plan. A relay-sold plan is active until revoke_plan runs on the provider's end-of-access event.
  • Not yet.

Subscription state is written by Stripe's webhooks or by the relay you configured, never by your app. g.subscriptions.mine() answers the same { plan, status } whichever way the plan is sold.

Do not write a webhook handler. Do not poll Stripe. Do not store plan state in your own collections; g.subscriptions.mine() is the single source of truth, and there is no client write path to it. And never build checkout URLs yourself: Stripe's URL rules silently drop raw emails, and sessions need a secret key that must never be in client-side code.

Plan limits (note counts, seats, feature caps) are your app's logic. Gemmein tells you who is on which plan, and plan names carry no quotas. Errors worth handling on checkout: authentication_required (sign in first: a plan is tied to an account, so a plan is never bought signed out), plan_has_no_link (Stripe isn't connected here or the plan has no price yet), and 409 plan_not_sellable (the plan is sold via a relay, or not yet).

Free trials and changing plan

A paid plan can start with a free trial: set trial on the plan in gemmein/payments.json, or the Free trial row in the plan editor. days is 1 to 730. card is required (the default) or optional. credits is what the trial grants; without it, the plan's per-period credits are prorated to the trial's days. onPaymentFailure is keep (the default) or end. Gemmein makes a second Payment Link for the trial and sends each customer to it once. g.subscriptions.mine() answers status: "trialing" with trialEndsAt until the trial ends.

Subscribers change plan, cancel and update their card in the Stripe customer portal. Gemmein sets it up itself from your plans, so there is nothing to turn on or paste: members switch between your paid plans (charged or credited at once, and a switch during a free trial ends the trial), cancel at the end of the period, and update their card. Your app's Manage plan button calls await g.subscriptions.manage(), which opens a portal session for the signed-in member. Gemmein follows every change through Stripe's webhook. On npx gemmein dev, manage() opens a local page that says what Stripe's portal would offer, and nothing changes.

Checkout redirects

g.subscriptions.checkout() and g.payments.buy() navigate the browser to Stripe themselves and resolve with { url, ... }. await them on the click, and don't also redirect to the returned url, which navigates twice.

Was this page helpful?

Last updated