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.
| Swift | Signature | Returns |
|---|---|---|
Gemmein(appKey:…) | init(appKey: String, apiURL: URL = defaultAPIURL, | 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, | CollectionClient. g.storage.collection(_:intent:) is the same factory, the same signature |
GemmeinServer(secretKey:…) | init(secretKey: String, apiURL: URL = defaultAPIURL, | 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 })
| Export | Signature | What it is |
|---|---|---|
createExpoGemmein | (options: GemmeinOptions, | 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 |
SecureStoreTokenStore | new SecureStoreTokenStore( | 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) | 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) | 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) | The platform string on x-client-info: expo-ios, expo-android, expo-web, or expo when no Platform was passed |
expoUploadPart | (file: 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_LIMIT | 2048 | The 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, | 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
| Method | Signature | Returns |
|---|---|---|
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.
| Swift | Signature | Returns |
|---|---|---|
sendEmailCode(_:) | func sendEmailCode(_ email: String) async throws | Void |
verifyEmailCode(email:code:) | func verifyEmailCode(email: String, | AuthSession { token: String, expiresAt: String, |
currentUser() | func currentUser() async throws -> CurrentUser | CurrentUser { authenticated: Bool, userId: String?,. One struct with optionals where JS has a union: read authenticated first |
logout() | func logout() async throws | Void. 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
| Method | Signature | Returns |
|---|---|---|
delete | () | Promise<unknown>. Erases the signed-in user, clears the stored token |
Swift: g.account
| Swift | Signature | Returns |
|---|---|---|
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)
| Method | Signature | Returns |
|---|---|---|
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.
| Swift | Signature | Returns |
|---|---|---|
create(_:key:for:published:) | @discardableResult func create(_ data: [String: JSONValue], | GemmeinRecord |
list(_:) | func list(_ options: ListOptions = ListOptions()) async throws -> ListResult | ListResult { records: [GemmeinRecord], cursor: String?, |
count(where:search:) | func count(where filter: [String: JSONValue]? = nil, | Int |
stats(_:where:search:) | func stats(_ field: String, | RecordStats { count: Int, sum: Double?, avg: Double?, |
watch(every:where:search:limit:onChange:) | func watch(every: TimeInterval? = nil, | Watcher. Not async; watcher.stop() ends it. WatchDelta { records, deleted, initial } |
get(_:expand:) | func get(_ id: String, | GemmeinRecord |
update(_:_:ifVersion:published:) | @discardableResult func update(_ id: String, | GemmeinRecord |
delete(_:) | func delete(_ id: String) async throws | Void |
upload(_:name:contentType:for:) | func upload(_ data: Data, name: String = "upload", | UploadedFile { id: String, ref: String,. ref is file:<uuid> |
g.files.link(_:intent:) | func link(_ ref: String, | FileLink { ref: String, url: String, expiresAt: 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
| Method | Signature | Returns |
|---|---|---|
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).
| Swift | Signature | Returns |
|---|---|---|
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 -> CheckoutSession | CheckoutSession { url: String, plan: String } |
g.payments.buy(_:item:) | func buy(_ product: String, | PaymentSession { url: String, product: 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
| Method | Signature | Returns |
|---|---|---|
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
| Swift | Signature | Returns |
|---|---|---|
g.credits.balance() | func balance() async throws -> Int | Int — the balance itself, where JS answers { balance }. Read it directly |
spendCredits(_:amount:reason:key:) | @discardableResult func spendCredits(_ personId: String, | SpendCreditsResult { spent: Int, deduped: Bool, — two flat Ints where JS nests balance: { before, after }. On GemmeinServer |
AI: g.ai
| Method | Signature | Returns |
|---|---|---|
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)]).
| Swift | Signature | Returns |
|---|---|---|
run(_:inputs:stream:) | func run(_ tool: String, | AiResponse. stream is a plain Bool argument, not an options object |
runText(_:inputs:) | func runText(_ tool: String, | String. The label is inputs: |
calls(limit:before:) | func calls(limit: Int? = nil, | AiCallPage { calls: [AiCallRecord], nextCursor: String? } |
chat(_:provider:tool:) | func chat(_ body: [String: JSONValue], | AiResponse |
text(_:provider:tool:) | func text(_ body: [String: JSONValue], | 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)
| Method | Signature | Notes |
|---|---|---|
collection(name).get / .list / .update | as above | Scoped 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. source ∈ manual | 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.
| Swift | Signature | Returns |
|---|---|---|
verifySession(_:) | func verifySession(_ token: String) async throws -> PersonHoldings | PersonHoldings { person: GatePerson, holdings: Holdings } |
holdings(_:) | func holdings(_ personId: String) async throws -> PersonHoldings | PersonHoldings |
grantAccess(_:entitlement:source:expiresAt:reason:) | @discardableResult func grantAccess(_ personId: String, | GrantResult { grant: Grant, holdings: Holdings }. ManualGrantSource is .manual, .trial, .promotion, .migration |
revokeAccess(_:_:reason:) | @discardableResult func revokeAccess(_ personId: String, | GrantResult |
invitePerson(_:) | func invitePerson(_ email: String) async throws -> InviteResult | InviteResult { person: GatePerson, created: Bool } |
notify(_:subject:text:kind:key:) | @discardableResult func notify(_ personId: String, | NotifyResult { sent: Bool, deduped: Bool?, id: String?,. subject and text are required labels, not an options object; NotifyKind is .event or .account |
spendCredits(_:amount:reason:key:) | see Credits above | SpendCreditsResult |
collection(_:) | func collection(_ name: String) throws -> ServerCollectionClient | ServerCollectionClient — func get(_ id: String) async throws -> GemmeinRecord, func list(_ options: ListOptions = ListOptions()) async throws -> ListResult, @discardableResult func update(_ id: String, |
testSession(_:) | func testSession(_ email: String) async throws -> AuthSession | AuthSession. 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
| Prefix | Environment | Where it lives |
|---|---|---|
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. Runs your app; never links or syncs |
sk_cli_... | Development | The CLI key, shown on Setup. What gemmein sync and gemmein go-live act with; lives in gemmein/.data/, never in git |
sk_sync_... | Production | A 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.
| What | Limit | On breach |
|---|---|---|
| Sign-in codes sent | 3 per email · 20 per IP · per 15 min | 429 with resetAt |
| Code attempts | 3 per email · 30 per IP · per 15 min | 429 with resetAt |
| Code lifetime | 10 minutes | ask for a new one |
| Session lifetime | 30 days | auth_expired once, then re-auth |
list() page size | 25 by default · 100 maximum | 400 invalid_limit above 100. Check hasMore |
| Record writes | 60 per minute, per app per IP | 429 with resetAt |
| Record reads | 150 per minute, per app per IP | 429 with resetAt |
In-app sends (addressed, direct) | 100 per hour, per sender | 429 with resetAt |
| File upload | 25 MB · images (JPEG/PNG/WebP/GIF/HEIC) and documents (PDF/ZIP/EPUB) | 413 file_too_large |
where filter | 5 fields, scalar values | invalid_filter |
| AI calls | 20 per person per minute · the credits set on the tool it names, one for an unnamed call | 429 ai_capped with resetAt · 402 credits_exhausted |
AI run inputs (g.ai.run) | 64 KB | 413 payload_too_large |
AI raw request body (g.ai.chat) | 256 KB, nested at most 32 levels | 413 payload_too_large · 400 invalid_body |
| AI call time | 170 s in all; a stream has 10 s to its first response headers | 502 provider_unreachable, the credit refunded |
| Server spend | 1 to 10,000 credits per call | 400 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.