guide

Errors

Every failed call throws a GemmeinError with a stable code to branch on and a message that reads correctly for users.

import { GemmeinError } from "@gemmein/sdk"

try {
  await g.collection("tasks").create({ title: "Test" })
} catch (err) {
  if (err instanceof GemmeinError) {
    err.code      // branch on this
    err.status    // HTTP status
    err.message   // human-readable — render it; it includes what to do next
    err.resetAt   // on 429s — when to retry (absent on storage-space 429s)
  }
}

forbidden vs denied

forbidden vs denied. forbidden (403) means the rules refused the request. The same call will always be refused, so change the approach. denied is retriable or fixable: a rate limit (429, so wait for resetAt) or a missing sign-in (401, so sign in). Distinguish by HTTP status. Rate-limit 429s carry resetAt; the storage-space 429 (usage_limit_exceeded on upload) carries none, because that limit is on stored bytes rather than time, and deleting files lifts it. Counter usage is never refused: exceeding the usage band notifies the owner and is billed the next month.

entitlement_required is the exception. It is the one 403 that can succeed later: the customer is signed in and simply doesn't hold the plan or product this collection is unlocked by. It carries that plan's key on the error (err.requires, e.g. "access:pro" for a plan named pro; one key, a string, even when several plans open the collection), so your app can show an upgrade prompt instead of a dead end. You don't need to check before every read: let the call fail and handle it.

The codes

