guide

Payments

Sell subscriptions and one-off products. Stripe is built in: connect it with one restricted key and Gemmein makes your Payment Links, hosts the webhook, manages subscriptions and writes receipts, so you build none of it.

Payments are processed by Stripe on your own account; Gemmein receives only the webhook events. Any provider that signs its webhooks, GoCardless, Paddle or Lemon Squeezy among them, drives access the same way through a relay.

Connect Stripe

Connect Stripe once per environment with a restricted key. Gemmein then makes your products, prices and Payment Links in your Stripe account and registers the webhook that tells it who paid.

  1. Open Stripe's API keys page: Developers (bottom left) → API keys, or directly: test mode for Development, live mode for Live.
  2. Choose Create restricted key. When Stripe asks how you will use the key, choose Powering an integration you built, then Choose your own below the templates. Don't pick a template: each one turns on 15 to 42 permissions, and Gemmein refuses the key. The third-party option does the same, ticking many permissions for you.
  3. Turn on only these five. The filter box at the top of the key editor finds a row by name.
    SectionRowSet to
    CoreProductsWrite
    BillingPricesWrite
    Payment LinksPayment LinksWrite
    Webhook EndpointsWebhook EndpointsWrite
    ConnectAccountsRead
    Accounts is under Connect, not Accounts v2. It is Read only, so Gemmein knows which Stripe account the key belongs to. Then choose Create.
  4. Already made a key with more? Edit it in Stripe and choose None on every section heading: each heading sets every row under it. Then turn on only the five.
  5. Paste the key on that environment's Payments page. It starts rk_test_ in Development and rk_live_ in Live. Don't share your keys anywhere else: paste the restricted key only there.
  6. Give each paid plan a price (an amount, a currency, and how often it bills: day, week, month or year), and each product or credit pack a one-time price: your AI writes them in gemmein/payments.json and npx gemmein sync sets them in Development, or you set them on the Payments page. Gemmein makes the Payment Link.

What it is. A Stripe restricted key, one per environment: test mode for Development, live mode for Live. It carries five permissions: Write on Products, Prices, Payment Links and Webhook Endpoints, and Read on Accounts. None of them can move money.

Does. Checks the five permissions with calls that change nothing, and names each one missing. Gemmein refuses a key that can do more than these five: it tries harmless reads of your money and customer data (balance, charges and refunds, customers, payouts, payment intents, payment methods, subscriptions, invoices, checkout sessions, transfers, coupons, disputes and more), and a key that can make any of them is refused. The Payments page then shows what the key can read and the five rows to set; the API answers stripe_key_too_broad with what it can read in readable. A Stripe account belongs to one Gemmein account: another Gemmein account connecting it is refused, while your own apps may share it. Registers one webhook endpoint in your Stripe with the eleven events Gemmein needs, plus the event of each custom rule you add. Checks the webhook every 6 hours and the key every day: a key that was revoked, rolled or given more permissions stops being used, and a webhook that was deleted, disabled, moved or stripped of events is flagged on Today and by email. A key problem shows Connect a new key, and a webhook problem shows Repair webhook. Replace key, on the key row, swaps in a new key from the same Stripe account and keeps the webhook and your Payment Links. Your Payment Links keep selling throughout. Creates a Stripe product, price and Payment Link for each plan, product and credit pack you price. A new price makes a new Stripe price and Payment Link and turns the old ones off, because a Stripe price never changes. A rename renames the Stripe product, and removing one archives it in Stripe. In Live, the live-mode key creates Live's own products, prices and Payment Links from the prices going live copied.

Does not. Move money, refund, or read your customers, charges or payments: the key carries no permission for any of them. Show the key again: once pasted, only its last four characters are shown. Accept a Stripe secret key (sk_…) or publishable key (pk_…), a live-mode key in Development or a test-mode key in Live. Take a Payment Link or a webhook signing secret made in Stripe: selling through Stripe needs the key, and Gemmein hands out only the Payment Links it made.

Needs something else when. You sell through a provider other than Stripe: use a relay.

Example. A restricted key made in Stripe test mode with the five permissions, pasted on Development's Payments page; "pro" priced at 15 GBP a month in gemmein/payments.json and set by npx gemmein sync. g.subscriptions.checkout("pro") opens the Payment Link Gemmein made in your Stripe.

