guide

Payments

Stripe end-to-end, without you building any of it: Gemmein hosts the webhook, manages subscriptions, and writes receipts. Payments are processed by Stripe on your own account; Gemmein receives only the webhook events. Stripe is built in. Any provider that signs its webhooks, GoCardless, Paddle or Lemon Squeezy among them, drives access the same way through a relay.

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

Does. Grants its key and its credits on purchase; writes the buyer's receipt; a full refund takes both back.

Does not. Does not set a price — the provider does. Does not sell subscriptions (those are plans).

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

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

Subscriptions: two jobs, total

The app owner names the plans and pastes Stripe Payment Links + one signing secret in the dashboard's Payments page. Your app then has exactly two jobs:

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

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

Exactly one subscription per customer (case-insensitive on email), created by the payment itself, downgraded to your default plan on cancellation, out-of-order Stripe events resolved to the newest.

A plan is sold one of three ways, set on the Payments page: via a Stripe Payment Link (checkout navigates there; Stripe's webhooks write the subscription and its cancellations and lapses end it); via a relay — any provider whose webhook the owner maps (GoCardless, Lemon Squeezy, Paddle, bank transfer) grants it with the grant_plan relay action and ends it with revoke_plan; or not yet. Subscription state is written by Stripe's webhooks or by the relay the owner configured, never by your app, and g.subscriptions.mine() answers the same { plan, status } whichever road wrote it. checkout on a plan sold via a relay or not yet answers 409 plan_not_sellable. A relay-sold plan is active until revoke_plan — the provider's cancellation webhook is the revoke.

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: raw emails are silently dropped by Stripe's URL rules, and sessions need a secret key that must never ship client-side.

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), plan_has_no_link (owner hasn't pasted that plan's link yet), plan_not_sellable (the plan is sold via a relay, or not yet).

Entitlements: what paying unlocks

This is the paywall. A plan or a product unlocks a collection by being chosen in that collection's Unlocked by row on the Collections page, by name. 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.

// Owner, on the Payments page: a plan named "pro" (name + Payment Link)
// Owner, on the Collections page: "reports" → Unlocked by → tick "pro (plan)"

// Your app just reads. catch entitlement_required and show 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 key, see below
}

Call plans and products whatever your business calls them (pro, Film X, Course: Foundations). Gemmein never learns what any of them mean, so a new tier needs no release from us. 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 customer 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. Owners also grant and revoke by hand from a customer's page (a trial, a comp, a support fix), choosing the plan or product by name.

For developers: the key. 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's customers lose 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.

Gating is per collection, not per record. “Three free lessons, the rest premium” means two collections, one open and one gated. Several plans or products on one collection means any of them opens it; “must hold both” is not expressible, and that is a second collection. And a gated public_read or community collection effectively becomes authenticated access: an anonymous reader is told to sign in (never which plan they lack).

Two kinds of grant: purchase-tied and by hand

Every bit of access a customer holds is a grant: one key, one reason, a start and maybe an end. Grants come in two families, and the family decides who can create it, what ends it, and whether it counts as money. A grant is never edited; an extension is a new grant, so the reason on every row stays true.

Purchase-tied covers subscription and purchase. These are written only when money arrives — by Stripe's signed webhook, or by the relay action (fulfil_product, grant_plan) the owner set as the product's or plan's road — never by hand and never by your app. A subscription grant ends when the subscription cancels, lapses or is revoked by its relay; a purchase grant ends on a full refund, and a partial refund leaves it. These are the only grants that count as revenue in your back office. You can end one by hand from the customer's page, which stops the access and refunds nothing; money moves only in Stripe.

By hand covers by hand, trial and promotion, plus migration for customers you brought over from somewhere else. You write these from a customer's page, choosing the plan or product by name, with an optional end date and a reason. One ends on that date or when you revoke it, and none of them count as revenue. A refund never changes a by-hand grant, and revoking one never triggers a refund.

By relay covers relay, the seventh source. An relay writes it when its trigger fires: a provider's signed webhook, a schedule, or a record change. It ends on the date the action named or when you revoke it, it never counts as revenue, and a refund never touches it. The customer's page reads “granted by relay <name>”, and no secret key can create one.

  • “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.

