Credits
Let people buy credits and spend them in your app. Credits are a balance each person holds and your product spends; Gemmein keeps the balance.
Example: a 100-credit pack and a 20-credit tool
On the Payments page, add a product named "100 credits", set how it is sold (a Stripe Payment Link, or a relay for another provider) and set Grants credits to 100. Each confirmed purchase adds 100 to the person's balance; two packs make 200. A full refund claws back what is still unspent, floored at zero; a partial refund moves nothing. The app shows the balance and sells the pack:
const { balance } = await g.credits.balance() // the signed-in person's balance now
if (balance === 0) await g.payments.buy("100 credits") // the pack is a product like any other
Each chat through the AI tool then costs the credits set on it, 20 in this case. For a spend of your own, such as an export or a render, your server spends by person id with a reason and a dedupe key:
const r = await gemmeinServer(process.env.GEMMEIN_SECRET_KEY).spendCredits(person.id, {
amount: 1, reason: "export", key: `export:${jobId}`
})
// { ok: true, spent: 1, deduped: false, balance: { before: 12, after: 11 } }
// GemmeinError 402 credits_exhausted: "this person has 0 credits — the spend needs 1"
- Your secret key needs the "Spend a person's credits" permission, ticked when you create the key.
- Repeat a spend with the same dedupe key (
key) and it answersdeduped: true,spent: 0and the same event. Nothing moves. - A dedupe key is scoped to the person and namespaced, so one order id reused for two people charges both. Rotating the secret key never charges twice.
holdings.creditsis{ balance }onverifySession()andholdings(), so one verify answers who the person is, what they hold, and how many credits they have.
What it is. A number on each person, kept by Gemmein and separate from access. Every movement is one line on the person's ledger, and a spend never takes the balance below zero. A refunded or downgraded plan can leave a debt, which the next renewal or pack pays off first.
What it does.
- Grant. The balance goes up when a person buys a pack, when a plan they pay for renews, when a relay grants some, or when you comp them from the dashboard (see Where credits come from). A plan period starts only with a period's own invoice: a proration, a one-off invoice or a zero-amount invoice starts none. A full refund of a period's invoice claws back what is left of that period's credits; the default plan carries none.
- Spend. Your server spends by person id, with a reason and a dedupe key so a
retry never spends twice. An AI tool call costs what you priced that tool at: a fixed
price per call by default, or, on a tool priced per token (
unit: "tokens"), a price per 1,000 tokens by default, settled at the provider's usage, up to a ceiling reserved before the call. See AI tools. - Ledger. The dashboard shows the meter, the grants and the ledger on the person's page, and lets you comp by hand, with an expiry date if you want one. A relay can run when someone runs low.
What it does not do.
- Spend below zero. A spend larger than the balance is refused whole, never partly.
- Open access. A locked collection still asks for the plan or the product, and a person holding 500 credits and no plan is still refused.
- Seats or quotas. There are none.
When you need something else.
- Access that switches on with a plan: see Payments.
- A bill at the end of a period, by token or by minute: Gemmein does not do this. Sell packs up front.
- A spend from the browser: nothing spends there. Your server, a relay or an AI tool moves credits, so put the spend behind your server or an AI tool.
Example. A 100-credit pack and a 20-credit tool, above.
Where credits come from
| Source | How | Ledger line |
|---|---|---|
| A purchase | A product with "Grants credits" (1 to 1,000,000). Added once per confirmed payment, in the same step that grants the product's access; a purchase by an email that has never signed in creates the person and credits them | purchase, the product's name as the reason |
| A refund | A full refund claws back what that purchase's own ledger line granted, at most what is still unspent, whatever the product says today. Partial refunds move nothing | refund_clawback, actor system |
| A plan | "Credits each period" on the plan, granted on each billing cycle's paid invoice (or, for a plan sold through a relay, each payment reference the relay's ref names), once per period, with your rollover rule: carry (the default), none, or carry up to a cap | grant, "<plan> plan"; an earlier period that ends under the rule is one expire line per grant |
| The free plan | The free (default) plan can carry an allowance too: "Credits each period" grants once per person per calendar month (UTC), at their first spend that month — never by a job over everyone — and every balance read shows it as spendable before it lands; a "welcome" allowance grants once, only to a person who has never held a paid plan | grant, "Free plan · <month>" or "Free plan · welcome" |
| The dashboard | "Grant credits" on the person's page, up to 100,000 per action, with a note and an optional expiry date | grant, your note, attributed to you |
| A relay | grant_credits { amount (1 to 10,000), reason?, expiresInDays? }, once per event; a promotional grant, the first to burn | grant, "relay: <reason>" |
| An expiry | What is left on a grant past its date, written when the person next moves or by the sweep soon after. What a run has reserved is safe past an expiry; only what comes back unused ends with its grant | expire, the grant's reason |
| Your server | spendCredits(personId, { amount?, reason, key? }), 1 to 10,000 per call | spend, your reason, attributed to the key; the person's page shows the credits spent in the last 30 days, not each line |
| An AI tool | The credits you set for the named tool, one by default, spent before the request is forwarded; refunded when the provider fails before the first byte | spend with reason the tool's label (ai_call for the default tool); spend_refund on a refund |
Spend lines are kept for 90 days in the cloud. Purchases, grants, expiries and clawbacks are kept. The person's page lists the live grants with their expiries and the newest 50 non-spend lines. An account erasure removes the person's grants, balance and ledger with everything else.
Grants and expiry
- Every credit a person holds sits on a grant: a pack, a plan period, a comp, a promo, a refund. The balance is what is left on the live ones.
- A plan allowance with no rollover ends with its period: at the period end a Stripe invoice names, otherwise when the next period's allowance lands.
- A relay's promotional grant ends after the days the relay names. A comp ends on the date you set. A pack never expires.
- Spends burn comps and promos first, then allowances, then packs, and within each the soonest to end first. Bought credits are the last to go.
- Each ledger line names the grants it moved. An expiry is one line per grant, for what was left.
API
| Surface | Call | Answer |
|---|---|---|
| Browser, signed in | g.credits.balance() (GET /auth/credits) | { balance, reserved, expiring }: spendable now, held by runs and per-token AI calls in flight, and the soonest grant that will end as { credits, at } or null |
| Server, secret key | spendCredits(personId, { amount?, reason, key? }) (POST /server/people/:id/credits/spend) | { ok, spent, deduped, balance: { before, after }, event: { id, reason, actor } }; a repeat with the same dedupe key answers spent: 0 and the same event |
| Server, secret key | verifySession(token) and holdings(personId) | holdings.credits = { balance, reserved, expiring }, the same meter |
| Relay | { "type": "grant_credits", "amount": 10, "reason": "…", "expiresInDays": 30 } | The person's balance goes up by the amount, once per event; with expiresInDays what is left ends that many days later |
| Relay trigger | { "kind": "credits_low", "below": 20 } | Runs once each time a person's spendable credits fall from 20 or more to under 20 (a spend, a clawback, an expiry), never on a rise; the person is the event's person; {{event.balance}} in templates |
Numbers
- A person holds at most 1,000,000,000, spendable and reserved together.
- A pack grants 1 to 1,000,000. A comp is up to 100,000 per action. A relay grants 1 to 10,000. A server spend is 1 to 10,000 per call.
Codes
| Code | Status | Meaning · what to do |
|---|---|---|
credits_exhausted | 402 | The balance is below the spend. The message carries the balance: "this person has {balance} credits — the spend needs {amount}". Show the pack; never retry the same spend |
session_required | 401 | g.credits.balance() without a signed-in person; sign in first |
capability_required | 403 | spendCredits() on a key without "Spend a person's credits"; create one with it ticked |
person_not_found | 404 | No person with this id in this app and environment; ids come from verifySession() or the dashboard |
invalid_amount | 400 | amount outside 1 to 10,000, or not a whole number |
invalid_reason | 400 | reason missing, not text, or over 200 characters |
invalid_key | 400 | key not text, empty, or over 200 characters |
dedupe_conflict | 409 | The dedupe key already names a different movement, another kind or another person; a dedupe key is one movement, reused only to retry that same one |
credits_ceiling | 409 | A credit that would carry what the person holds past 1,000,000,000; nothing was added |
What happens if…
| If… | What happens | What you do |
|---|---|---|
| A spend is more than the balance | Refused whole with 402 credits_exhausted, carrying the balance. Nothing moves | Show the pack. Don't retry the same spend |
| The same spend is sent twice with the same key | It is spent once. The repeat answers deduped: true with the first event | Nothing |
| Many spends for one person arrive at once | Each is checked against the balance as it lands, so no more than the balance is ever spent | Nothing |
| One key is sent for two different people | Both are charged: a key belongs to one person | Nothing, or derive the key from the person's own job |
| A grant would take a person past 1,000,000,000 | Refused with credits_ceiling; nothing is added. A pack paid through Stripe is recorded and Stripe is not asked to send it again | For a pack, refund it in Stripe |
| A pack is refunded in full after some credits were spent | What is left of that purchase's credits is taken back, never below zero. A partial refund moves no credits | Nothing |
| Stripe sends the same pack payment twice | The credits are added once | Nothing |
| An AI tool's provider fails before its first byte | The spend is refunded: the credits go back onto the grants they came from. A grant that has ended since comes back as a new refund grant | Nothing |
| A plan period ends | With no rollover, what is left of that period's allowance ends. Carry keeps it, and carry up to n ends the oldest excess. Packs and comps are untouched | Nothing |
| Credits reach their end date | What was left ends, and Logs records whose credits, how many, and the balance before and after | Nothing |
| A run holds credits and never finishes | The hold is released back to the balance on its own | Nothing |
A person's balance falls below a credits_low line | The relay runs once for that crossing. A top-up and a second fall runs it again | Nothing |
| Your server spends with a key that lacks "Spend a person's credits" | Refused with 403 capability_required, quoting the checkbox | Create a secret key with it ticked |
Local development
gemmein dev keeps the same ledger on your machine, with snapshot and
restore. Its pay simulator applies each product's "Grants credits" from
gemmein/payments.json (gemmein payments setup asks for it). You can
buy a pack and spend from it before a payment provider is connected. The local secret key
holds "Spend a person's credits".
Thanks for telling us.
Thanks for telling us.
Last updated