reference

SDK reference

The complete surface of @gemmein/sdk: every method, signature, and return shape — with the GemmeinSwift signature beside each one, and everything @gemmein/sdk/expo adds. This exact reference also ships inside the package as REFERENCE.md.

Setup

import { gemmein, gemmeinServer, GemmeinError } from "@gemmein/sdk"

const g   = gemmein("pk_...")          // browser: public key, domain-locked, safe to ship
const srv = gemmeinServer("sk_...")    // server only — never in the browser (the SDK throws)

gemmein() accepts options { apiUrl?, tokenStore?, fetch?, visibility?, platform? }; gemmeinServer() takes { apiUrl? } only. gemmein() throws missing_app_key/invalid_app_key if the key is absent or an sk_.

The last three are what a non-browser runtime needs and a browser never passes. fetch is used for every request this client makes, in place of globalThis.fetch. visibility ({ isHidden(): boolean; onChange(cb: () => void): () => void }) is where watch() learns the app went to the background, in place of document.visibilityState; onChange returns its own unsubscribe. platform is appended to the x-client-info header — gemmein-sdk/<version> expo-ios — cleaned and capped to the 64 characters the usage ledger stores. On Expo, createExpoGemmein from @gemmein/sdk/expo fills all four in for a phone; the Swift package fills them in for Apple platforms. See Mobile.

Swift: GemmeinSwift

The same HTTP contract and the same method names, in Swift idiom, for iOS 17+ and macOS 14+, with no dependencies: .package(url: "https://github.com/gemmeinhq/gemmein-swift.git", from: "0.12.0").

import GemmeinSwift

let g = try Gemmein(appKey: "pk_live_...")   // KeychainTokenStore by default

try await g.auth.sendEmailCode(email)
_ = try await g.auth.verifyEmailCode(email: email, code: code)
let page = try await g.collection("notes").list(ListOptions(limit: 20))
let me = try await g.auth.currentUser()    // .storeAccountToken for RevenueCat

Licences: the SDK, the MCP server and the gemmein launcher are MIT. The engine the launcher downloads is proprietary — run and cache it freely for building against Gemmein; no redistribution. Full text at downloads.gemmein.com/engine/LICENSE.

init(appKey:apiURL:tokenStore:platform:session:). apiURL points at a local gemmein dev engine while you build; tokenStore defaults to KeychainTokenStore, and MemoryTokenStore is there for tests; platform defaults to this OS and is appended to x-client-info. An sk_ key is refused here, as in the browser — GemmeinServer ships in the package for Swift that runs on a server. The clients are the same names as the browser's: g.auth, g.collection(_:), g.files, g.subscriptions, g.payments, g.purchases, g.account, g.credits, g.ai, and GemmeinServer. Everything it throws is a GemmeinError carrying the server's own code and sentence, plus resetAt and requires.

SwiftSignatureReturns
Gemmein(appKey:…)init(appKey: String, apiURL: URL = defaultAPIURL,
tokenStore: TokenStore? = nil,
platform: String? = nil,
session: URLSession = .shared) throws
Gemmein. Throws invalid_app_key on an sk_, missing_app_key on anything that is not a pk_
g.collection(_:intent:)func collection(_ name: String,
intent: String? = nil) throws -> CollectionClient
CollectionClient. g.storage.collection(_:intent:) is the same factory, the same signature
GemmeinServer(secretKey:…)init(secretKey: String, apiURL: URL = defaultAPIURL,
session: URLSession = .shared) throws
GemmeinServer. See Server below

Three shapes differ from JavaScript, and they are the three a first build trips on: g.credits.balance() answers a bare Int, not a { balance } wrapper; g.files.link(_:) answers a FileLink whose url is a String; and a record or AI body is [String: JSONValue], so a variable needs its case (["title": .string(typed)]) where a literal needs nothing. Each method's Swift signature sits beside its JavaScript twin in the sections below.

TokenStore is a protocol — func get() async -> String?, func set(_ token: String) async throws, func clear() async. KeychainTokenStore(appKey:) is the default and MemoryTokenStore(token:) is there for tests and for the app that catches secure_store_unavailable. Only set throws.

Expo: @gemmein/sdk/expo