Changing a price, or renaming something Gemmein made in Stripe, needs the key connected. Disconnecting forgets the key: the webhook, its signing secret and every Payment Link keep working, and only making or changing things in Stripe stops. If two people save at once, the later save is refused with a reload message rather than overwriting the first.

Turning payments off stops new sales: nothing new can be bought and Gemmein turns off the Payment Links it made. Existing customers carry on as before. A renewal keeps its access, and a cancellation, a full refund of a purchase, or a renewal Stripe stops retrying takes it away. The webhook stays; a new sale that still arrives is kept on the Problems page for you to refund or grant, and is never granted on its own. Removing the webhook is a separate action, Disconnect and delete, which waits until no Gemmein link is on, no subscription can still charge, and 72 hours have passed since the last link went off. A key from a different Stripe account is refused. In Development, Start over lets you connect another Stripe account once nothing can still pay.

A payment names what was bought by the Payment Link it was made on. A payment on a link Gemmein did not make with this environment's key is kept on the Problems page for you to refund or grant, and is never granted on its own.

Protect your accounts

  • Use a different email address for Stripe and for Gemmein. If one inbox is compromised, the other account stays safe.
  • Make them addresses only you use, not your public name or contact address: for example, a new address kept only for this. Attackers target the addresses they can find on your site, on LinkedIn or in git commits.
  • Turn on two-step sign-in for both inboxes and for Stripe. You sign in to Gemmein with a code sent to your email, so your inbox's security is your Gemmein account's security.
  • Never share your keys or sign-in codes, including in screenshots. Gemmein asks for a key only in the Connect Stripe field on the Payments page.

Going live

Going live copies your plans and products into Live with their names, prices, credit allowances and packs, and what they unlock. Development's Stripe products, Payment Links and signing secret stay in Development. Connect Stripe in Live with a live-mode key and Gemmein creates them in Stripe live mode. Until then customers can't pay in Live, and Live's checkout answers payments_not_configured; you can still go live first.

Once live, Promote on the Go live page copies what Development has into Live:

  • Plans, products and credit packs Live doesn't have are copied whole.
  • Plan and pack definitions (credits, rollover, the welcome allowance, a pack's credits) are brought to Development's values.
  • A different price is shown and never applied. Change Live's price on Live's Payments page.
  • An AI tool or collection unlocked by a plan Live doesn't have is re-linked to Live's plan of the same name. If no name matches, the promote stops before anything moves and says which one.
  • An AI provider key is copied only when you tick Use the same key as Development.

Each priced plan and product has a Tax category, such as Online service, Software subscription, Ebook or Audio. Gemmein sets the matching tax code on the product it makes in Stripe, which Stripe Tax and Managed Payments read. A product starts on Online service and a plan on Software subscription.

Set by your AI

In Development your AI sets how each plan and product is sold, in one file the project keeps: gemmein/payments.json. Per product: its price, the file inside the project (or the link) a buyer gets, the credits it adds and its tax category. Per plan: its price and how often it bills, the credits each period grants and what rolls over, and its tax category.

{ "plans": [
    { "name": "Free", "default": true },
    { "name": "Pro", "paid": true,
      "price": { "amountMinor": 900, "currency": "gbp", "interval": "month" },
      "credits": { "perPeriod": 500, "rollover": "none" } } ],
  "products": [
    { "name": "Field Guide", "price": { "amountMinor": 1500, "currency": "gbp" },
      "deliveryFile": "assets/field-guide.pdf", "taxCategory": "ebook" } ] }
  • npx gemmein sync shows what it will change, uploads the file, and sets it in Development. With Stripe connected there, each priced item sells at once through Stripe test mode; without, it waits as not sold yet and is made the moment the key connects.
  • Those rows read set by your AI on the Payments page. You can edit any of them; the next sync shows your edit and leaves it as it is, unless you run npx gemmein sync --overwrite.
  • An item missing from the file stays. "remove": true on it takes it down: its Payment Link goes off, and a file stays for whoever bought it.
  • A product's file is a pdf, zip, epub, audio file or image up to 100 MB, or a video (mp4, webm, mov) up to 500 MB (50 MB in Development). The buyer's g.files.link(file) plays an audio or video product in the page; other files download.
  • npx gemmein payments --cloud reads back what Development sells: each price, how it is sold, its Payment Link, its file and tax category, and whether Stripe is connected.
  • Live is never set by sync. Going live and promote copy Development's plans and products into Live, where Live's own key makes them.

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; the provider's cancellation webhook is the revoke.
  • 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).

One-off purchases

