Payments
Stripe end-to-end, without you building any of it: Gemmein hosts the webhook, manages subscriptions, and writes receipts. Money never touches Gemmein.
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.
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 deliberately 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; 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).
What paying unlocks — entitlements
This is the paywall. A plan or a product can list what it grants; a collection can require one. The server refuses everyone else — your app writes no check at all.
// Owner, on the Payments page: plan "pro" grants access:reports
// Owner, on Collections → settings: "reports" requires access:reports
// Your app just reads. The refusal IS the upgrade trigger:
try {
const { records } = await g.collection("reports").list()
} catch (err) {
if (err.code === "entitlement_required") showUpgrade(err.requires) // "access:reports"
}
The names are yours to invent — access:pro, access:film-x,
access:course-foundations. Gemmein never learns what any of them mean, so a
new tier needs no release from us. Subscribing grants; cancelling, lapsing or a full
refund takes it back. A customer can hold the same access for several reasons at once —
a plan and a lifetime purchase and a support gesture — and it ends only
when the last one does.
Gating is per collection, not per record. “Three free
lessons, the rest premium” means two collections — one open, one gated. One
requires per collection, access: keys only, no and/or
expressions. And a gated public_read or community collection
effectively becomes authenticated access: an anonymous reader is told to sign in
(never which entitlement they lack).
Selling things — one-off purchases
Plans are for subscriptions. To sell a thing — a beat, an ebook, a course, a licence — the owner adds products (name + Payment Link) on the same page:
Every payment is recorded by Gemmein itself. Buyer,
amount, currency, Stripe reference, what it granted, and every refund against it — 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 product's Payment Link:
await g.payments.buy("premium license", { item: "beat_37" }) // redirects
// Fulfilment: let the product GRANT an entitlement and gate the
// collection on it — the server then refuses non-buyers with no code
// from you. Never gate on the redirect coming back: redirects can be
// faked, grants come from Stripe's signed webhook.
// Their payment history, straight from Gemmein's own record:
const { purchases } = await g.purchases.mine()
// [{ 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. It is not where the money truth lives — that is Gemmein's own record above, which your collections cannot alter.
Receipts carry { product, item?, status, amountTotal, currency, paidAt,
deliveryUrl?, paymentRef } in .data — 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 — 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) — it's the
audienceUserId that 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. There is no request that returns yours — not a crafted
filter, not 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 if the frontend forgets to check.
No carts, no quantities — 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 — don't also redirect to the returned url (you'll double-navigate).
What this doesn't do
Said plainly, so you find out here rather than halfway through building. None of these are coming soon; if one of them is your business, Gemmein is the wrong tool today.
- No consumable credits. Access is a yes/no question; credits are a quantity, and a quantity needs a ledger — additions, spending, reversals, an atomic floor. Selling a “100 credit pack” is not a supported pattern: the webhook can only set a number, so buying the same pack twice sets the balance to 100 twice and silently erases the first purchase.
- 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 deliberately leaves access in place — 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.