The same package, a second entry point. It re-exports the whole core surface, so an Expo app imports from one place; the table is everything the entry adds. The peers install in the app, not in your monorepo: npx expo install expo-secure-store expo-file-system. Nothing is imported from Expo or React Native at module load, so the entry resolves in Node too. See Mobile.

import { createExpoGemmein } from "@gemmein/sdk/expo"
import { AppState, Platform } from "react-native"

export const g = createExpoGemmein({ appKey: "pk_live_..." }, { AppState, Platform })
ExportSignatureWhat it is
createExpoGemmein(options: GemmeinOptions,
modules?: ExpoModules) => Gemmein
The client. It is Gemmein — the four seams a phone has, filled in, plus the picker conversion in front of every upload(). Anything you pass in options wins over the defaults
SecureStoreTokenStorenew SecureStoreTokenStore(
appKey: string,
secureStore?: SecureStoreModule)
The TokenStore backed by expo-secure-store: get(), set(token), clear(). Keyed per app key, WHEN_UNLOCKED_THIS_DEVICE_ONLY, so the token never rides an iCloud backup. Reads and clears degrade to “signed out”; a refused write throws secure_store_unavailable
appStateVisibility(appState?: AppStateModule)
=> VisibilityHook
The visibility hook watch() sleeps on, answered by React Native's AppState instead of document.visibilityState. Only "active" counts as visible; before the module resolves the app is treated as visible
expoFetch(injected?: typeof fetch)
=> typeof fetch
expo/fetch, resolved on the first request, globalThis.fetch if it is not there. React Native's own fetch is XHR-backed and cannot give a streaming body — which is what g.ai.chat({ stream: true }) and g.ai.run(…, { stream: true }) return
expoPlatformTag(platform?: PlatformModule)
=> string
The platform string on x-client-info: expo-ios, expo-android, expo-web, or expo when no Platform was passed
expoUploadPart(file: UploadInput,
modules?: ExpoModules)
=> Promise<UploadInput>
A picker's { uri, name, type, size } turned into an expo-file-system File, because Expo's FormData refuses a bare picker part. Anything already carrying bytes passes through. Without expo-file-system it throws upload_input_unsupported
SECURE_STORE_VALUE_LIMIT2048The byte ceiling on a secure-store value. Over it, set throws token_too_large rather than letting iOS throw a native error the app cannot read
ExpoModules{ SecureStore?: SecureStoreModule,
AppState?: AppStateModule,
Platform?: PlatformModule,
FileSystem?: FileSystemModule,
fetch?: typeof fetch }
The second argument. Every key optional: pass none and the entry resolves each module lazily on the first call that needs it; pass your own — an app's static imports, a test's fakes — and there is no dynamic resolution at all

SecureStoreModule, AppStateModule, PlatformModule and FileSystemModule are the exported types of those four slices — the little of each module this entry calls, and nothing more.

Auth: g.auth

MethodSignatureReturns
sendEmailCode(email)Promise<void>. Emails a sign-in code
verifyEmailCode({ email, code })Promise<AuthSession>
currentUser()Promise<CurrentUser>. Never throws for session state
logout()Promise<void>. Revokes the server session

Swift: g.auth

The same four methods, with Swift's argument labels. Everything that leaves the device is async throws and throws GemmeinError.

SwiftSignatureReturns
sendEmailCode(_:)func sendEmailCode(_ email: String) async throwsVoid
verifyEmailCode(email:code:)func verifyEmailCode(email: String,
code: String) async throws -> AuthSession
AuthSession { token: String, expiresAt: String,
user: AuthUser }
currentUser()func currentUser() async throws -> CurrentUserCurrentUser { authenticated: Bool, userId: String?,
email: String?, storeAccountToken: String? }
. One struct with optionals where JS has a union: read authenticated first
logout()func logout() async throwsVoid. Idempotent
type AuthSession = { token, expiresAt, user: { id, email } }
type CurrentUser = { authenticated: true, userId, email, storeAccountToken? }   // note: userId, NOT id
                 | { authenticated: false }

storeAccountToken is the opaque per-person name to hand a store — RevenueCat's app user id, Apple's appAccountToken, Google's obfuscatedExternalAccountId. Minted on the first currentUser() call, stable for the life of the person, correlated to nothing, and gone when they are erased. Hand a store that, never userId and never the address. See Mobile.