Plans are for subscriptions. To sell a single thing (a beat, an ebook, a course, a licence), add products on the same page and choose how each is sold:

  • Stripe. A price, with Stripe connected; Gemmein makes the Payment Link. g.payments.buy navigates to Stripe only for a product sold this way.
  • Relay. Any provider whose webhook you map (GoCardless, Lemon Squeezy, Paddle, bank transfer) fulfils the product with the fulfil_product relay action. The relay binds by name: renaming or deleting it stops fulfilment until a relay with that name exists again, and the product card shows this.
  • Not yet. The product is defined and its grants and credits are known, but no way of selling it is connected.

g.payments.buy on a product sold via a relay or not yet answers 409 product_not_sellable.

Buying signed out

A visitor who is not signed in can buy a product. g.payments.buy works either way, so a product's buy button needs no sign-in in front of it. Signed out, Stripe Checkout asks for the buyer's email, and the purchase is theirs the first time they sign in with that email. A product bought with an item note needs a signed-in buyer (401 authentication_required). Plans always need sign-in, because a plan is tied to an account.

The purchase email

After each paid product purchase, Stripe or relay-sold, Gemmein emails the buyer once for that payment: Your <product> is ready, from your domain's account address, with a link to your Library on your app's verified web domain. It is on by default. On this page, under Products, Email buyers when they buy turns it off and Library path sets where the link opens (/ until you set one). It is sent only from your own verified domain; until one is verified it is not sent, and Logs shows one row a day saying so. See Email.

What it is. A product: a named thing you sell once, such as a download, a licence or a credit pack.

Does. Grants its access key and its credits on purchase. Writes the buyer's receipt. A full refund takes both back.

Does not. Does not sell subscriptions (those are plans). Sold through a relay, it does not set a price; the provider does.

Needs something else when. You sell through a provider without a Payment Link: use a relay with fulfil_product. You meter by usage: use credits spent per AI tool.

Example. "Starter pack", 100 credits, sold through a GoCardless relay.

For one product that covers many items, name the item. The item is display text on the receipt; the price is always the Payment Link's or the relay's. g.purchases.mine() returns the person's payment history from Gemmein's own record:

await g.payments.buy("premium license", { item: "beat_37" })   // redirects

const purchases = await g.purchases.mine()   // the array itself
// [{ item, amountMinor, currency, refundedMinor, status, grants, paidAt }]

To fulfil, tick the product in the collection's Unlocked by row on the Collections page. The server then refuses people who haven't bought it, with no code from you. Never grant access when the redirect comes back: redirects can be faked, and access comes from Stripe's signed webhook.

The 14-day waiver at checkout

Buyers in the UK and EU have 14 days to cancel an online purchase. For digital content, that right ends when delivery starts, but only if the buyer agreed to it before paying. One switch per environment, off by default, adds that agreement to the Stripe checkout of every one-off product: a file, a link, or access alone. Set it in gemmein/payments.json, with the link to your Terms of Service:

{ "consent": { "digitalWaiver": true, "termsUrl": "https://example.com/terms" }, "products": [ … ] }

The buyer must tick this box before paying. "the seller's terms" links to your termsUrl:

"I agree to the seller's terms, and I ask for this digital content to be delivered straight away. I understand that once delivery starts, I lose my right to cancel within 14 days."

What it is. One switch per environment for one-off products: a file, a link, or access alone. Go-live and promote carry it to Live.

Does. Gives each such product a new Payment Link at the same price, whose checkout requires the box above; the old link goes off. Every Logs row of a purchase made with it records the consent and the words the buyer ticked.

Does not. Does not touch plans or credit packs: they are services used over time. The wording is one neutral default and cannot be edited. It is not legal advice: whether it fits your business is your decision.

Needs something else when. Turning it on needs termsUrl: https, a public domain name (no IP address, localhost or private name), no username or password, at most 500 characters. Without it, the save is refused with consent_terms_url_required. Stripe also needs the Terms of Service link in your account: add it under Settings → Business → Public details. Until then, turning it on is refused with stripe_terms_url_missing.

Example. A GBP ebook shop turns it on; the ebook's checkout shows the box, and the purchase's Logs rows read consent: accepted with the words.

Your AI recommends it when your Stripe account is in the UK or EU, or a price is in GBP or EUR. npx gemmein payments --cloud shows whether it is on, and when it is recommended.

Show what you sell

