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
| Code | Status | Meaning · what to do |
|---|---|---|
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. The SDK auto-clears the token; retry or re-auth |
unknown_collection | 404 | Collection doesn't exist. The owner creates it in the dashboard; don't retry |
not_found | 404 | A record you can't see. Existence is never leaked; treat it as absent |
forbidden | 403 | The rules refused this. Never retry; fix the approach or show the message |
protected_account | 403 | This account is protected and cannot be deleted; retrying cannot help |
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 (one string). Show an upgrade path, and retry after they have it |
denied | 429 / 401 / 500 | Rate limit (wait for resetAt), missing sign-in, or an unexpected failure on the server (500); could work later |
conflict | 409 | A keyed create, floor/ceiling, or stale ifVersion. This is the mechanism working, so tell the user it was taken |
collection_not_empty | 409 | Deleting a collection that still has active records; the records go first. This too is the mechanism working |
html_not_allowed | 400 | Community/addressed/direct store plain text; remove HTML tags |
missing_text | 400 | A send with no text; send something |
text_too_long | 400 | The text exceeds the maximum length; shorten it |
invalid_recipient | 400 | A support reply refused: the recipient is not a single plain address |
invalid_publish | 400 | published is an option on public rules only, and never a data field |
invalid_shape | 400 | A 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_file | 400 | A 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_ref | 400 | The 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_since | 400 | since must be a strict ISO 8601 timestamp (pass the previous answer's watermark), and it fixes the order; drop sort |
not_a_customer | 404 | notify()'s recipient isn't a verified person of this app and environment. One code covers both cases on purpose: existence is never leaked |
notify_capped | 429 | notify() 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_flight | 409 | A notify() with the same key is being sent right now. Retry in a moment; a delivered send answers idempotently |
sender_domain_required | 409 | notify(), 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_audience | 400 | for isn't a user of this app; fix the recipient id |
reply_only | 403 | This direct collection only allows replying to people who wrote to you first |
sends_disabled | 403 | The owner turned in-app sends off; addressed sends happen in their dashboard |
unknown_record | 400 | A link points at a record that doesn't exist (live only); fix the id |
unknown_product | 404 · 400 with no name | No 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_link | 409 | The owner hasn't pasted that plan's Payment Link yet |
product_not_sellable | 409 | g.payments.buy on a product sold via a relay or not yet — there is no Payment Link to open |
plan_not_sellable | 409 | g.subscriptions.checkout on a plan sold via a relay or not yet — there is no Payment Link to open |
relay_missing | 400 | A relay action, or a product's or plan's "How it's sold", names a relay that doesn't exist in this environment |
authentication_required | 401 | Sign in before checkout / reading a subscription |
payload_too_large / file_too_large | 413 | Over 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_content | 400 | Uploaded bytes aren't the claimed type (usually a renamed file); send the real file, don't retry |
test_session_forbidden_live | 403 | testSession() on a live environment; reaffirm's Tier B is dev-only |
unknown_plan | 404 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_purchasable | 400 | That's the free default plan; nothing to buy |
invalid_expand | 400 | expand on a field/rule with no link shape; join in memory instead |
scope_denied | 403 | Secret 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_off | 403 | The 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_inputs | 400 | On g.ai.run: an input is unknown, missing, the wrong type or over its cap; the message names it |
tool_incomplete | 409 | On g.ai.run: the tool composes nothing, no prompt template and no inputs |
secret_key_expired | 403 | A 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_type | 415 | Upload isn't an allowed type: images (JPEG/PNG/WebP/GIF/HEIC) or documents (PDF/ZIP/EPUB) |
invalid_key | 400 | A 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_suspended | 403 | The app owner's account is suspended (billing); the owner fixes payment at app.gemmein.com |
origin_not_allowed | 403 | The 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_exceeded | 429 | Storage 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_denied | 403 | The record exists but belongs to someone else; never retry |
local_key | 403 | A 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_configured | 404 | The owner hasn't turned payments on yet, so no plans or products exist. Don't retry; show a message |
invalid_item | 400 | The item on a purchase breaks the allowed shape |
invalid_email | 400 | Not a valid email address |
invalid_code | 400 | The sign-in code is wrong or past its 10 minutes; ask for a new one |
invalid_filter | 400 | where takes at most 5 fields, and values must be a string, number or boolean |
invalid_search | 400 | search is over 200 characters |
invalid_precondition | 400 | ifVersion must be a positive integer; pass the version you read |
invalid_op | 400 | An atomic op targets a field that isn't a number in the collection's shape |
invalid_body | 400 | Record 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_params | 400 | A 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_long | 400 | A sign-in field (email, code, the test-session fields) is over 500 characters; the message names the field |
invalid_file | 400 | The file is empty |
invalid_upload | 400 | The upload call is missing its key |
upload_incomplete | 400 | Confirm was called before the bytes finished uploading; finish the upload first |
already_confirmed | 409 | This upload was already confirmed; don't confirm twice |
file_quarantined | 403 | The stored bytes failed the content check; the file is held and won't be served |
simulation_failed | 502 | Development only: the local payment simulator couldn't complete the round trip |
invalid_secret_key | — | Client-side: gemmeinServer() got a missing or pk_ key; pass the sk_ from a server env var |
session_invalid | 401 | verifySession(): no session matches this token; send the person to sign in again. Never store or reuse a token across people |
session_expired | 401 | verifySession(): the session ended (the message names when); send the person back to sign-in |
session_revoked | 401 | verifySession(): a newer sign-in, a sign-out, or the owner ended this session; send the person back to sign-in |
person_suspended | 403 | The owner suspended this person; access stays off until the owner reactivates them in the dashboard. holdings() still answers, flagged suspended: true |
person_not_found | 404 | No 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_required | 403 | The 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_revoked | 409 | A grant ends once, and revokedAt is set and never edited. It is already ended; don't retry |
grant_not_found | 404 | Not this person's grant, in this app and environment; re-read holdings() for the live grant ids |
invalid_source | 400 | A 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_entitlement | 400 | Not 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_id | 400 | A 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_route | 404 | Not 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_allowed | 405 | The right route, the wrong verb: verifySession(), grantAccess(), revokeAccess(), invitePerson(), notify() and spendCredits() are POST, holdings() is GET |
unknown_receiver | 404 | A relay receiver: no receiver with this name on this app. A deleted relay no longer receives; a paused one still does |
bad_signature | 401 | Receiver: 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_large | 413 | Receiver: the body is over 256 KB |
invalid_json | 400 | Receiver: the body is not JSON |
receiver_capped | 429 | Receiver: more than 120 verified events in a minute for this app; resetAt says when. Unverified traffic never spends this budget |
source_capped | 429 | Receiver: 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_capped | 429 | invitePerson(): 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_recorded | 503 | Receiver: the event could not be stored, so nothing ran. The provider sends it again |
invalid_definition | 400 | A relay file has a problem; the message is one sentence naming the field, why, and what to do |
relay_capped | 400 | The environment holds 20 relays. Delete one, or fold two into one; limits are raised on request |
name_taken | 409 | A relay with this name exists in this environment |
version_conflict | 409 | The relay's definition moved since it was read; re-read and reapply |
not_replayable | 409 | Replay applies to a dead, failed or done event; a queued or running one is already on its way |
unknown_tool | 404 | No AI tool by that name in this environment. See The AI route |
tool_disabled | 403 | The owner switched this AI tool off |
entitlement_required | 403 | An AI tool's requires names a plan or product this person lacks; the message names it |
model_pinned | 403 | This AI tool's model is fixed; leave model out of the body |
too_many_tools | 409 | An environment holds at most 50 AI tools |
invalid_tool | 400 | Creating or updating an AI tool with a bad field; the message names it |
credits_exhausted | 402 | The 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_configured | 409 | No provider key on this app and environment; the owner pastes one on the AI page. See The AI route |
provider_required | 400 | More than one provider key is set and the call named none; pass provider |
model_not_allowed | 403 | The 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_capped | 429 | 20 AI calls per person per minute; wait for resetAt |
session_required | 401 | g.ai.chat() or g.credits.balance() without a signed-in person; sign in first |
provider_unreachable | 502 | The provider did not answer before the first byte; nothing was charged and the credit is refunded. Retry |
provider_error | the provider's | Thrown 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_response | 0 (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_unreachable | 0 (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_unsupported | 0 (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_large | 0 (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_unavailable | 0 (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_name | 0 (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_amount | 400 | spendCredits(): amount outside 1 to 10,000, or not a whole number |
invalid_reason | 400 | spendCredits(): reason missing, not text, or over 200 characters |
dedupe_conflict | 409 | spendCredits(): the key already names a different movement, another kind or another person; a key is one movement, reused only to retry that same one |
credits_ceiling | 409 | A credit that would carry the balance past 1,000,000,000; nothing was added. See Credits |
ai_test_capped | 429 | The AI page's test call; one a minute per app |