authenticated: false does not say why. Signed out, suspended by the owner, and erased all read the same, so a moderated user's state never leaks to the client. One signed-out screen covers all three.

Account: g.account

MethodSignatureReturns
delete()Promise<unknown>. Erases the signed-in user, clears the stored token

Swift: g.account

SwiftSignatureReturns
delete()@discardableResult func delete() async throws -> JSONValue?JSONValue?. The server's body, where JS has unknown; @discardableResult, so ignoring it is legal

The “delete my account” screen. Every app it applies to needs one (GDPR right to erasure; Apple 5.1.1(v) for apps with account creation). Server-side it's the full cascade: sessions revoked, records and files deleted, subscription row removed. Irreversible: put a real confirm in front of it.

Data: g.collection<T>(name)

MethodSignatureReturns
create(data, { key?, for?, published? }?)Promise<GemmeinRecord>
list({ limit?, sort?, where?, cursor?, search?, expand?, since? }?)Promise<ListResult>: since (the previous answer's watermark) returns only what changed or was deleted after it, oldest first; incompatible with sort
count({ where?, search? }?)Promise<number>: how many records this person could list, under the same rule, scope and filters
stats(field, { where?, search? }?)Promise<RecordStats>: { count, sum, avg, min, max } over the records whose field is a number; the four are null when none is
watch(onChange, { every?, where?, search?, limit? }?){ stop() }. Polls list({ since }) every 10 s (5 s floor); onChange gets { records, deleted, initial }, sleeps while the tab is hidden and resyncs on return
get(id, { expand? }?)Promise<GemmeinRecord>
update(id, data | atomicOps, { ifVersion?, published? }?)Promise<GemmeinRecord>
delete(id)Promise<void>
upload(file, { name?, contentType?, for? }?)Promise<{ id, ref, contentType, sizeBytes }>. Store ref in a record field, never a URL; for (addressed/direct only) hands the file to that one person
g.files.link(ref, { intent? }?)Promise<{ ref, url, expiresAt?, contentType, sizeBytes?, name? }>. Call when you render; links expire in minutes unless the collection is public. intent: "download" for documents

Swift: g.collection(_:)

The same methods. Three shapes differ on purpose: a record body is [String: JSONValue] (a literal needs no ceremony — ["title": "Ran", "done": false] — a variable needs its case, ["title": .string(typed)]); upload takes Data, not a Blob; and watch is not async.

SwiftSignatureReturns
create(_:key:for:published:)@discardableResult func create(_ data: [String: JSONValue],
key: String? = nil, for recipient: String? = nil,
published: Bool? = nil) async throws -> GemmeinRecord
GemmeinRecord
list(_:)func list(_ options: ListOptions = ListOptions()) async throws -> ListResultListResult { records: [GemmeinRecord], cursor: String?,
hasMore: Bool, deleted: [String], watermark: String? }
count(where:search:)func count(where filter: [String: JSONValue]? = nil,
search: String? = nil) async throws -> Int
Int
stats(_:where:search:)func stats(_ field: String,
where filter: [String: JSONValue]? = nil,
search: String? = nil) async throws -> RecordStats
RecordStats { count: Int, sum: Double?, avg: Double?,
min: Double?, max: Double? }
watch(every:where:search:limit:onChange:)func watch(every: TimeInterval? = nil,
where filter: [String: JSONValue]? = nil,
search: String? = nil, limit: Int? = nil,
onChange: @escaping @Sendable (WatchDelta) -> Void) -> Watcher
Watcher. Not async; watcher.stop() ends it. WatchDelta { records, deleted, initial }
get(_:expand:)func get(_ id: String,
expand: [String] = []) async throws -> GemmeinRecord
GemmeinRecord
update(_:_:ifVersion:published:)@discardableResult func update(_ id: String,
_ data: [String: JSONValue], ifVersion: Int? = nil,
published: Bool? = nil) async throws -> GemmeinRecord
GemmeinRecord
delete(_:)func delete(_ id: String) async throwsVoid
upload(_:name:contentType:for:)func upload(_ data: Data, name: String = "upload",
contentType: String = "",
for recipient: String? = nil) async throws -> UploadedFile
UploadedFile { id: String, ref: String,
contentType: String, sizeBytes: Int }
. ref is file:<uuid>
g.files.link(_:intent:)func link(_ ref: String,
intent: LinkIntent? = nil) async throws -> FileLink
FileLink { ref: String, url: String, expiresAt: String?,
contentType: String, sizeBytes: Int?, name: String? }
. url is a String, not a URL: pass it through URL(string:) before AsyncImage. intent is .inline or .download

ListOptions(limit:sort:where:cursor:search:expand:since:) — every argument optional, sort is ListSort (.newest, .oldest, .updated), and where keeps the label where, binding a [String: JSONValue]. Your fields live under data, read as note.data["title"]?.string; everything else on a GemmeinRecord is server-derived and read-only.

type GemmeinRecord<T> = {
  id: string
  data: T                      // YOUR fields live here — record.data.title, never record.title
  createdAt: string
  updatedAt: string
  ownerUserId: string | null   // server-set; null for app-owned (receipts, dashboard-created)
  version: number              // +1 per update; pass as { ifVersion } to guard concurrent edits
  published: boolean           // top-level; only meaningful on public rules
  key?: string                 // the create-if-absent key, when one was used
  audienceUserId?: string      // recipient on addressed/direct (server-stamped)
  expand?: Record<string, GemmeinRecord | null>   // filled by { expand: [...] }
  existing?: true              // present when a keyed create returned YOUR existing record
}

type ListResult<T> = { records: GemmeinRecord<T>[], cursor?, hasMore,
                       deleted?, watermark? }  // deleted rides `since` reads (your scope only); watermark: on a delta read, the FINAL page's; paging a full sync, adopt the FIRST page's

sort: "newest" | "oldest" | "updated". where: a literal per field is an exact match; an object of operators (eq ne gt gte lt lte in nin contains startsWith exists) narrows it, up to 5 fields and 3 operators each, all AND (the grammar). expand: up to 3 link fields, only on community/shared/direct. Atomic counters go in value position: update(id, { stock: { decrement: 1, floor: 0 } }).

A field added to a sealed shape by a promote run (npx gemmein go-live, run again, or the dashboard's Go-live page) may carry a default: records that never wrote it read that value, expand targets and where see it too, and a written value wins — writing null clears it back to the default. search still matches stored text only. See Data & the rules.

Payments

MethodSignatureReturns
g.subscriptions.mine()Promise<{ plan, status } | null>
g.subscriptions.checkout(plan?)Promise<{ url, plan }>. Navigates the browser to Stripe
g.payments.buy(product, { item? }?)Promise<{ url, product, item? }>. Navigates the browser to Stripe
g.purchases.mine()Promise<Purchase[]>. The array itself: { item, kind, amountMinor, currency, refundedMinor, status, grants, paidAt, delivery? }

Swift: g.subscriptions / g.payments / g.purchases

Nothing navigates: checkout and buy answer a url and the app opens it (openURL, SFSafariViewController).

SwiftSignatureReturns
g.subscriptions.mine()func mine() async throws -> Subscription?Subscription? { plan: String, status: String }. nil when they never paid
g.subscriptions.checkout(plan:)func checkout(plan: String? = nil) async throws -> CheckoutSessionCheckoutSession { url: String, plan: String }
g.payments.buy(_:item:)func buy(_ product: String,
item: String? = nil) async throws -> PaymentSession
PaymentSession { url: String, product: String,
item: String? }
g.purchases.mine()func mine() async throws -> [Purchase][Purchase]. item, kind, amountMinor: Int?, currency: String?, refundedMinor: Int, status, grants: [String], paidAt, delivery: Purchase.Delivery? — an enum (.gemmeinFile / .externalURL) where JS has a tagged object

Credits: g.credits

MethodSignatureReturns
g.credits.balance()Promise<{ balance }>. The signed-in person's balance now; 401 session_required without a session. The ledger, the numbers and the codes are on Credits

Swift: g.credits

SwiftSignatureReturns
g.credits.balance()func balance() async throws -> IntInt — the balance itself, where JS answers { balance }. Read it directly
spendCredits(_:amount:reason:key:)@discardableResult func spendCredits(_ personId: String,
amount: Int? = nil, reason: String,
key: String? = nil) async throws -> SpendCreditsResult
SpendCreditsResult { spent: Int, deduped: Bool,
balanceBefore: Int, balanceAfter: Int, event: Event }
— two flat Ints where JS nests balance: { before, after }. On GemmeinServer

AI: g.ai

MethodSignatureReturns
g.ai.run(tool, inputs?, { stream?, signal? }?)Promise<Response>. The tool's provider's own answer, untouched (SSE when stream). A non-2xx from the provider is returned as-is; a Gemmein refusal throws GemmeinError. inputs is a flat Record<string, string | number | boolean>. See The AI route
g.ai.runText(tool, inputs?, { signal? }?)Promise<string>. A non-stream run collected to one string, whichever provider answered; a provider's non-2xx throws provider_error
g.ai.calls({ limit?, before? }?)Promise<{ calls: AiCallRecord[], nextCursor }>. The signed-in person's own calls, newest first; nextCursor is null on the last page
g.ai.chat(body, { tool?, provider?, signal? }?)Promise<Response>. The provider's own request body in, the fetch Response out, untouched: status, headers and bytes are the provider's, 2xx or not, and a stream stays a stream. One credit per call. Gemmein's own refusals throw GemmeinError. See The AI route
g.ai.text(body, options?)Promise<string>. A non-stream answer as one string, whichever provider answered; a provider's non-2xx throws provider_error with the provider's status and message

Swift: g.ai

AiResponse stands where JS returns the fetch Response, and run / runText take the label inputs:runText("summary", inputs: ["text": .string(body)]).

SwiftSignatureReturns
run(_:inputs:stream:)func run(_ tool: String,
inputs: [String: JSONValue] = [:],
stream: Bool = false) async throws -> AiResponse
AiResponse. stream is a plain Bool argument, not an options object
runText(_:inputs:)func runText(_ tool: String,
inputs: [String: JSONValue] = [:]) async throws -> String
String. The label is inputs:
calls(limit:before:)func calls(limit: Int? = nil,
before: String? = nil) async throws -> AiCallPage
AiCallPage { calls: [AiCallRecord], nextCursor: String? }
chat(_:provider:tool:)func chat(_ body: [String: JSONValue],
provider: AiProvider? = nil,
tool: String? = nil) async throws -> AiResponse
AiResponse
text(_:provider:tool:)func text(_ body: [String: JSONValue],
provider: AiProvider? = nil,
tool: String? = nil) async throws -> String
String

AiResponse carries status: Int, headers, ok: Bool, creditsRemaining: Int?, refunded: Bool, tool: String?, isFake: Bool, func data() async throws -> Data, and two streams — lines() and events(), both AsyncThrowingStream<String, Error>. AiProvider is .openai, .anthropic, .google.

Server: gemmeinServer(sk)

MethodSignatureNotes
collection(name).get / .list / .updateas aboveScoped per collection in the dashboard. No creates, no deletes, no auth access
verifySession(token)Promise<{ ok, person: { id, email, role }, holdings }>. The browser's session token in, identity and holdings out, in ONE call per request. Needs no capability on the key (the caller already holds the person's token); never touches the session. No extension, no last-seen
holdings(personId)Promise<{ ok, person: { id, email, role, suspended }, holdings }>. For the paths with no token in hand. A suspended person is returned with suspended: true and their holdings; verifySession refuses them. Needs the key's "Look up a person's access by id" box
grantAccess(personId, { entitlement, source?, expiresAt?, reason? })Promise<{ ok, grant, holdings }>. Access by hand: a trial, a promotion, a migration. sourcemanual | trial | promotion | migration (default manual): purchases and subscriptions come from the built-in Stripe path; another provider's signed webhook grants through a relay. reason ≤ 200 chars, never edited, and it is what the owner reads in their logs. holdings is the state AFTER. Needs "Grant and revoke access"
revokeAccess(personId, grantId, { reason? }?)Promise<{ ok, grant, holdings }>. A grant ends once: the returned grant carries revokedAt, and a second call is 409 already_revoked. A key may end a payment-made grant (as the dashboard's "end this access" does). The payment itself is untouched. Same capability as granting
invitePerson(email)Promise<{ person: { id, email, role, invited, suspended }, created }>. Create a person by email before they sign in (201, created: true), or find them (200, created: false); idempotent, case-insensitive, one id. invited stays true until their first sign-in; a suspended person is returned flagged. The one server call that takes an email: POST /server/people, 500 invite calls per app per day (429 invite_capped; resetAt says when the window ends). Needs the key's "Create a person by email before they sign in" box
notify(personId, { subject, text, kind?, key? })Promise<{ sent, deduped?, recorded?, id, threadId, replyRail? }>. Email one of YOUR verified people by id, never an address. kind: "event" (default, capped 5/person/day) or "account" (security/account notices. Skips the per-person cap; the 200/app/hour cap and the owner's off-switch still apply); key makes it at-most-once. Sends land as Inbox threads. Sends only from YOUR verified sender domain, wearing that domain's news word (kind: "account" wears its account word): until one is verified on the Domains page the call is refused sender_domain_required (409) and nothing leaves. Sign-in codes are the one email Gemmein sends on your behalf before that, as <App name> (via Gemmein)
spendCredits(personId, { amount?, reason, key? })Promise<{ ok, spent, deduped, balance: { before, after }, event: { id, reason, actor } }>. One conditional decrement, floored at zero: refused whole with 402 credits_exhausted and the balance in the message. amount defaults to 1 (1 to 10,000); reason is what the owner reads on the ledger; key makes a retry answer the same event with spent: 0, scoped to the person. Needs "Spend a person's credits". See Credits
testSession(email)Promise<{ token, expiresAt, user }>. Dev environments only; sk_live throws test_session_forbidden_live. For the reaffirm harness.

Swift: GemmeinServer

GemmeinServer ships in the package for Swift that runs on a server. It takes a secret key, and a secret key never belongs in an app bundle — Gemmein refuses one. There is no ok field on these answers: a refusal throws.

SwiftSignatureReturns
verifySession(_:)func verifySession(_ token: String) async throws -> PersonHoldingsPersonHoldings { person: GatePerson, holdings: Holdings }
holdings(_:)func holdings(_ personId: String) async throws -> PersonHoldingsPersonHoldings
grantAccess(_:entitlement:source:expiresAt:reason:)@discardableResult func grantAccess(_ personId: String,
entitlement: String, source: ManualGrantSource? = nil,
expiresAt: String? = nil,
reason: String? = nil) async throws -> GrantResult
GrantResult { grant: Grant, holdings: Holdings }. ManualGrantSource is .manual, .trial, .promotion, .migration
revokeAccess(_:_:reason:)@discardableResult func revokeAccess(_ personId: String,
grantId: String,
reason: String? = nil) async throws -> GrantResult
GrantResult
invitePerson(_:)func invitePerson(_ email: String) async throws -> InviteResultInviteResult { person: GatePerson, created: Bool }
notify(_:subject:text:kind:key:)@discardableResult func notify(_ personId: String,
subject: String, text: String,
kind: NotifyKind? = nil,
key: String? = nil) async throws -> NotifyResult
NotifyResult { sent: Bool, deduped: Bool?, id: String?,
threadId: String?, replyRail: Bool?, recorded: Bool? }
. subject and text are required labels, not an options object; NotifyKind is .event or .account
spendCredits(_:amount:reason:key:)see Credits aboveSpendCreditsResult
collection(_:)func collection(_ name: String) throws -> ServerCollectionClientServerCollectionClientfunc get(_ id: String) async throws -> GemmeinRecord, func list(_ options: ListOptions = ListOptions()) async throws -> ListResult, @discardableResult func update(_ id: String,
_ data: [String: JSONValue]) async throws -> GemmeinRecord
testSession(_:)func testSession(_ email: String) async throws -> AuthSessionAuthSession. Development only

GatePerson { id: String, email: String, role: String, suspended: Bool?, invited: Bool? }; Holdings { access: [String], grants: [Grant], credits: Int? } — a bare Int? where JS nests { balance }.

// Gemmein hosts no compute. YOUR function — Vercel, a VPS, a cron box — asks the
// only three questions it has: who is this person, what do they hold, change it.
const { person, holdings } = await g.verifySession(token)
if (!holdings.access.includes("access:pro")) return deny()

type Holdings = {
  access: string[]                // the keys they hold NOW — ["access:pro"]
  grants: Grant[]                 // the LIVE grants behind them (revoked/expired are gone)
  credits: { balance: number } | null   // the balance now (engine 0.8.0+); null from an older local engine — see /credits
}

type Grant = {
  id: string
  entitlement: string           // "access:<slug>" — the plan's or product's own key
  source: "subscription" | "purchase" | "manual" | "trial" | "promotion" | "migration"
  startsAt: string
  expiresAt: string | null
  revokedAt?: string | null       // present on the grant revokeAccess returns
}

Holdings, not billing. The gate answers what a person holds, never what they pay: no subscription status, no amounts, no Stripe ids, and a grant's source kind only, never its sourceId. Gate on what they hold. A person id, never an email address. Looking someone up by id and granting are new power, so each sits behind a plain-English checkbox the owner ticks when minting the key; existing keys have both off, so nothing in production changes. Every /server/* call a resolved secret key makes, allowed or refused, lands in that key's usage ledger: the Secret keys page carries the summary (last used · 7-day calls · refusals), and the full day × route × outcome table lives in the key's own room. A refusal from a publishable pk_ key can't be attributed to a secret key, so it reaches only the request log. Grants and revokes write a full before→after row to the logs, attributed to the key by name. Refusals: session_invalid, session_expired, session_revoked, person_suspended, person_not_found, capability_required, invalid_source, invalid_entitlement, unknown_plan, grant_not_found, already_revoked, invalid_id and invalid_body, each with its action in the error table.

Keys & environments

PrefixEnvironmentWhere it lives
pk_test_...DevelopmentFrontend code. Safe to expose
pk_live_...ProductionFrontend code. Safe to expose
sk_dev_... / sk_live_...Dev / ProdServer env vars only. Runs your app; never links or syncs
sk_cli_...DevelopmentThe CLI key, shown on Setup. What gemmein sync and gemmein go-live act with; lives in gemmein/.data/, never in git
sk_sync_...ProductionA one-hour sync key (Secret keys → production). gemmein sync --live carries relays and AI tools into production; pasted, used, never saved

Environments are fully isolated: different data, different users, different collections. Errors: see the full code table. Management (collections, domains, keys, payments config, logs) is dashboard-only and excluded from the SDK, so management credentials can never leak from app code.

Register your domain before you deploy. Requests are checked against the app's allowed origins, and before go-live the only allowed origin is localhost, so the first push to a preview URL fails every call with 403 origin_not_allowed, even though the same code worked locally a minute earlier. Add it first, on the dashboard's Domains page. The rule is stateful: a deployment that works before go-live can break after it if its domain was never registered. This is a browser rule: a native mobile app sends no Origin header, so it is never checked against a domain, before or after go-live.

The numbers

Everything the server enforces, in one place. Nothing here is a soft target: every limit below is enforced and returns the listed error code.

WhatLimitOn breach
Sign-in codes sent3 per email · 20 per IP · per 15 min429 with resetAt
Code attempts3 per email · 30 per IP · per 15 min429 with resetAt
Code lifetime10 minutesask for a new one
Session lifetime30 daysauth_expired once, then re-auth
list() page size25 by default · 100 maximum400 invalid_limit above 100. Check hasMore
Record writes60 per minute, per app per IP429 with resetAt
Record reads150 per minute, per app per IP429 with resetAt
In-app sends (addressed, direct)100 per hour, per sender429 with resetAt
File upload25 MB · images (JPEG/PNG/WebP/GIF/HEIC) and documents (PDF/ZIP/EPUB)413 file_too_large
where filter5 fields, scalar valuesinvalid_filter
AI calls20 per person per minute · the credits set on the tool it names, one for an unnamed call429 ai_capped with resetAt · 402 credits_exhausted
AI run inputs (g.ai.run)64 KB413 payload_too_large
AI raw request body (g.ai.chat)256 KB, nested at most 32 levels413 payload_too_large · 400 invalid_body
AI call time170 s in all; a stream has 10 s to its first response headers502 provider_unreachable, the credit refunded
Server spend1 to 10,000 credits per call400 invalid_amount

The most common surprise: list() is a page, not the set. No limit means 25 rows rather than all of them, and a limit above 100 is refused with invalid_limit (“limit must be between 1 and 100”). A list that looks short is usually pagination rather than missing data. Always read hasMore and page with cursor.