The products and plans on the Payments page are your app's catalog. A storefront lists them with g.payments.products() and a pricing page with g.subscriptions.plans(), in the order the page shows them. No sign-in is needed; the app key is enough. Each item's name is what buy and checkout take, and your app supplies its own words and images for each one in code, keyed by that name:

const products = await g.payments.products()
// [{ name, price: { amountMinor, currency }, delivers, credits, unlocks }]
const plans = await g.subscriptions.plans()
// [{ name, free, price: { amountMinor, currency, period }, credits, unlocks }]

What it is. One read of what your app sells: the products and plans on the Payments page, for each environment.

Does. Lists every product and plan that can be bought right now, plus the free plan at price 0. Gives each one's name, price (in minor units: 1500 is 15.00), what the buyer gets (a file, a link or nothing to download; credits; access) and, for a plan, how often it bills. A price changed on the Payments page shows within a minute.

Does not. Does not list a product or plan that is not sold yet, sold through a relay, or waiting for Stripe to be connected. Does not carry Payment Links, Stripe ids, files or delivery links. Does not carry descriptions or images; those are your app's design.

Needs something else when. Someone buys: g.payments.buy(name) or g.subscriptions.checkout(name). You need what a person owns: g.purchases.mine() and g.subscriptions.mine().

Example. A shop page renders each of g.payments.products() with its words and image from the app's own copy for that name, and a Buy button that calls g.payments.buy(p.name).

Do not make a collection to list what you sell. The Payments page is the one list; a second one has to be filled and kept in step by hand.

Access

This is the paywall. A plan or a product unlocks a collection when you choose it by name in that collection's Unlocked by row on the Collections page. Paying for the plan opens it; cancelling, lapsing or a full refund takes it back. The server refuses everyone else, and your app writes no check at all.

// On the Payments page: a plan named "pro" with a price
// On the Collections page: "reports" → Unlocked by → tick "pro (plan)"

// Your app reads, catches entitlement_required and shows the upgrade prompt:
try {
  const { records } = await g.collection("reports").list()
} catch (err) {
  if (err.code === "entitlement_required") showUpgrade(err.requires)  // "access:pro", the plan's access key (see below)
}

Call plans and products whatever your business calls them (pro, Film X, Course: Foundations). Gemmein attaches no meaning to the names, so a new tier needs no Gemmein release. A collection can be unlocked by several plans or products at once ("anyone on pro or studio, or who bought Midnight Pack"), and any one of them opens it. A person can hold access for several reasons at once, such as a plan, a lifetime purchase and a manual grant from the dashboard, and it ends only when the last one does.

Access keys. Each plan and product carries one access key, generated from the name when it is first saved: plan "pro" → access:pro, product "Midnight Pack" → access:midnight-pack. A rename keeps the key, so nobody loses access. It is what err.requires carries (one key, a string; if several plans open the collection, it names the first one) and what g.purchases.mine() lists under grants. The dashboard shows it only as a muted hint beside the name, and nobody types it.

Access is set per collection. "Three free lessons, the rest premium" means two collections, one open and one locked. "Must hold both" cannot be expressed; that is a second collection. A locked public_read or community collection becomes authenticated access: an anonymous reader is told to sign in (never which plan they lack).

What your app can see. It reads a person's purchases and what each one granted (g.purchases.mine()) and their subscription (g.subscriptions.mine()). By-hand grants are not listed to the app, and a locked read succeeds. So never rebuild the paywall client-side from the lists you can see; let the server refuse, and show the upgrade prompt on entitlement_required. To give someone access, grant it on their page in the dashboard; it is never code.

Grants

Every bit of access a person holds is a grant: one access key, one reason, a start and maybe an end. Grants have seven sources in three families. The family decides who can create a grant, what ends it, and whether it counts as revenue on your dashboard.

FamilySourcesWho writes itEnds whenCounts as revenue
Purchase-tiedsubscription, purchaseOnly a payment: Stripe's signed webhook, or the relay action (fulfil_product, grant_plan) set as how the product or plan is sold. A bank debit counts once it clears. Never you by hand, never your appA subscription grant: the subscription cancels, lapses or is revoked by its relay. A purchase grant: a full refund (a partial refund leaves it)Yes, the only grants that do
By handby hand, trial, promotion, migration (people you brought over from somewhere else)You, from a person's page, or your server with a secret key (grantAccess), choosing the plan or product by name, with an optional end date and a reasonOn its end date, or when you revoke itNo
By relayrelayA relay, when its trigger fires: a provider's signed webhook, a schedule, or a record change. No secret key can create oneOn the date the action named, or when you revoke itNo
  • A grant is never edited. An extension is a new grant, so the reason on every grant stays true.
  • You can end a purchase-tied grant by hand from the person's page. That stops the access and refunds nothing; money moves only in Stripe.
  • A refund never changes a by-hand or relay grant, and revoking a by-hand grant never triggers a refund.
  • A relay grant reads "granted by relay <name>" on the person's page.