When a refund meets access, you decide. The platform only takes access back on the events that plainly mean it: a subscription cancelled or lapsed, or a one-off purchase refunded in full. Everything else is left to you, on purpose: a partial refund leaves access in place, and a refund on a subscription invoice touches no access at all, because a goodwill refund and a “take it all back” refund look identical to a webhook. So when someone cancels at period end and asks for their money back, they keep access until the period ends unless you act. There are 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 customer's page. Every refund the webhook chose not to act on is written to your activity view, so nothing is silent.

What your app can see. It reads a customer'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 gated read simply 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, ask the owner to grant it on the customer's page; it is never code.

One-off purchases

Plans are for subscriptions. To sell a single thing (a beat, an ebook, a course, a licence), the owner adds products on the same page, and sets how each is sold: via a Stripe Payment Link, via a relay — any provider whose webhook the owner maps (GoCardless, Lemon Squeezy, Paddle, bank transfer) fulfils it with the fulfil_product relay action — or not yet (defined, its grants and credits known, no road wired). g.payments.buy on a product sold via a relay or not yet answers 409 product_not_sellable; it navigates to Stripe only for the stripe road. The relay road binds by name: renaming or deleting the relay stops fulfilment until a relay with that name exists again; the product card shows it.

Every payment is recorded by Gemmein 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. Your customer 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 to appear inside your own data model.

// One product covering many items? Name the item — display text on the
// receipt; the PRICE always comes from the provider — the Payment Link
// or the relay:
await g.payments.buy("premium license", { item: "beat_37" })   // redirects

// Fulfilment: on the Collections page, tick the product in the
// collection's Unlocked by row — the server then refuses non-buyers
// with no code from you. Never gate on the redirect coming back:
// redirects can be faked; access comes from Stripe's signed webhook.

// Their payment history, straight from Gemmein's own record:
const purchases = await g.purchases.mine()   // the array itself
// [{ item, amountMinor, currency, refundedMinor, status, grants, paidAt }]

Receipts: optional and app-visible

A receipts collection puts a purchase inside your data model, so your app can list and edit it like any other record. The authoritative payment record is Gemmein's own record above, which your collections cannot alter.

Receipts carry { product, item?, status, amountTotal, currency, paidAt, deliveryUrl?, deliveryFile?, paymentRef } in .data, where amountTotal is minor units exactly as Stripe reported. The owner fulfils orders by editing the receipt from the dashboard (status: "shipped"); your app just reads it. Refunds happen in the owner's 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, not a user), 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 customer 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. And the buyer's identity comes from the checkout reference Gemmein encoded, never from anything the browser or the payment page sent back. So what someone paid for stays theirs even when the frontend performs no check.

Carts and quantities are out of scope, by design. One product per checkout. A "cart" is N checkouts, or one bundled product the owner prices as a bundle. Don't build a cart UI that promises otherwise.

Both redirects self-navigate

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

What this doesn't do

This list is here so you find out now rather than halfway through building. None of these are planned, so if one of them is your business, Gemmein is the wrong tool. Credits are not on it: a credit pack is a product, on its own page.

  • Credits are a product, not an exception. Access is a yes/no question; a credit pack is a product with "Grants credits" set, and the ledger adds at every purchase, so two packs make double. A plan can carry "Credits each period", granted on each billing cycle's paid invoice (or the relay's payment reference), once; a proration or a zero-amount invoice is not a period, what is left of an earlier period follows the plan's rollover rule (carry, none, or carry up to a cap), and a full refund claws the period back. A full refund claws back what is still unspent, floored at zero. Your server spends with a reason, and the AI route spends the credits you set on the tool it names — one for an unnamed call. The ledger, the numbers and the codes are on Credits. What is not here is a bill after the fact by token or by minute; sell packs up front.
  • 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 rows.
  • No seats or per-organization billing. There are no organizations, memberships, invitations or ownership transfers. One customer, 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) and no marketplace shapes: multiple sellers, payouts, commissions or inter-party disputes.
  • No usage-based invoicing, and no tax calculation, invoicing or accounting export. That is Stripe's job and Stripe is better at it.
  • No DRM. Gemmein controls whether a customer 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 full refund revokes what that payment granted; a dispute does not, because Gemmein doesn't receive dispute events. Handle those in Stripe and revoke by hand if you need to.

A partial refund leaves access in place, because a small goodwill refund on a large purchase shouldn't confiscate what someone bought. Only a full refund revokes, and only the grants that payment created: a customer who also subscribes keeps their subscription's access.