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)
}
}
The one rule that matters
forbidden means stop; denied means
later. forbidden (403) is the rules refusing you — the same call will
always be refused; fix the approach. denied is retriable or fixable:
a rate limit (429 — wait for resetAt) or a missing sign-in (401 — sign in).
Distinguish by HTTP status. Rate-limit 429s and monthly usage 429s carry
resetAt; a storage-space 429 (usage_limit_exceeded on upload)
carries none — deleting files frees room immediately, the calendar doesn't.
entitlement_required is the exception. It is
the one 403 that can succeed later — the customer is signed in and simply doesn't
hold what this collection is gated on. It carries the key on the error
(err.requires, e.g. "access:pro"), 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 as absent |
forbidden | 403 | The rules refused this — never retry; fix the approach or show the message |
entitlement_required | 403 | Signed in, but doesn't hold what this collection needs. err.requires names the key — show an upgrade path; retry after they have it |
denied | 429 / 401 | Rate limit (wait for resetAt) or missing sign-in — could work later |
conflict | 409 | A keyed create, floor/ceiling, or stale ifVersion — the mechanism working; tell the user it was taken |
html_not_allowed | 400 | Community/addressed/direct store plain text — remove HTML tags |
invalid_publish | 400 | published is an option on public rules only — not a data field |
invalid_shape | 400 | A field the live (sealed) collection never learned — sealed shapes can't take new fields, so send only the ones it has |
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 | No product by that name — the message lists what the app sells |
plan_has_no_link | 409 | The owner hasn't pasted that plan's Payment Link yet |
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 |
invalid_file_content | 400 | Uploaded bytes aren't the claimed image type — upload the actual image |
test_session_forbidden_live | 403 | testSession() on a live environment — reaffirm's Tier B is dev-only |
unknown_plan | 404 | No plan by that name — the message lists the app's real plans |
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 |
unsupported_file_type | 415 | Upload isn't an allowed image type — send JPEG/PNG/WebP/GIF/HEIC |
invalid_key | 400 | A keyed create's key breaks the charset/length law (1-120 chars of letters, numbers, : _ . @ / -) |
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 | The app's monthly allowance is spent. Unlike a rate limit this carries no resetAt — it clears at the start of the next month, or the owner moves up a state. Retrying now cannot succeed |
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 — 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 |
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 |