Which grant to use:

  • "Give them a free month": a trial grant with an end date.
  • "Launch deal, first 50 get pro": a promotion grant.
  • "Support fix, they were double-charged": a by hand grant with the reason written down.
  • "They paid on my old platform": a migration grant, so the record says why.
  • "They paid through GoCardless": a relay grant, written when the provider's signed webhook lands.
  • "They paid": never by hand, because Stripe writes it, so revenue reflects only money that arrived.

Refunds

Gemmein takes access back only on the events that plainly mean it: a subscription cancelled or lapsed, or a one-off purchase refunded in full. A full refund revokes only the grants that payment created, so a person who also subscribes keeps their subscription's access.

Every other refund is yours to act on:

  • A partial refund leaves access in place, because a small goodwill refund on a large purchase shouldn't take away what someone bought.
  • A refund on a subscription invoice touches no access at all. A webhook cannot tell a goodwill refund from a "take it all back" refund.
  • When someone cancels at period end and asks for their money back, they keep access until the period ends unless you act. You have two moves, both one click and both audited: cancel the subscription immediately in Stripe, which ends the access the moment the event lands, or revoke it by hand on the person's page.
  • Every refund the webhook did not act on is written to your activity view.

Receipts

Gemmein records every payment itself. Buyer, amount, currency, Stripe reference, what it granted, and every refund against it. That record is kept outside your collections, so renaming or deleting one can never change what your revenue was. Each person reads their own copy with await g.purchases.mine(); refunds appear as refundedMinor and a status of part_refunded or refunded.

A receipts collection is optional. Add one only if you want purchases inside your data model, so your app can list and edit them like any other record. Gemmein's own record above stays the authoritative payment record, and your collections cannot alter it.

Receipts carry { product, item?, status, amountTotal, currency, paidAt, deliveryUrl?, deliveryFile?, paymentRef } in .data, where amountTotal is minor units exactly as Stripe reported. You fulfil orders by editing the receipt from the dashboard (status: "shipped"); your app reads it. Refunds happen in your Stripe dashboard, and charge.refunded flips the receipt's status to "refunded".

A receipt is app-owned: its top-level ownerUserId is null (the webhook wrote it), and the audienceUserId is what scopes it to the buyer.

That scoping keeps a receipt private. Receipts live in an addressed collection, so the server narrows every read to the signed-in buyer: another person calling list() on the same collection gets their own receipts and nothing else. No request returns yours, whether a crafted filter or a guessed id. A signed-in buyer's identity comes from the checkout reference Gemmein encoded, and a guest's from the email Stripe Checkout collected, read from Stripe's signed webhook; never from anything the browser sent back. So what someone paid for stays theirs even when the frontend performs no check.

A deleted account

A person who deletes their account and later buys again is a new customer. A payment made before the deletion grants nothing, whether it arrives from Stripe or from a relay; refund it in your payment provider. Relays tell before from after by the payment time you map as paid_at (see Relays). The rows Logs and Problems keep for these refusals never contain the person's address.

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.

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 your Stripe customer portal. Set it up once per environment:

  1. In Stripe, open Settings → Billing → Customer portal and turn it on.
  2. Under Subscriptions, let customers switch plans and add each plan's product. Set a trialing subscription's update to end the trial.
  3. Copy the portal's login link and paste it on Payments → Customer portal link.

Your app sends a subscriber there with await g.subscriptions.manage(). Gemmein follows every change through Stripe's webhook.

What happens if…

The cases you are most likely to meet, by part of this page. Credits have their own table on Credits.

After paying, and comped plans

