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)
  }
}

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

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 as absent
forbidden403The rules refused this — never retry; fix the approach or show the message
entitlement_required403Signed in, but doesn't hold what this collection needs. err.requires names the key — show an upgrade path; retry after they have it
denied429 / 401Rate limit (wait for resetAt) or missing sign-in — could work later
conflict409A keyed create, floor/ceiling, or stale ifVersion — the mechanism working; tell the user it was taken
html_not_allowed400Community/addressed/direct store plain text — remove HTML tags
invalid_publish400published is an option on public rules only — not a data field
invalid_shape400A field the live (sealed) collection never learned — sealed shapes can't take new fields, so send only the ones it has
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_product404No product by that name — the message lists what the app sells
plan_has_no_link409The owner hasn't pasted that plan's Payment Link yet
authentication_required401Sign in before checkout / reading a subscription
payload_too_large / file_too_large413Over the size cap — the message states it; shrink it
invalid_file_content400Uploaded bytes aren't the claimed image type — upload the actual image
test_session_forbidden_live403testSession() on a live environment — reaffirm's Tier B is dev-only
unknown_plan404No plan by that name — the message lists the app's real plans
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
unsupported_file_type415Upload isn't an allowed image type — send JPEG/PNG/WebP/GIF/HEIC
invalid_key400A keyed create's key breaks the charset/length law (1-120 chars of letters, numbers, : _ . @ / -)
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_exceeded429The 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_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 — 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
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