@gemmein/sdk
The secure backend for AI-built apps: passwordless login, safe data storage, and Stripe-driven payment flips — with the unsafe paths removed by design. No SQL, no security-rules language, no server to configure.
Step 0 — get your app key
Every call needs an app key (pk_...). Get one in 30 seconds: sign in at app.gemmein.com with an email code (free, no card), and copy the key from the Setup page. The Setup page also gives you a ready-made prompt that teaches this whole SDK to your AI tool.
Install
npm i @gemmein/sdk
No bundler? The SDK is dependency-free pure ESM — copy dist/index.js from the package next to your HTML and import { gemmein } from "./index.js" in a <script type="module">. Works on any static host.
Quick start
import { gemmein } from "@gemmein/sdk"
const g = gemmein("pk_test_...")
await g.auth.sendEmailCode("user@example.com")
await g.auth.verifyEmailCode({ email: "user@example.com", code: "12345678" })
await g.collection("tasks").create({ title: "Buy milk", done: false })
const { records } = await g.collection("tasks").list()
The client has two layers: your collections (g.collection("tasks") — your app's own data model) and the business primitives Gemmein runs for you (g.auth, g.subscriptions, g.payments, g.account — self-service surfaces for the signed-in user; managing other people's users, subscriptions, and records happens in the owner's dashboard, on purpose).
Sessions persist across page reloads automatically (localStorage in browsers, memory elsewhere — override with tokenStore if you need custom persistence). One session per user: verifying a new code revokes that email's older sessions — a stale token throws auth_expired once, the SDK clears it, and a retry (or re-auth) recovers.
Auth
await g.auth.sendEmailCode("user@example.com")
const session = await g.auth.verifyEmailCode({ email: "user@example.com", code: "12345678" })
const user = await g.auth.currentUser()
await g.auth.logout()
Deleting an account
await g.account.delete()
Self-service erasure — the "delete my account" screen. Every app it applies to needs one (GDPR right to erasure; Apple 5.1.1(v) for any app with account creation). Server-side it's the full cascade: sessions revoked, the user's records and files deleted, their subscription row removed. Irreversible — put a real confirm in front of it.
Storage
Collections are created by the app owner in the dashboard (app.gemmein.com → data → "+ New collection") — the SDK can't create them. A 404 unknown_collection means it doesn't exist yet: ask the app owner to add it (one click) and pick its rule from the table below.
const tasks = g.collection("tasks")
const task = await tasks.create({ title: "Buy milk", done: false })
console.log(task.id, task.data.title)
const { records } = await tasks.list()
records.forEach(r => console.log(r.data.title))
const recent = await tasks.list({ limit: 10, sort: "newest" })
const filtered = await tasks.list({ where: { done: false } })
const one = await tasks.get("rec_abc123")
await tasks.update("rec_abc123", { done: true })
await tasks.delete("rec_abc123")
const file = await tasks.upload(imageBlob, { name: "avatar.png" })
await tasks.create({ title: "Profile", avatar: file.ref })
const { url } = await g.files.link(record.avatar)
Store the reference, never the URL. A reference doesn't expire and grants
nothing on its own; link() is where authorization happens, every time. That
also means the same app code keeps working if a collection later changes from
public to private.
The bound, honestly: revoking access stops Gemmein issuing new links
immediately. A link already in someone's hands works until it expires. Nothing
can take back a file they already downloaded — this is controlled delivery, not
DRM.
Collection rules
Each collection has one rule, set in the dashboard. The server enforces it — your app never implements authorization:
private | Owner only — plus the app owner/admin, who reads everything | Owner only | User's personal data (tasks, notes, settings) |
shared | All authenticated users | Each user: own records only | Feeds, communities, team boards |
public_read | Anyone (no login needed) | App owner only | Catalogs, menus, marketing pages, single-author blogs |
community | Anyone (no login needed) | Each signed-in user: own records only — plain text | Multi-author blogs, public boards, profiles |
addressed | Each user: only records addressed to them (owner sees all) | Owner only, naming a recipient | Notifications, invoices, order status |
direct | Author + the named recipient | Each signed-in user, naming a recipient | Messages, sharing, requests |
admin_write | All authenticated users | Admin/owner only | App settings, announcements |
Owner scoping is automatic. For private collections each user only sees their own records; when the signed-in user is the app's owner or admin, reads return every user's records (build your admin screen with the same list() calls — no server needed). Don't gate admin UI on any visible role field — sessions always report member, even for the owner; elevation happens server-side per request, so render whatever list() returns. For shared collections everyone reads everything but update/delete only touch the caller's own records. Never filter by userId, never set userId/owner/role fields. The server derives them from the session and rejects reserved fields if you try to set them:
id, appId, app_id, environmentId, environment_id, userId, user_id, ownerId, ownerUserId, owner_user_id, tenantId, tenant_id, role, isAdmin, is_admin, createdAt, created_at, updatedAt, updated_at, deletedAt, deleted_at
User content is data, not markup. When one user's content renders in another user's session (community, shared), render it with text bindings — textContent, {} in React/Vue/Svelte — never innerHTML. community collections enforce this server-side: any string field containing HTML tags is refused with 400 html_not_allowed. Store plain text, or markdown written without raw tags.
Sending to people — inboxes and messages
addressed and direct records carry a recipient, named on the call you already make:
await updates.create({ text: "Your order shipped 🎉" }, { for: userId })
await messages.create({ text: "hey!" }, { for: otherUserId })
const { records } = await updates.list({ sort: "newest", limit: 20 })
The recipient is server-stamped (record.audienceUserId) — never a data field. User ids come from records' ownerUserId or the owner's admin lists. Rules of the road:
- Same message for everyone →
admin_write (one record all users read). A specific thing for a specific person → addressed (one record per recipient).
- Inboxes are poll-or-refresh:
list() on window focus plus a gentle ~60s interval — never a tight loop. Read-state ("seen") lives in the user's own private collection.
direct is messaging inside someone's app, and the app owner can read it (owner reads reach everything, under every rule) — never present it as private or encrypted chat.
- Errors teach the fix:
invalid_audience (recipient isn't a user of this app), reply_only (this collection only allows replying to people who wrote to you first — tell the user), sends_disabled (the owner turned sends off; addressed sends then happen in their dashboard).
- Both rules store plain text like
community — render other users' content as text, never innerHTML.
Contention — when users race for the same thing
A permission model can't stop two people booking the same 3pm slot. Preconditions can, and each is just an argument on a call you already make. A 409 conflict from any of them is the mechanism working, not an error to retry away — catch it and tell the user the slot/stock/edit was taken.
await bookings.create({ who: email }, { key: "slot:2026-07-15T15:00" })
await orders.create({ item: 42 }, { key: "unit:item42:1" })
await products.update(id, { stock: { decrement: 1, floor: 0 } })
await pages.update(id, { body }, { ifVersion: page.version })
An object in a patch is treated as an atomic op ONLY when its keys are exactly increment|decrement (+ optional floor|ceiling), all numbers — anything else is stored as plain data. Keys are 1-120 chars of letters, numbers, and : _ . @ / -. Never find-then-create and never compute counters client-side: both race, and both fail only when real users collide.
Payments (Stripe) — you don't build the webhook
Gemmein hosts your Stripe webhook and manages subscriptions for you: exactly one 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. The app owner names the plans and pastes one Stripe signing secret in the dashboard's Payments page — nothing to seed. Your app has exactly two jobs:
await g.subscriptions.checkout("pro")
const sub = await g.subscriptions.mine()
if (sub?.plan === "pro") { }
Do not write a webhook handler. Do not poll Stripe. Do not store plan/subscription state in your own collections — g.subscriptions.mine() is the single source of truth, and there is deliberately no client write path to it.
Plan limits (note counts, feature caps, seat numbers) are your app's logic — Gemmein only tells you who is on which plan; the dashboard's plan names carry no quotas.
Selling things (one-off purchases)
Plans are for subscriptions. To sell a thing — a poster, a beat, an ebook, a session — the owner adds products (name + Stripe Payment Link) on the same Payments page, plus a receipts collection (rule addressed). Then:
await g.payments.buy("premium license", { item: "beat_37" })
const { records } = await g.collection("receipts").list()
const paid = records.find(r => r.data.product === "premium license" && r.data.status === "paid")
if (paid) { }
Receipts carry { product, item?, status, amountTotal, currency, paidAt, deliveryUrl?, paymentRef } in .data — amountTotal is minor units exactly as Stripe reported, and paymentRef is the Stripe payment id the refund flip matches on (read-only; you rarely need it). The owner fulfils orders by editing the receipt from their dashboard (status: "shipped") — your app just reads it. Refunds happen in the owner's Stripe dashboard; if they forward charge.refunded, the receipt's status flips 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.
No carts, no quantities — one product per checkout, by design. A "cart" is N checkouts, or one bundled product the owner prices as a bundle. Don't build a cart UI that promises otherwise.
Drafts on public collections
On public_read and community collections, { published: false } saves a draft the public can't see — server-enforced, the author still sees their own, the owner sees all:
const post = await notes.create({ title: "wip" }, { published: false })
await notes.update(post.id, {}, { published: true })
Read the current state back as a top-level field — record.published (a boolean), NOT record.data.published (same place as id and updatedAt, not inside your fields). Non-authors only ever receive published records, so you'll only ever see false on your own drafts — or on everything, as the owner. That's how you render an "unreleased" badge in an owner-only admin list.
Never fake drafts with a status field + client-side filtering on a public collection — the data still reaches every reader's network tab. published is an option, not a data field; the server rejects it inside data.
Server-side (API routes, cron jobs)
For trusted server code, use gemmeinServer with a scoped secret key (sk_...) — created in the dashboard, scoped per collection:
import { gemmeinServer } from "@gemmein/sdk"
const server = gemmeinServer(process.env.GEMMEIN_SECRET_KEY)
const tasks = await server.collection("tasks").list()
await server.collection("tasks").update("rec_abc123", { done: true })
Secret keys can only get/list/update the collections you scoped them to — no creates, no deletes, no auth or management access. Never put an sk_ key in browser code (the SDK throws if you try).
Errors
Every method throws GemmeinError:
import { GemmeinError } from "@gemmein/sdk"
try {
await g.collection("tasks").create({ title: "Test" })
} catch (err) {
if (err instanceof GemmeinError) {
err.code
err.status
err.message
err.resetAt
err.requires
}
}
missing_app_key | 401 | No app key — get one at app.gemmein.com |
invalid_app_key | 403 | Key not recognized (typo, or wrong environment) |
auth_expired | 401 | Session expired — SDK auto-clears the token |
unknown_collection | 404 | Collection doesn't exist — create it in the dashboard |
scope_denied | 403 | Secret key not scoped for this collection/action |
html_not_allowed | 400 | Community collections store plain text — remove HTML tags |
invalid_file_content | 400 | Uploaded bytes aren't the image type they claimed — upload the actual image, not a renamed file |
unknown_product | 404 | No product by that name — the message lists what the app sells |
invalid_publish | 400 | published is an option on public collections only — not a data field, not for scoped rules |
forbidden | 403 | The rules refused this — a permission your user doesn't have. Never retry: the same call will always be refused. Fix the approach (wrong collection rule, non-admin writing to admin_write, secret key out of scope) or show err.message. |
entitlement_required | 403 | Signed in, but not on a plan (or holding a product) this collection is unlocked by. err.requires carries that plan's key (access:pro for a plan named pro). The one 403 that succeeds later: show your upgrade screen, send them to checkout, retry after they hold it. |
denied | 429 / 401 | The generic refusal for everything retriable or fixable: a rate limit (429 — carries resetAt, wait and retry) or a missing sign-in (401 — sign in first via g.auth.sendEmailCode). Distinguish by HTTP status; show err.message, which reads correctly for each. |
Branching on error codes: switch on the specific named codes above. The one rule that matters: forbidden means stop — retrying can never succeed; denied means the request could work later (wait for resetAt on 429, sign in on 401). Only rate-limit denied carries resetAt — that's the reliable signal for a retry-after.
Keys & environments
pk_test_... | Development | Frontend code — safe to expose |
pk_live_... | Production | Frontend code — safe to expose |
sk_dev_... / sk_live_... | Dev / Prod | Server env vars only |
Environments are fully isolated: different data, different users, different collections.
Management is dashboard-only
Collections, domains, keys, payments config, logs, and usage live in the dashboard — deliberately not in the SDK, so management credentials can never leak from app code.
Gemmein is an early release: the core above is live, security-audited, and safe to build on; the wider feature set rolls out in stages. gemmein.com · hello@gemmein.com