If…What happensWhat you do
A product went on sale before your domain was verifiedGemmein points its Payment Link back to your domain the moment it is verified. After paying, the buyer lands on your return path (/ unless you set one on the Payments page).Nothing.
Your verified domain is removedBuyers see Stripe's own confirmation page after paying.Verify the domain again.
You remove a productIts file stays while any buyer holds it and is deleted once nobody does (a full refund ends a hold; a part refund keeps it). A file uploaded in the last 24 hours is never touched, and a Live copy replaced at go-live goes the same way. Logs records each deletion.Nothing.
You comp a plan with an end dateIt ends on its own at that date (checked every 10 minutes): back to the default plan, its access goes, and Logs records it. Credits already granted stay.Nothing.
You switch or cancel a comped planIts access moves at once: the old plan's comp access goes, the new plan's comes.Nothing.
A comped person later paysThe paid plan takes over and the comp's own access goes, so nothing outlives the paid plan.Nothing.

Connecting Stripe

If…What happensWhat you do
You paste a secret key, a publishable key, a signing secret or a Payment LinkRefused in one sentence. A live-mode key in Development, or a test-mode key in Live, is refused tooMake a restricted key with the five permissions above
The key is missing a permissionRefused, naming each missing row (stripe_key_permissions)Turn that row on in Stripe's key editor and paste the key again
The key can read money or customer dataRefused (stripe_key_too_broad), with what the key can readChoose None on every section heading, then turn on only the five
The Stripe account is already connected to another Gemmein accountRefused (stripe_account_in_use). Your own apps may share one Stripe accountConnect a Stripe account that only you use
You paste a key from a different Stripe accountRefused while what Gemmein made in the first account existsIn Development, Start over once nothing can still pay
You replace the key with a new one from the same Stripe accountThe webhook, its signing secret, your Payment Links and the account's claim carry overNothing
The key is revoked or rolled in StripePayment Links already out keep selling and payments keep landing. Making or changing anything in Stripe is refused (stripe_key_broken). The Payments page, Today and Problems show it, and you get one emailConnect a new key
The key is given more permissions laterGemmein stops using it (stripe_key_too_broad) and resumes on its own once it is back to the fiveRemove the extra permissions in Stripe
Gemmein's webhook is deleted or disabled in StripeThe Payments page, Today and Problems show it, and you get one emailChoose Repair webhook. A deleted one is made again with a new signing secret, and the old one keeps verifying for 72 hours
Stripe refuses part of a save, such as a permission the key lostNothing is saved. What Gemmein made in Stripe during that save is turned off again, and the message names what to fixFix what the message names and save again
Two people save the Payments page at onceThe later save is refused with a reload messageReload and save again
A payment lands on a Payment Link Gemmein did not make, or on a removed item's old linkNothing is granted, even to an item added again under the same name. Problems names the paymentRefund it in Stripe, or grant it by hand
You disconnect the keyThe webhook, its signing secret and every Payment Link keep working. Only making or changing things in Stripe stopsNothing
Live has no key yetNothing sold through Stripe can be bought in Live; its checkout answers payments_not_configuredConnect a live-mode key on Live's Payments page

Subscriptions

If…What happensWhat you do
A renewal payment failsThe customer keeps the plan while Stripe retries the card, and g.subscriptions.mine() still answers active. Your Stripe retry schedule is the grace periodNothing. Set the retries in your Stripe subscription settings
Stripe stops retrying a failed renewalWhen Stripe cancels the subscription or marks it unpaid, the customer moves to your default plan and the plan's access closes. A subscription Stripe leaves past due keeps the planIn your Stripe subscription settings, cancel the subscription or mark it unpaid after the last retry
A customer cancels at period endThey keep the plan until the period ends, then move to your default planNothing
A subscriber calls g.subscriptions.checkout againNo second subscription is started. It answers already_subscribed with your customer portal link, where they change planSet the customer portal link (see Changing plan)
The price on a subscription is changed in Stripe to another plan's priceThe customer moves to that plan, and Logs records the change. A price Gemmein did not make leaves the plan as it isNothing
A subscription is paused in StripeThe customer is on your default plan while it is paused. When it resumes, the plan comes backNothing
A renewal is paidThe plan's credits for the new period are granted once. A renewal never changes the planNothing
A subscription payment is refunded in fullThat period's credits are taken back and the payment reads refunded. The plan and its access stayTo end access too, cancel the subscription in Stripe (see Refunds)
Stripe sends events late, out of order or twiceEach applies once and the newest state wins. A late payment never reopens a cancelled planNothing
A customer pays by bank debitThe plan opens when the payment clears. A payment that never clears is closed after 30 days, and opens the plan if it still arrivesNothing
You rename a planIts subscribers move with it and keep their accessNothing

Free trials