CodeStatusMeaning · what to do
missing_app_key401No app key; get one at app.gemmein.com
invalid_app_key403Key not recognized: typo, or wrong environment
auth_expired401Session expired. The SDK auto-clears the token; retry or re-auth
unknown_collection404Collection doesn't exist. The owner creates it in the dashboard; don't retry
not_found404A record you can't see. Existence is never leaked; treat it as absent
forbidden403The rules refused this. Never retry; fix the approach or show the message
protected_account403This account is protected and cannot be deleted; retrying cannot help
entitlement_required403Signed in, but not on a plan (or holding a product) this collection is unlocked by. err.requires carries that plan's key (one string). Show an upgrade path, and retry after they have it
denied429 / 401 / 500Rate limit (wait for resetAt), missing sign-in, or an unexpected failure on the server (500); could work later
conflict409A keyed create, floor/ceiling, or stale ifVersion. This is the mechanism working, so tell the user it was taken
collection_not_empty409Deleting a collection that still has active records; the records go first. This too is the mechanism working
html_not_allowed400Community/addressed/direct store plain text; remove HTML tags
missing_text400A send with no text; send something
text_too_long400The text exceeds the maximum length; shorten it
invalid_recipient400A support reply refused: the recipient is not a single plain address
invalid_publish400published is an option on public rules only, and never a data field
invalid_shape400A field the live (sealed) collection never learned. A new field reaches live through a promote run (one owner answer per field), so until then send only the ones it has. This also covers a file field given the wrong kind (a document where the field takes an image, or the reverse), and the message names which
unknown_file400A ref-shaped value points at a file that doesn't exist or isn't yours to hand. This is enforced on every rule, for creates and updates, from app code and the dashboard alike. Fix the ref; don't invent one
invalid_ref400The value handed to a link door is not a file reference at all. A ref is file:<id>, the value upload() returned; pass that, never a filename or a URL
invalid_since400since must be a strict ISO 8601 timestamp (pass the previous answer's watermark), and it fixes the order; drop sort
not_a_customer404notify()'s recipient isn't a verified person of this app and environment. One code covers both cases on purpose: existence is never leaked
notify_capped429notify() hit a cap of 5/person/day (event class) or 200/app/hour. Wait for resetAt; account-class skips only the per-person cap
in_flight409A notify() with the same key is being sent right now. Retry in a moment; a delivered send answers idempotently
sender_domain_required409notify(), a relay's email_person, and the dashboard Inbox — a new conversation or a reply — send only from your own verified sender domain. Until one is verified on the Domains page the send is refused: nothing left, nothing was recorded, no cap was spent, and the same key sends once you verify. Sign-in codes are the one email Gemmein sends on your behalf before that, as <App name> (via Gemmein)
invalid_audience400for isn't a user of this app; fix the recipient id
reply_only403This direct collection only allows replying to people who wrote to you first
sends_disabled403The owner turned in-app sends off; addressed sends happen in their dashboard
unknown_record400A link points at a record that doesn't exist (live only); fix the id
unknown_product404 · 400 with no nameNo product by that name (404); the message lists what the app sells. A g.payments.buy() that names no product is a 400, with the same list
plan_has_no_link409The owner hasn't pasted that plan's Payment Link yet
product_not_sellable409g.payments.buy on a product sold via a relay or not yet — there is no Payment Link to open
plan_not_sellable409g.subscriptions.checkout on a plan sold via a relay or not yet — there is no Payment Link to open
relay_missing400A relay action, or a product's or plan's "How it's sold", names a relay that doesn't exist in this environment
authentication_required401Sign in before checkout / reading a subscription
payload_too_large / file_too_large413Over the size cap. The message states it; shrink it. A record's data is at most 32 KB (files go through uploads, never inline). On g.ai.run(): the inputs are over 64 KB; send less. On g.ai.chat(): the raw body is over 256 KB; shorten the conversation you send
invalid_file_content400Uploaded bytes aren't the claimed type (usually a renamed file); send the real file, don't retry
test_session_forbidden_live403testSession() on a live environment; reaffirm's Tier B is dev-only
unknown_plan404 from checkout · 400 from grantAccess()No plan by that name. From checkout the message lists the app's real plans (404, so there is nothing to check out). From grantAccess() it is a 400: no plan or product by that name, and the owner adds it on the Payments page
plan_not_purchasable400That's the free default plan; nothing to buy
invalid_expand400expand on a field/rule with no link shape; join in memory instead
scope_denied403Secret key used outside its dashboard-configured scope. A server key asked to sign in as the account owner or an admin, or to link or sync: linking and dev sync use the CLI key (Setup), production sync a Sync key (Secret keys → production). A sync key asked for anything but relays and AI tools. On g.ai.chat(): a secret key called the AI route, which is for the browser; a server calls the provider directly
raw_calls_off403The browser may not compose provider requests for this provider (g.ai.chat). Call a named tool with g.ai.run, or switch raw calls on for the key on the AI page
invalid_inputs400On g.ai.run: an input is unknown, missing, the wrong type or over its cap; the message names it
tool_incomplete409On g.ai.run: the tool composes nothing, no prompt template and no inputs
secret_key_expired403A sync key past its hour; the message names the instant. Mint a new one on Secret keys → production and run npx gemmein sync --live again
unsupported_file_type415Upload isn't an allowed type: images (JPEG/PNG/WebP/GIF/HEIC) or documents (PDF/ZIP/EPUB)
invalid_key400A keyed create's key doesn't match the allowed characters or length (1-120 chars of letters, numbers, : _ . @ / -); on spendCredits(), the key is not text of up to 200 characters
account_suspended403The app owner's account is suspended (billing); the owner fixes payment at app.gemmein.com
origin_not_allowed403The first-deploy one. The request came from a domain this app doesn't allow. Before go-live only localhost works, so a preview URL 403s every call. The owner adds the domain in the dashboard; never retry
usage_limit_exceeded429Storage is full past its overflow room; uploads refuse until the owner deletes files or adds storage. No resetAt: the limit is on stored bytes, not time. (Counter usage is never refused: exceeding the band notifies the owner and is billed next month)
ownership_denied403The record exists but belongs to someone else; never retry
local_key403A pk_local_ key from gemmein dev was used against the cloud; local keys only work against 127.0.0.1 while dev is running
payments_not_configured404The owner hasn't turned payments on yet, so no plans or products exist. Don't retry; show a message
invalid_item400The item on a purchase breaks the allowed shape
invalid_email400Not a valid email address
invalid_code400The sign-in code is wrong or past its 10 minutes; ask for a new one
invalid_filter400where takes at most 5 fields, and values must be a string, number or boolean
invalid_search400search is over 200 characters
invalid_precondition400ifVersion must be a positive integer; pass the version you read
invalid_op400An atomic op targets a field that isn't a number in the collection's shape
invalid_body400Record data must be a plain JSON object; on g.ai.chat(), the body must be the provider's JSON request object, nested at most 32 levels
missing_params400A required field is absent or empty; the message names it (missing email). The SDK passes what you give it, so a blank form field reaches the API as missing — check it first
field_too_long400A sign-in field (email, code, the test-session fields) is over 500 characters; the message names the field
invalid_file400The file is empty
invalid_upload400The upload call is missing its key
upload_incomplete400Confirm was called before the bytes finished uploading; finish the upload first
already_confirmed409This upload was already confirmed; don't confirm twice
file_quarantined403The stored bytes failed the content check; the file is held and won't be served
simulation_failed502Development only: the local payment simulator couldn't complete the round trip
invalid_secret_keyClient-side: gemmeinServer() got a missing or pk_ key; pass the sk_ from a server env var
session_invalid401verifySession(): no session matches this token; send the person to sign in again. Never store or reuse a token across people
session_expired401verifySession(): the session ended (the message names when); send the person back to sign-in
session_revoked401verifySession(): a newer sign-in, a sign-out, or the owner ended this session; send the person back to sign-in
person_suspended403The owner suspended this person; access stays off until the owner reactivates them in the dashboard. holdings() still answers, flagged suspended: true
person_not_found404No person with this id in this app and environment; ids come from verifySession() or the dashboard, never from an email. Existence is never leaked
capability_required403The secret key doesn't carry this power; the owner mints a key with "Look up a person's access by id", "Grant and revoke access", "Create a person by email before they sign in" or "Spend a person's credits" ticked. Purchases still come only from Stripe
already_revoked409A grant ends once, and revokedAt is set and never edited. It is already ended; don't retry
grant_not_found404Not this person's grant, in this app and environment; re-read holdings() for the live grant ids
invalid_source400A purchase or subscription was asked for by hand, and that is refused. Access that money made comes only from Stripe; a key grants manual, trial, promotion or migration
invalid_entitlement400Not a valid access:<slug> key; fix the slug, or drop the key entirely and pass the plan's or product's own name, which grantAccess() resolves for you
invalid_id400A prototype name (__proto__, constructor) was sent where a person id or a grant id belongs. Ids come from verifySession() or the dashboard, never from a name you built
unknown_route404Not one of the gate's routes, and the message lists them. Call the server client's methods rather than building the path by hand
method_not_allowed405The right route, the wrong verb: verifySession(), grantAccess(), revokeAccess(), invitePerson(), notify() and spendCredits() are POST, holdings() is GET
unknown_receiver404A relay receiver: no receiver with this name on this app. A deleted relay no longer receives; a paused one still does
bad_signature401Receiver: the signature did not verify against this receiver's secret. Check the secret the provider signs with and the header the scheme expects
body_too_large413Receiver: the body is over 256 KB
invalid_json400Receiver: the body is not JSON
receiver_capped429Receiver: more than 120 verified events in a minute for this app; resetAt says when. Unverified traffic never spends this budget
source_capped429Receiver: more than 60 unverified calls in a minute from one address. Verified events count against the app, unverified ones against the address they came from; resetAt says when
invite_capped429invitePerson(): 500 invite calls per app per day, a fetch of an existing person counting too; a receiver's person_email shares the cap and counts only a person it creates. The limit is temporary; write to hello@gemmein.com to raise it. resetAt says when the window ends
not_recorded503Receiver: the event could not be stored, so nothing ran. The provider sends it again
invalid_definition400A relay file has a problem; the message is one sentence naming the field, why, and what to do
relay_capped400The environment holds 20 relays. Delete one, or fold two into one; limits are raised on request
name_taken409A relay with this name exists in this environment
version_conflict409The relay's definition moved since it was read; re-read and reapply
not_replayable409Replay applies to a dead, failed or done event; a queued or running one is already on its way
unknown_tool404No AI tool by that name in this environment. See The AI route
tool_disabled403The owner switched this AI tool off
entitlement_required403An AI tool's requires names a plan or product this person lacks; the message names it
model_pinned403This AI tool's model is fixed; leave model out of the body
too_many_tools409An environment holds at most 50 AI tools
invalid_tool400Creating or updating an AI tool with a bad field; the message names it
credits_exhausted402The person's 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. See Credits
ai_not_configured409No provider key on this app and environment; the owner pastes one on the AI page. See The AI route
provider_required400More than one provider key is set and the call named none; pass provider
model_not_allowed403The owner's allowlist does not name this model; the message lists the ones it does. Only applies to a tool with no pinned model
ai_capped42920 AI calls per person per minute; wait for resetAt
session_required401g.ai.chat() or g.credits.balance() without a signed-in person; sign in first
provider_unreachable502The provider did not answer before the first byte; nothing was charged and the credit is refunded. Retry
provider_errorthe provider'sThrown by g.ai.text() and g.ai.runText(): the provider's own non-2xx, its message in err.message; g.ai.chat() and g.ai.run() return it as it came. See The AI route
invalid_response0 (client-side)The SDK's own, with no HTTP status: a 200 from verifyEmailCode() that carried no session token, or a provider answer with no text where g.ai.text() or g.ai.runText() expected one (a streaming body: read the stream with g.ai.chat() or g.ai.run())
network_unreachable0 (client-side)The request never reached Gemmein: no connection, an offline device, a host that does not resolve, an apiUrl pointing at nothing. The message names the host and err.cause carries the transport's own error. Check the connection and the apiUrl; the call is safe to retry. A call cancelled with { signal } is not this — it stays an AbortError
upload_input_unsupported0 (client-side)@gemmein/sdk/expo: upload() was handed a picker's { uri } and expo-file-system is not installed, so there is nothing to read the bytes with. Install it (npx expo install expo-file-system), or pass a Blob or an expo-file-system File
token_too_large0 (client-side)@gemmein/sdk/expo: a value over 2,048 bytes was handed to the device secure store, which iOS refuses with a native throw the app cannot read. Store the token, not the session payload — a Gemmein session token is two orders of magnitude smaller. See Mobile
secure_store_unavailable0 (client-side)The token store could not keep the session, so sign-in throws rather than handing back a session the next launch will lose. All three of the SDK's stores raise it. BrowserTokenStore (@gemmein/sdk): the browser refused the localStorage write — private mode past its quota, site data blocked, a sandboxed iframe. @gemmein/sdk/expo: expo-secure-store is not installed, or the device refused the write. Swift KeychainTokenStore: the Keychain refused, and the OSStatus is in the message; -34018 is an unsigned build with no keychain access group, so sign the app (ad-hoc is enough). The store's own error is on err.cause (in Swift, in the message). Reads and clears stay lenient on all three — an unreadable store means signed out, never a crash. The session is real, so an app that would rather run than stop catches this one code and rebuilds its client with a MemoryTokenStore. See Mobile
invalid_collection_name0 (client-side)g.collection(name) was given a name outside the grammar: lowercase letters, digits and underscores, starting with a letter, 2 to 63 characters (tasks, user_notes). Thrown synchronously, before any request exists, and the message echoes what it was given. A name that is legal but has no collection behind it is a different code — unknown_collection, 404
invalid_amount400spendCredits(): amount outside 1 to 10,000, or not a whole number
invalid_reason400spendCredits(): reason missing, not text, or over 200 characters
dedupe_conflict409spendCredits(): 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_ceiling409A credit that would carry the balance past 1,000,000,000; nothing was added. See Credits
ai_test_capped429The AI page's test call; one a minute per app