Credits
A balance your customers hold and your product spends. A pack they buy, a comp you give, a call that costs one. Credits are a quantity beside access, and Gemmein keeps the ledger: every addition, every spend, every refund, and a floor of zero.
What it is. A number on each customer, kept by Gemmein. It goes up when they buy a pack, when a relay grants some, or when you comp them from the dashboard. It goes down when your server spends with a reason, or when the AI route makes a call on their behalf. Every movement is one line on the person's ledger, and the balance never goes below zero.
What it does. A product grants credits at purchase: set "Grants credits" on the product and each confirmed purchase adds that many. Your server spends them, by person id, with a reason, and with a key so a retry never spends twice. The AI route spends the credits the owner set for the named tool, one by default. A relay grants them. The dashboard shows the balance and the ledger on the person's page, and lets you comp by hand.
What it does not do. Credits do not expire. The balance does not go negative: a spend larger than the balance is refused whole, never partly. Credits are not priced by token; an AI tool call costs whatever the owner priced that tool at, not its length. Credits do not 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. Nothing spends from the browser; your server, a relay or the AI route moves credits. There are no seats and no quotas.
When you need something else. Access that switches on with a plan is an entitlement: see Payments. A bill after the fact, by token or by minute, is not something Gemmein does; sell packs up front. A spend that must happen in the browser has no home; put it behind your server or the AI route.
The worked case: a 100-credit pack, 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 buyer'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 then costs the credits set on the AI tool through the AI route — 20, in this case — which spends before it forwards. For a spend of your own, an export or a render, your server spends by person id with a reason and a 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"
The key needs "Spend a person's credits" ticked when it is minted. A repeat with the
same key answers deduped: true, spent: 0 and the same event, and
moves nothing; the key is scoped to the person and namespaced, so one order id reused for
two people charges both, and rotating the secret key never re-charges. holdings.credits
is { balance } on verifySession() and holdings(), so
one verify answers who the person is, what they hold, and how many credits they have.
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 row granted, at most what is still unspent, whatever the product says today. Partial refunds move nothing | refund_clawback, actor system |
| The dashboard | "Grant credits" on the person's page, up to 100,000 per action, with a note | grant, your note, attributed to you |
| A relay | grant_credits { amount (1 to 10,000), reason? }, once per event | grant, "relay: <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 |
| The AI route | The credits the owner 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 on the cloud rail. Purchases, grants and clawbacks are kept. The person's page lists the newest 50 non-spend lines. An account erasure removes the person's balance and ledger with everything else.
Routes and verbs
| Surface | Call | Answer |
|---|---|---|
| Browser, signed in | g.credits.balance() (GET /auth/credits) | { balance } |
| 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 key answers spent: 0 and the same event |
| Server, secret key | verifySession(token) and holdings(personId) | holdings.credits = { balance } |
| Relay | { "type": "grant_credits", "amount": 10, "reason": "…" } | The person's balance goes up by the amount, once per event |
Numbers
- No expiry. Floor zero. A refund claws back at most what is still unspent. A balance carries at most 1,000,000,000.
- 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.
- The AI route spends the credits the owner set for that tool; one by default.
- Spend lines are kept 90 days; purchases, grants and clawbacks are kept.
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"; mint 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 key already names a different movement, another kind or another person; a key is one movement, reused only to retry that same one |
credits_ceiling | 409 | A credit that would carry the balance past 1,000,000,000; nothing was added |
Both rails
gemmein dev keeps the same ledger on your machine, with snapshot and
restore, and its pay simulator honours the "Grants credits" a product declares in
gemmein/payments.json (gemmein payments setup asks for it), so
buying a pack and spending from it runs end to end before a payment provider is connected.
The local secret key holds "Spend a person's credits".