If…What happensWhat you do
A customer starts a trialThe plan opens at once and its trial credits are granted once. g.subscriptions.mine() answers trialing with trialEndsAtUnlock on plan, as for any subscriber
The trial ends and the card paysThe status becomes active, and the plan's credits for the first paid period are grantedNothing
The trial ends and the card is declined, with onPaymentFailure keepThe customer keeps the plan while Stripe retries the card. If Stripe gives up, they move to your default planSet the retries in your Stripe subscription settings
The trial ends and the card is declined, with onPaymentFailure endThe customer moves to your default plan at once. If a retry succeeds later, the plan opens againNothing
The trial ends with no card (card optional)Stripe cancels the subscription and the customer moves to your default planSend them to g.subscriptions.manage() to add a card before the trial ends
The customer cancels during the trialThey keep the plan until the trial ends, are not charged, then move to your default plan. mine() answers endsAtNothing
The same customer asks for another trialCheckout sends them to the plan's paid link. One trial per customer per app, and none for anyone who has paid for a plan. A +tag or a Gmail dot is the same customerNothing
Someone uses many addresses on a catch-all domainEach address is a separate customer, so each can start a trialKeep the card required (the default) for plans where that matters
A trialing customer switches planThe trial ends, and the new plan's first-period credits are granted when the switch is paidNothing
A customer deletes their account and signs up againThey are a new customer, but their trial stays usedNothing
Someone opens the trial link without your app, or starts two trials in two tabsOnly one trial Gemmein offered opens a plan. Any other grants nothing, and Problems names itCancel that subscription in Stripe
You change or remove a plan's trialNew customers get the new offer. Trials already running keep theirsNothing
Development has given 25 trials todayCheckout hands out the paid link until the day rolls on. Live has no capNothing

Changing plan

If…What happensWhat you do
Your app calls g.subscriptions.manage() and no portal link is setIt answers portal_not_set_up. In Development the answer carries the setup stepsPaste the customer portal link on Payments
A subscriber calls checkout for another plan or the same oneIt answers already_subscribed with the portal link, whether their subscription is active, trialing, past due, unpaid, paused or incomplete. No second subscription is startedSend them to the url it returns
The same person opens two checkouts for a plan at onceThe second answers checkout_in_progress for a few seconds. After 24 hours a checkout link only pays for the person it was made forNothing
Two subscriptions for one person go live anywayProblems shows "Duplicate subscription — cancel one in Stripe". Gemmein can't cancel in StripeCancel one in Stripe
A subscriber moves to a plan with more creditsThe new plan opens at once. Its extra credits arrive when the upgrade is paid. Each prorated line is priced on its own: the new plan's per-period credits × the share of the period the line covers, minus the same for the old plan, each line by its own discounts (tax excluded). A line in another currency than its plan grants nothing. A 99% coupon gives 1% of the credits, and several changes on one invoice each count once. If your portal bills prorations later, they come with that invoiceNothing
A subscriber moves to a plan with fewer credits, or the upgrade is refundedCredits are taken back at the same rate for the money returned, first from what the upgrade granted. A credit pack's own line is never touched, and the ledger records it as a plan changeNothing
A downgrade returns money for credits you already usedYour balance goes below zero until your next renewal or purchase. It can't be spent; the next renewal or pack pays it down firstNothing
A subscriber moves up, spends, and moves back down, again and againThe credits gained match the money kept. What they spent ahead stays a debtNothing
A subscriber switches during a trialThe trial ends when the portal is set to end it, and the new plan opens. A switch that charges nothing grants no extra creditsSet a trialing subscription's update to end the trial in your portal settings
A renewal and a plan change arrive out of orderThe renewal grants the credits of the plan on its own invoice's subscription lineNothing

One-off purchases

Signed-out buyers, the purchase email and deleted accounts have their own sections: Buying signed out, The purchase email and A deleted account.

If…What happensWhat you do
A buyer pays by bank debitThe product is granted once the payment clears. A failed payment grants nothing, and Logs says so onceNothing
A checkout never clearsIt is closed after 30 days. A payment that still arrives is fulfilledNothing
A purchase is refunded in fullThe product's access and credits are taken back, the purchase reads refunded, and g.files.link refuses its file from the next requestNothing
The buyer downloaded the file before a full refundNo new download link is issued. A copy already on their device stays with them (see Limits)Nothing
A purchase is refunded in partAccess, credits and the file stay. The purchase reads part_refundedTo take access back, revoke it on the person's page
A credit pack is refunded in full after some credits were spentWhat is left of that purchase's credits is taken back, never below zeroNothing
A buyer who also subscribes is refunded for a productOnly what that purchase opened closes. The subscription's access staysNothing
A subscriber who bought products cancelsProducts bought outright stayNothing
A refund arrives before its paymentIt is applied when the payment lands: a full refund grants nothing, and a partial one grants the product and records the refundNothing
Stripe sends the same payment twiceThe product and its credits are granted onceNothing
The 14-day waiver is turned onEach one-off product (a file, a link, or access alone) gets a new Payment Link whose checkout requires the box; the old link goes off. Plans and credit packs are unchangedNothing
The 14-day waiver is turned on without termsUrlRefused with consent_terms_url_required. A link that isn't https, carries a username or password, or is over 500 characters is refused with invalid_consent. Nothing changesAdd your Terms of Service link as termsUrl
termsUrl changes while the waiver is onEach such product gets a new Payment Link whose box links the new terms; the old links go offNothing
A save would make more than 10 new Payment Links in a day in Development, or 50 in Live, for things already on saleRefused with development_capped (Live: links_capped). Nothing changes. A new price makes one link; the 14-day waiver turned on or off, or a new termsUrl, makes one per product it covers; promote counts the same. A product's first link never countsTry again the next day (UTC)
You change the 14-day waiver on the Payments page after your AI set itThe next npx gemmein sync shows a conflict and keeps your settingRun npx gemmein sync --overwrite to take the file's
The 14-day waiver is on when you go live or promoteThe Go live page notes that Live needs a Terms of Service link set in your Stripe live accountSet it in Stripe live mode first
A buyer pays on a link replaced days agoThe payment still resolves to its productNothing
A domain is verified, or the return path changes, while the 14-day waiver is onEach Payment Link is pointed at the new return in place. The box stays, no link is made again, and nothing counts against the daily capNothing
The 14-day waiver is turned on and Stripe has no Terms of Service linkRefused with stripe_terms_url_missing. Nothing changesAdd the link in Stripe under Settings → Business → Public details, then turn it on again
A buyer pays with the 14-day waiver onEvery Logs row of the purchase records consent: accepted, with the words the buyer tickedNothing
A buyer opened checkout on the old link before the waiver was turned onThey can still finish paying, without the box. The purchase is fulfilled, and its Logs rows carry no consentNothing
The 14-day waiver is turned offThe products get new links without the box. Purchases already made keep their recordNothing
A relay refunds a product with refund_productExactly what that purchase added is taken back. A purchase made through Stripe is refunded only in StripeRefund Stripe purchases in Stripe

Access

If…What happensWhat you do
A signed-out visitor opens a locked collectionThey are told to sign in, never which plan or product unlocks itNothing
A signed-in person without the plan or product opens itRefused until they hold one of the plans or products in Unlocked bySend them to checkout
You grant access by hand with an end dateIt opens at once and closes on its own at that date. A date already past is refusedNothing
Access was granted in DevelopmentIt never opens anything in LiveGrant it again in Live
Your dashboard or a secret key reads a locked collectionIt is not refused: the lock applies to customersNothing

Limits

Gemmein does not do the following, and none of it is planned. If your business depends on one of them, use a different backend. Credit packs are products; see Credits.

  • No usage quotas or record-count limits. "Ten projects on the free plan" is your app's logic. Gemmein tells you who holds what; it does not count your records.
  • No seats or per-organization billing. There are no organizations, memberships, invitations or ownership transfers. One person, one identity.
  • No physical goods. No shipping, addresses, delivery rates, inventory, stock reservation, variants, fulfilment, tracking or returns. Gemmein is for software and digital access.
  • No multi-item carts. One product per checkout, by design. A "cart" is N checkouts, or one bundled product you price as a bundle. Don't build a cart UI that promises otherwise.
  • No marketplace shapes: multiple sellers, payouts, commissions or inter-party disputes.
  • No usage-based invoicing, and no tax calculation, invoicing or accounting export. Stripe handles these.
  • No DRM. Gemmein controls whether a person may fetch a file. Nothing stops a legitimately downloaded file being reshared afterwards, and no backend can change that. For an external asset (a delivery link you host elsewhere), the host is responsible for everything after the link is issued.
  • Chargebacks aren't handled. A dispute does not revoke access, because Gemmein doesn't receive dispute events. Handle those in Stripe and revoke by hand if you need to.