Errors
When something fails, your app gets a code to act on and a message you can
show people. Every failed call throws a GemmeinError with a stable
code to branch on and a message that reads correctly for
people.
import { GemmeinError } from "@gemmein/sdk"
try {
// "tasks" must exist first: a new app has no collections (404 unknown_collection)
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 (403) means the
safety 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; deleting files lifts it.
entitlement_required is the exception. It is
the one 403 that can succeed later: the person is signed in and 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
Keys and domains
| 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 |
origin_not_allowed | 403 | The request came from an address this key does not allow. The Development app key works from any secure (https) address, such as a browser builder's preview, a host's preview deploy or your own domain, and from localhost, before and after go-live, with nothing to list. The Live app key works only on the app's verified domains. A Live key on a local page is told: "Your Live app key refuses local pages; work locally with your Development app key or npx gemmein dev." A Development key on any other local address is told: "With a Development app key, local pages work only on localhost or 127.0.0.1, over http or https." Plain http on a public name, a file or a sandboxed frame is told: "A Development app key works from any secure (https) address, and over plain http only on localhost or 127.0.0.1." On npx gemmein dev, any other named address is told: "npx gemmein dev allows pages on http://localhost and http://127.0.0.1 only. To call from a public https address, use your cloud Development app key (npx gemmein keys app prints it)." The body carries reason (no_key, secret_key, unknown_key, local, address, domain or insecure) to branch on, never the sentence, and, once the key names an app, live (true once the app is live) and environment (development or production: whose rule refused the page). A key problem is about the key, whatever the page's address. With no key: "This request has no app key. Add the app key npx gemmein keys app prints." With a secret key: "A secret key never goes in a browser. Use the app key npx gemmein keys app prints." With a key Gemmein doesn't know: "This app key isn't recognised. Use the app key npx gemmein keys app prints." On npx gemmein dev the same three point at the key it prints instead: "This request has no app key. Add the app key npx gemmein dev prints (pk_local_…)." "A secret key never goes in a browser. Use the app key npx gemmein dev prints (pk_local_…)." "This app key isn't recognised by gemmein dev. Use the app key npx gemmein dev prints (pk_local_…)." Add and verify the domain the app goes live on (a host's preview address can't be added); never retry. A refused https address on the Live key shows on the dashboard's Errors page |
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 gemmein dev is running |
live_paused | 503 | Live is paused: the Gemmein plan behind this app ended, or a failed payment stayed unpaid for 7 days. Every Live request answers it; Development keeps answering. To the people using the app (an app key): "This app is temporarily unavailable. Please try again later." To your own server code (a server key): "Your Gemmein subscription ended, so Live is paused. Development still works. Restart your plan from Settings → Usage & billing." After an unpaid payment the second sentence is "Your Gemmein payment didn't go through, so Live is paused. Development still works. Update your card from Settings → Usage & billing." Show your own temporarily-unavailable state and try again later; nothing your app does lifts it, only the founder paying. Webhooks sent to Live are kept (answered 200 with "held": true) and run in order when Live is back |
live_unavailable | 403 | Rotating a server key or an AI tool's secret while Live is a dashboard action for now. Nothing was rotated. Use Settings → API keys → the key → Rotate, or Settings → AI tools → the tool → Rotate secret |
scope_denied | 403 | Secret key used outside its dashboard-configured scope:
|
secret_key_expired | 403 | A key handed out by a browser approval has ended; the message names the instant. This machine's Development approval lasts 7 days: run npx gemmein login and approve it again. A Live approval is good for 10 minutes: run npx gemmein sync --live again, and it asks for a fresh one |
key_name_taken | 409 | npx gemmein keys create: the environment already has a key with that name, or keys revoke named one that two keys share. Pick another --name, or revoke the one you mean on Settings → API keys |
key_not_found | 404 | npx gemmein keys revoke: no working server key has that name in that environment. npx gemmein keys list shows the keys there are |
invalid_key_plan | 400 | npx gemmein keys: the key's request could not be read (a collection or access level it doesn't know). Nothing was made; the message names the part, and your AI runs the command again |
no_environment | 409 | The approval or key names an environment this app doesn't have: a Live key before the app is live, for one. Make a Development key, or take the app live first |
domain_exists | 409 | npx gemmein domains add: the domain is already on this app. npx gemmein domains shows its DNS records and whether it is verified |
store_error | 500 | A change did not reach Gemmein's store, or was made but could not be read back (adding a domain, for one). The message says whether anything was added. Refresh the page or try again |
key_limit_reached | 409 | An environment holds at most 50 active secret keys, and making another was refused: “Revoke a key to make another”. Every active key is listed on Settings → API keys; revoke one you no longer use. Rotating a key is never refused |
app_limit_reached | 409 | An account holds at most 10 apps, and making another was refused. Apps can't be deleted; write to Gemmein to raise the limit |
app_limit_free | 409 | Your first app is free. Pick a plan for it on Settings → Usage & billing to add another |
app_limit_plan_inactive | 409 | Your plan isn't active, so a new app was refused — every app you already have keeps working. Update your card in Stripe, then try again |
app_paused | 402 | This app is paused because your plan isn't active. Update your card in Stripe to keep building |
secret_key_public | 400 | Going live reads your live site, and it found a secret key on a public page or a same-origin script it loads. Move it to your server, redeploy, rotate the key on Settings → API keys, then go live again |
secret_scan_failed | 400 | Going live checks the CLI scan on record, and it found a secret key in your build. Move it to your server, rebuild, rotate the key, then run npx gemmein check again |
secret_scan_incomplete | 400 | Going live couldn't finish reading one of your verified domains for secret keys within its 15 s budget, and there's no CLI scan on record to fall back on. Run npx gemmein check in your project, or confirm in the dashboard that no secret key ships in your site (the confirmation is logged), then go live again |
secret_scan_confirm_required | 400 | A mobile-only app has no website for Gemmein to scan when you go live. Run npx gemmein check on the built app, or confirm that no secret key ships in your app: in the dashboard, or with npx gemmein go-live --confirm-no-secret-key (the confirmation is logged) |
invalid_domain | 400 | The domain can't be added: it isn't written as a domain name (lowercase, like fernhall.app, with no https:// or path), or it is a shared host such as vercel.app. Add a domain you own |
preview_address | 400 | The domain is your host's preview address (my-app.vercel.app); Gemmein can't check it or send from it. Add the domain you'll go live on: its records and proof carry to Live, so you do them once |
apex_has_cname | 409 | Replies were asked to arrive at the name itself on a subdomain that points at its host with a CNAME. A name that is a CNAME can’t receive mail itself. Nothing was changed; keep replies on mail.<name> |
domain_check_unavailable | 503 | Domain checks aren't switched on for this Gemmein, so the domain check can't run. Nothing changed |
email_domain_exists | 409 | The app already sends email from a domain. An app sends from one domain at a time: remove that one first (Domains → the domain → Email), then add the new one |
email_domain_claimed | 409 | Another app already sends email from this domain. Remove it from that app first |
invalid_email_domain | 400 | The domain couldn't be set up to send your app's email; the message says why. Check the spelling (no https://, no path) and add it again |
email_domains_unavailable | 503 | This deployment has no email provider, so a custom sender domain can't be added here. The app keeps Gemmein's sender |
resend_error | 502 | The email provider refused or didn't answer while setting up the domain's email. If it refused, nothing changed: try again in a minute. If it didn't answer, reload in a minute to see whether it went through |
invalid_senders | 400 | npx gemmein emails sender (or set_sender_words): the senders plan is missing, empty, or names something other than account, support or news. Send the sender word for at least one of the three |
Sign-in and sessions
| Code | Status | Meaning · what to do |
|---|---|---|
auth_expired | 401 | Session expired. The SDK auto-clears the token; retry or sign in again |
denied | 429 | A rate limit; wait until resetAt, then try again |
denied | 401 | Sign-in is missing for this call. Sign the person in, then try again |
denied | 500 | An unexpected failure on the server. Nothing you sent caused it; try again later |
authentication_required | 401 | Sign in before a plan's checkout (a plan is tied to an account), a product bought with an item note, or reading a subscription. A product without a note can be bought signed out |
user_suspended | 403 | Sign-in: the code was right, but the owner suspended this person, so no session is made. It stays refused until the owner reactivates them in the dashboard; show the message |
people_limit_reached | 403 | “This app isn’t taking new sign-ups right now.” Legacy only — current pricing has no people limit, so no app receives this code any more. People already signed up keep working |
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 |
email_send_failed | 503 | The sign-in code email could not be sent (the email provider refused or failed the send, or could not be reached). The person’s earlier code, if they have one, still works; the code in any email from this try that does arrive won’t work. Retryable: show "Try again in a moment" and let the person ask again. The answer is the same for every address, and the try does not count toward the 3 per 15 minutes when the provider was down, busy or unreachable, or the time ran out before the email was handed over. A provider that takes the email but does not answer in time is not this error: the start answers 202, and the code works when the email arrives |
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 |
test_session_forbidden_live | 403 | testSession() only works in Development. Use an sk_dev_ key |
protected_account | 403 | This account is protected and cannot be deleted; retrying cannot help |
export_rate_limited | 429 | A person exports their own data once every 30 days, counted from their last export; the dashboard exports a person's data once a day. The message gives the date and, on the person's own export, says they can still ask the app's owner for their data. Only that refusal, the person's own within 30 days, carries ownerContact: the app's support address, or null when it has none. While an export of the same person is still being prepared, another is refused at once, and resetAt is a couple of minutes away. Try again after resetAt |
audit_unavailable | 503 | A data export's row in Logs could not be written, so nothing was handed out. Try again |
erase_unavailable | 503 | Deleting an account, with g.account.delete() or from the Customers page: Gemmein couldn't read the app's payments, which it needs to end the person's subscription in Stripe. Nothing was deleted and nothing changed in Stripe. Show the message and let them try again in a moment |
confirm_required | 403 | g.auth.signOutDevice(id) on another device needs a sign-in within the last 10 minutes. Gemmein emailed the person a confirm code: show a code field, then call signOutDevice(id, { code }). A wrong code is invalid_code; calling again without a code sends a new one |
posts_unpublished | 409 | A rule change that shows every record (to private, admin_write, addressed or direct) while some posts aren't published: waiting, sent back, taken down by their author, or a draft. "2 posts aren't published — publish or delete them first." Publish or delete them in Records, then change the rule |
device_not_found | 404 | g.auth.signOutDevice(id): none of the person's signed-in devices has that id; it may already be signed out. g.auth.devices() lists the ones still signed in |
invalid_device | 400 | g.auth.signOutDevice(id) was given something that isn't a device id. Pass the id from g.auth.devices(); it starts with sess_ |
Data
| Code | Status | Meaning · what to do |
|---|---|---|
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 safety rules refused this. Never retry; fix the approach or show the message |
app_only | 403 | The collection is app_only: no app session reads or writes it, signed in or not, yours included. Don't retry; signing in changes nothing. Only the dashboard, your server (a secret key) and relays reach it. Have your server or a relay copy what people may see into a collection they can read |
conflict | 409 | A keyed create, a floor or ceiling, or a stale ifVersion refused the write. This is expected: tell the person it was taken |
collection_not_empty | 409 | Deleting a collection that still has active records; the records go first. This is expected |
invalid_publish | 400 | published is an option on public rules only, and never a data field |
moderation_required | 403 | The collection's posts wait for the owner's approval. Save without published: true; the owner publishes from the dashboard |
author_not_visible | 403 | list({ author }) on private, addressed or admin_write. Drop author |
invalid_author | 400 | author is "me" (signed in) or a person's user id |
invalid_publishing | 400 | A collection's "publishing" is "authors publish" or "owner approves", and "owner approves" works on community. On public_read the setting is refused: only you write it, so there's nothing to approve. Fix the file |
invalid_shape | 400 | The live collection has no field with this name. Send only the fields it has. A new field reaches the live collection when the owner goes live again and approves it (one answer per field). 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_record | 400 | A link points at a record that doesn't exist (live only); fix the id |
invalid_since | 400 | since must be a strict ISO 8601 timestamp (pass the previous answer's watermark), and it fixes the order; drop sort |
invalid_show_time | 400 | showFrom / showUntil isn't an ISO 8601 instant with a zone (2026-11-01T09:00:00Z), the day doesn't exist, showUntil isn't later than showFrom, null came on a create, or the collection is private or app_only (nobody else reads it). The message names which |
not_rankable | 400 | list({ sort: { by } }) named a field the collection doesn't declare rankable. Add it to "rankable" in gemmein/collections/<name>.json (a number field) |
invalid_sort | 400 | sort isn't "newest", "oldest", "updated" or { by, order }, or order isn't "desc" or "asc". The message names the fix |
invalid_expand | 400 | expand on a field or safety rule with no link shape; join in memory instead |
system_collection | 403 | Collections named starting __ are the platform's own; no app session and no key reads, writes, or deletes them. Name the collection your code needs, or (for a transcribe tool's audio) upload with g.ai.upload(tool, file) instead |
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 |
invalid_filter | 400 | where takes at most 5 fields, and values must be a string, number or boolean |
invalid_count | 400 | count() takes only where and search; list({ count: true }) is refused on a since delta |
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 |
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_name | 400 | A collection name is 2-63 lowercase letters, numbers or underscores, starting with a letter, and never a JavaScript built-in (constructor, prototype). Rename the collection |
invalid_limit | 400 | limit isn't a whole number from 1 to 100. Ask for at most 100 and page with cursor; nothing is cut down for you |
invalid_open_fields | 400 | A collection's "openFields" is refused: it is on private or admin_write, names a link, file or server-managed field, gives an operation Gemmein doesn't know, or has an empty "one of" list. The message names the fix; correct the collection file or Open fields on the collection's page in the dashboard's Data |
invalid_rankable | 400 | A collection's "rankable" names more than 3 fields, a field twice, or a name that isn't a data field; on community, shared and direct, a field that isn't a declared number. Fix the collection's file; the message names the field |
not_open_field | 400 | open(id) named a field that isn't open; only the author changes it, with update(). The message lists the open fields. To let readers change it, name it under "openFields" in the collection file |
wrong_open_operation | 400 | open(id) used the wrong operation for the field: a counter takes count(field, 1) or -1, a "one of" takes set(), a "one per person of" takes choose(), a flag takes flag(field, true) or false. The message names the right call |
open_value_not_in_list | 400 | A "one of" or "one per person of" field was given a value that isn't one of its choices, or a choice taken off its list; the message lists them. Send a listed choice |
open_field_write | 400 | App code wrote an open field through create() or update(). Open fields change through open(id); a secret key and the dashboard may set a "one of" to a listed choice, and counters and flags are never written directly |
open_field_not_allowed | 403 | open(id) on a private or admin_write collection, where nobody else can change a field. Open fields work on shared, community, public_read, addressed and direct |
open_field_needs_person | 403 | A secret key called open(id). Each like, vote or flag is a signed-in person's own; your server sets a "one of" with update(), and counters and flags are never written for someone |
invalid_stats | 400 | stats(field) named something that isn't a data field (a letter, then up to 62 letters, digits or underscores), or was sent since, sort, cursor, limit or expand, which don't apply: stats answer over every record the filters admit. Send where and search only |
open_field_too_fast | 429 | One person changed one record's open fields more than 10 times in a minute. Show the last state and let them try again in a moment |
collection_not_declared | 400 | The collection has no declaration yet, and people other than the writer read it, so its fields are declared before anything is written. Add them to gemmein/collections/<name>.json: npx gemmein dev picks them up at once, and npx gemmein sync carries them to the cloud |
undeclared_field | 400 | The write carries a field the collection's file doesn't declare; the message names it, with an example. Declare it under "fields" in gemmein/collections/<name>.json |
field_deprecated | 400 | The write sets a field the collection's file marks "deprecated": true. A retired field takes no new values, and records that already have it keep it. Stop writing it, or remove the mark |
posts_waiting | 409 | Owner approval can't be turned off, and the rule can't move to one without approval, while posts wait for it. The answer carries waiting, the count. Approve them or send them back (Data → the collection → Publishing), then make the change |
confirm_required | 400 | On a collection's page in the dashboard's Data: a rule change that lets more people see or write the collection's existing records asks for the collection's name. Type it to confirm, then save |
invalid_visibility | 400 | visibility isn't "followers", "circle:<id>" or "everyone", or it is set on a collection that isn't community or shared, or on a record with no person. Followers-only and circle posts belong in a community or shared collection |
file_is_public | 400 | A followers-only or circle post names a file that is public, someone else's, or not uploaded for that audience. Upload it again with { visibility: "followers" } (or the circle's visibility) and put that ref in the post |
person_unavailable | 403 | A follow, a message or a direct file across a block, whoever made the block. The answer never says who blocked. Don't retry, and tell the person they can't follow or message them |
follow_limit | 429 | A person already follows the most people they can: 5,000 (200 in Development). Unfollow someone first |
block_limit | 429 | A person already blocks the most people they can: 1,000 (50 in Development). Unblock someone first |
request_limit | 429 | A person has the most follow requests waiting that they can have: 500 sent (50 in Development), or 1,000 received (100 in Development). Cancel one, or try again once some are answered |
request_not_found | 404 | No follow request from that person is waiting. g.people.requests() lists the ones that are |
invalid_private | 400 | private isn't true or false. true turns follows into requests the person accepts; false lets anyone follow and accepts the requests waiting |
person_private | 403 | A private account's followers and following show only to the people it accepted. Follow the account (the person accepts) to see them |
invalid_list | 400 | list on a people read isn't following, followers, friends, blocked, requests or requested (another person's lists are followers and following), or limit or cursor came without a list. Use the g.people methods |
circle_not_found | 404 | No circle with that id has the person in it. g.circles.list() lists their circles |
circle_creator_only | 403 | Only the person who created a circle adds or removes its members. Ask its creator |
circle_creator_leaving | 400 | The person who created a circle is leaving it. Name a member to take it over (leave(id, { transferTo })) or disband it (disband(id)) |
circle_full | 429 | The circle holds the most members its plan allows: 50, 250 on Run a business, 1,000 on Grow. Remove someone first |
circle_limit | 429 | A person already created the most circles they can: 100 (10 in Development). Disband one first |
invalid_circle_name | 400 | A circle's name is 1 to 80 characters. Send a name in that range |
not_in_circle | 403 | A circle's posts are for its members: a person posts to a circle only while they are in it. Ask the circle's creator to add them |
circle_member_not_found | 404 | The dashboard, the CLI or your AI removed a person from a circle they aren't in, and nothing changed. people_graph lists a circle's members |
circle_creator | 409 | The dashboard, the CLI or your AI removed a circle's creator from their own circle, and nothing changed. To stop them, suspend or delete the person |
follow_not_found | 404 | The dashboard, the CLI or your AI ended a follow that doesn't exist: that person doesn't follow the other and has no follow request waiting, and nothing changed. people_graph lists who follows whom |
invalid_query | 400 | A read of a person's follows names neither a person (person with a view of following, followers, requests, requested or circles) nor a circle (circle=cir_…), or env isn't development or live |
rule_conflict | 409 | A create names a collection that already exists with another rule. A rule changes from the collection's card on the dashboard, not by creating it again; npx gemmein sync reports it as a conflict line |
sensitive_field | 400 | A declared field's name says it holds health or medical data, biometric data, a government ID number or payment card details (ssn, passport_number, card_number, cvc, diagnosis and similar). Collections don't hold these, and payments go through Stripe. Rename the field if it holds something else. gemmein dev, npx gemmein sync, your AI's declare_collection and the dashboard's field editor all refuse it |
Views
| Code | Status | Meaning · what to do |
|---|---|---|
unknown_collection | 400 | (Alongside this code's existing 404 row above — this status is specific to saving a view.) Saving a view names a collection this environment doesn't have. Declare the collection first (declare_collection, or npx gemmein sync from a gemmein/collections/*.json file), then save it again |
add_not_enabled | 400 | Adding through a view: this view has no add declared. Push a view that declares one |
edit_not_enabled | 400 | Editing through a view: this view has no edit declared. Push a view that declares one |
field_not_in_form | 400 | Adding or editing through a view named a field the view's own add.fields or edit.fields doesn't list. Check the view's declared fields |
invalid_data | 400 | Adding or editing through a view: the data sent wasn't a JSON object. Send a plain object of field values |
invalid_view | 400 | A view's recipe doesn't match its collection: a column, total, layout or action names a field, or an action a choice, the collection hasn't declared — or, on an update, the definition tries to change the view's collection (fixed after creation; push a new view instead). The message names the field and the fix |
views_capped | 400 | An environment holds at most 100 views. Remove one first |
unknown_move | 400 | Pressing a view's button named a move that isn't one of that view's declared actions. Check the view's own action names |
unknown_view | 404 | Deleting a view with dev_delete_view: Development doesn't have a view by that name. Check the name on the Views page |
window_not_enabled | 400 | Adding or editing through a view sent showFrom or showUntil on a view with no window declared. Push a view that declares one |
reorder_not_enabled | 400 | Reordering a view's rows: this view has no reorder declared. Push a view that declares one |
move_not_allowed | 409 | A view's action button was pressed from a state the move doesn't allow — its from doesn't include the record's current value — or the field it writes is no longer a declared "one of" open field at all (narrowed or retired since the view was made: push the view again once its recipe matches) |
open_value_not_in_list | 409 | Pressing a view's button re-checks the collection's CURRENT open-field choices every time, not the recipe's own cached ones: the move's to (or a listed from) is no longer one of them. Push the view again once its recipe matches (see also open_value_not_in_list at 400, the SDK's own open() refusal — the same name, raised elsewhere) |
changed_since_opened | 409 | A record (or, on the dashboard, a collection's record editor) changed since the version you read: a view's action button was pressed, or a dashboard edit saved, against a version that has since moved. Re-read the record and reapply your change |
reorder_not_allowed | 409 | Reordering a view's rows: the view's collection no longer declares a rankable field — it did when the view was pushed. Push the view again once its recipe matches |
no_neighbor | 409 | Reordering a view's rows: moving a row up or down with no neighbour at that end of the view's own order. Nothing to move past |
invalid_timezone | 400 | A tz parameter (reading a view's rows, or the dashboard's own record reads) isn't an IANA time zone name, such as Europe/Paris |
Files
| Code | Status | Meaning · what to do |
|---|---|---|
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 passed to a file link call is not a file reference. A ref is file:<id>, the value upload() returned; pass that, never a filename or a URL |
invalid_file_content | 400 | Uploaded bytes aren't the claimed type (usually a renamed file); send the real file, don't retry |
unsupported_file_type | 415 | Upload isn't an allowed type: images (JPEG/PNG/WebP/GIF/HEIC), documents (PDF/ZIP/EPUB), audio (mp3/m4a/wav/webm/ogg/flac/aac) or video (mp4/webm/mov) |
media_rate_limited | 429 | One person started 20 audio or video uploads this hour. Try again after resetAt |
too_many_open_uploads | 429 | Too many uploads in progress: one person has 5 files of any type started and not confirmed, or 1 GB of them. Confirm one, or wait an hour |
person_storage_full | 429 | One person's files would pass their share: the larger of 10% of the app's storage or 2 GB |
usage_limit_exceeded | 429 | Storage is past its limit and the grace (the lesser of 10% over or 7 days) has run out; uploads refuse until the owner deletes files, adds a storage pack or moves the app up a tier. Files already stored stay. No resetAt: the limit is on stored bytes, not time. (Counter usage is never refused or charged: past a monthly ceiling the owner is told once a month) |
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 |
too_large | 413 | On npx gemmein dev: the upload is over 510 MB, the most it stores. The SDK reports it as upload_failed with status 413; send a smaller file |
upload_refused | 403 | On npx gemmein dev: the bytes don't match the upload that was started (another file name, type or size, or the upload expired). The SDK reports it as upload_failed with status 403; start the upload again |
file_quarantined | 403 | The stored bytes failed the content check; the file is held and won't be served |
Messages and email
| Code | Status | Meaning · what to do |
|---|---|---|
thread_not_found | 404 | An AI reply named a conversation that doesn't exist, belongs to another app, or that no customer started (a send the customer never answered). An AI replies only inside a conversation a customer started; list them with GET /server/inbox/threads or the connector's live_inbox |
person_erased | 409 | The conversation's customer was erased, so their address is gone from it. A reply, from an AI or the dashboard's Inbox, is refused with nothing sent: "This person was erased — there is no address to reply to." Fetching one of their messages again is refused too: "This person was erased — their message is not fetched again." |
inbox_unreadable | 409 | The customer's last message is still arriving, or Gemmein couldn't read it, so an AI can't tell whether a person wrote it. Nothing was sent. Try again in a minute; reply yourself from the dashboard's Inbox meanwhile |
inbox_unauthenticated | 409 | The customer's last message didn't pass DMARC, so its sender isn't proven, and an AI reply could reach someone who never wrote. An AI can ask to reply only to senders whose email passes DMARC; nothing was sent. Reply yourself from the dashboard's Inbox, where the message is marked as an unverified sender |
inbox_auto_reply | 409 | The customer's last message wasn't written by a person (an auto-reply, an out-of-office, a bounce or a mailing list), so an AI reply could start a mail loop. Nothing was sent. Wait for the customer to write, or reply yourself from the Inbox |
inbox_reply_cap | 409 | 3 AI replies in a row with no answer from the customer. Nothing was sent; the next AI reply waits for the customer to write. Reply yourself from the Inbox |
inbox_thread_limited | 429 | This conversation has had its 10 AI replies for today. Nothing was sent; resetAt says when the next can go. Reply yourself from the Inbox |
inbox_recipient_limited | 429 | This address has had its 5 AI replies for this hour, across every conversation. Nothing was sent; resetAt says when the next can go |
inbox_reply_limited | 429 | In Live, AI replies passed 60 an hour for this connection or key, or 300 an hour for the app across every AI sender. Nothing was sent; resetAt (and Retry-After on the key route) says when the next can go. Reply yourself from the Inbox |
html_not_allowed | 400 | Community/addressed/direct store plain text; remove HTML tags, or you turn on the collection's allowsMarkup setting (Content: allows markup) when the app renders HTML safely |
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_audience | 400 | for isn't a person in this app; fix the recipient id |
too_many_recipients | 400 | A direct message, or a file uploaded to a direct collection, names more than 20 people; send one addressed record each, or use a shared collection |
invalid_recipients | 400 | A list of recipients sent to a collection that is not direct (a record or a file upload); addressed takes one person per record |
reply_only | 403 | This direct collection only allows replying to people who wrote to you first |
sends_disabled | 403 | The owner turned direct messages off for this collection; addressed records are sent from the dashboard or a relay |
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 |
no_person | 400 | notify() was sent without a personId. Pass the person's user id in this app, never an email address |
no_subject | 400 | notify()'s subject is missing or over 300 characters; the message says which |
no_text | 400 | notify()'s text is missing or over 10,000 characters; the message says which. It is plain text: a notification, not a campaign |
invalid_kind | 400 | notify()'s kind is neither "event" (capped per person) nor "account" (account activity: a sign-in, an access change, a billing problem) |
send_failed | 502 | The mail could not be sent, or the sending domain could not be checked just then. Nothing was sent or recorded and no cap is spent: retry notify() with the same key. In the dashboard Inbox, try again in a minute |
content_slot | 400 | An inbox-reply template needs {{content}} exactly once |
development_capped | 429 | Cloud Development's email budget is spent: notify(), a relay's email_person and the purchase email share 20 a day per app and 50 a day per account. Wait for resetAt, or test volume locally with npx gemmein dev. Live has no such budget |
reserved_key | 400 | A notify() key starting gemmein:. Those keys are Gemmein's own; choose another |
in_flight | 409 | A notify() with the same key is being sent right now. Retry in a moment; a delivered send answers idempotently |
invalid_brand | 400 | sign-in.json is not a valid brand config: an unknown key, a greeting or app name with a digit or an address, a colour that is not #rrggbb or is too light to read the code on, or a logo that is not an https: image. The message names which |
logo_unavailable | 400 | The logo in sign-in.json could not be fetched when you saved: it is not a PNG, JPEG, GIF or WebP, is over 200 KB, redirects, or answered an error. Nothing is saved; fix the address or the image and save again |
template_too_large | 400 | An email template is over 100 KB. Host images at https: addresses and trim the HTML |
templates_capped | 429 | Email templates take 60 saves and 120 previews an hour per app (150 and 300 per account). Wait for resetAt, or preview locally with npx gemmein emails preview |
templates_live_refused | 409 | Email templates are saved in Development; go-live and promote copy them into Live |
unknown_template | 400 | An email template's name is not one Gemmein sends: sign-in, purchase, notify, notify-account, notify-event, email-person or inbox-reply |
unknown_variable | 400 | An email template uses a {{var}} its email does not have. The message names the ones it has |
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 is sent or recorded, no cap is 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). A test member’s email in Development needs no domain: it lands in the test mailbox |
already_waiting | 409 | The dashboard Inbox: a new conversation to someone who is already waiting on a reply under the same subject. Reply in that conversation instead |
start_rate_limited | 429 | The dashboard Inbox: 30 new conversations in an hour from this app. Wait a moment; replies in existing conversations are not counted |
not_a_member | 403 | The dashboard's alert settings: the email signed in to the dashboard isn't this account's. Sign in with the account's email and try again |
inbound_cap | 429 | Mail to your support address past the Inbox ceiling: one conversation holds at most 500 messages, and an app at most 2,000 conversations. Mail past it is not added to the Inbox; a customer with a full conversation can write under a new subject |
Payments and access
| Code | Status | Meaning · what to do |
|---|---|---|
entitlement_required | 403 | Signed in, but without the plan or product that unlocks this. On a collection: not on a plan (or holding a product) the collection is unlocked by, and err.requires carries that plan's key (one string). On an AI tool: the tool's requires names a plan or product this person lacks, and the message names it. Show an upgrade path, and retry after they have it |
payments_not_configured | 404 | No plans or products exist in this environment, or Stripe isn't connected in this environment. Don't retry; show a message |
unknown_product | 404 | No product by that name in this environment. The message is safe to show the buyer; the owner's Logs list what this environment sells, and in Development the response carries it as ownerDetail |
unknown_product | 400 | A g.payments.buy() that names no product. The message lists what the app sells; name one |
unknown_plan | 404 | Checkout for a plan that doesn't exist. The message lists the app's plans, so there is nothing to check out |
unknown_plan | 400 | From grantAccess(): no plan or product by that name. The owner adds it on the Payments page |
plan_has_no_link | 409 | The plan has no Payment Link Gemmein made in this environment yet. Show the message; the owner connects Stripe and prices the plan on the Payments page in the dashboard |
already_subscribed | 409 | The signed-in person already has a subscription, so no second one is started. Send them to the url in the answer (a Stripe customer portal session Gemmein opens for them), where they change plan |
no_subscription | 409 | g.subscriptions.manage(): the signed-in person has never paid for a plan through Stripe, so there is nothing to manage. Show the plans instead |
no_subscription | 400 | Your own Gemmein billing, on Usage & billing: adding or removing a storage pack needs an active Gemmein subscription. Finish billing setup, then try again |
invalid_plan | 400 | Your own Gemmein billing, on Usage & billing: a plan change named an unknown tier or interval. A tier is Launch, Run a business or Grow; billing is monthly or yearly; one change moves either the account’s tier or the billing interval, not both |
interval_locked | 409 | Your own Gemmein billing: your year is paid, so monthly billing opens 30 days before it renews; the message names the date. Try again from then |
not_live | 409 | Your own Gemmein billing: changing the tier of an app that isn’t live. Its tier is chosen when it goes live |
storage_over_new_limit | 409 | Your own Gemmein billing: removing a storage pack or moving a Live app down a tier while its files don't fit the storage it would have afterwards (its tier's storage plus the packs left). Nothing is charged or changed. Delete files, or keep or add a pack, then try again |
no_plans | 409 | g.subscriptions.manage(): the app sells no plans (none listed, and no subscription still charging). A plan removed while members still pay keeps their portal open |
portal_unavailable | 409 | g.subscriptions.manage(): the customer portal can't open right now, because Stripe isn't connected here or the key lacks Customer portal: Write. The message is safe to show; in Development, ownerDetail says what the owner fixes on the Payments page |
portal_unavailable | 503 | g.subscriptions.manage(): Gemmein couldn't read the app's payments for a moment. Nothing changed; let them try again |
portal_unavailable | 502 | g.subscriptions.manage(): Gemmein couldn't reach Stripe to open the portal. Stripe was unreachable; nothing in your app needs fixing. Nothing changed; let them try again shortly |
portal_rate_limited | 429 | g.subscriptions.manage(): the person opened the portal more than 10 times in 10 minutes. Development also allows an app 100 opens an hour. Show the message and let them try again in a few minutes |
checkout_in_progress | 409 | The same person already has a checkout open for this plan. Show the message; they finish it there, or try again in a few seconds |
product_not_sellable | 409 | g.payments.buy on a product sold through a relay, not sold yet, or with no Payment Link Gemmein made, so it has no Payment Link to open |
plan_not_sellable | 409 | g.subscriptions.checkout on a plan sold through a relay, or not sold yet, so it has no Payment Link to open |
plan_not_purchasable | 400 | That's the free default plan; nothing to buy |
invalid_item | 400 | The item on a purchase breaks the allowed shape |
simulation_failed | 502 | Development only: the local payment simulator couldn't complete the round trip |
payments_off | 409 | Payments are turned off in this environment, so checkout and buy are refused and nothing can be bought here. To sell here again, set payments up again on the dashboard's Payments page |
invalid_email_for_checkout | 400 | The signed-in person's email address is too long to carry through Stripe checkout, so no checkout was made. Ask them to sign in with a shorter address |
Secret key calls
| Code | Status | Meaning · what to do |
|---|---|---|
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 have this permission; the owner creates 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 current 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 or from the relay a product or plan is sold through; 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 | This path is not a /server route; the message lists the valid ones. Call the server client's methods instead of 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 |
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 |
person_required | 400 | A secret key's create() or upload() on private, shared or community names no person; pass { for: personId }. On direct, name both: { from: authorId, for: recipientId } |
invalid_person | 400 | A secret key named a person where it can't: { from } on anything but direct, any person on admin_write or public_read (those records are the app's), or an id that isn't a person of this app (ids come from verifySession(), invitePerson() or the dashboard). From app code, { from } is refused: the signed-in person is always the author |
Relays
| Code | Status | Meaning · what to do |
|---|---|---|
scheme_renamed | 401 | A relay saved before the hmac_sha256_header scheme was renamed signature refuses every event until its definition is saved again with "scheme": "signature" (the relay's page, or npx gemmein sync); nothing else changes. A definition that names the old scheme is refused at save with invalid_definition, naming the new one |
too_many_signatures | 400 | The signature header lists more than 8 signatures. A sender lists one, or two or three while it rotates its secret. Check that signatureKey and separator match the sender's docs |
unsigned_event | 409 | Replay was refused: the event arrived without its content signed (the sender signed only a token, or sent a shared token), and the relay now runs an action that needs trigger.trustUnsignedEvents. Nothing ran. Set the opt-in on the relay to let such events act, or let this one go. See Unsigned events |
no_call_url | 400 | A new signing secret was asked for on a relay that calls no URL, so nothing it does is signed. Add a call_url action to its file first |
provider_secret_required | 400 | Gemmein was asked to make the secret for a relay whose sender always makes its own: Stripe's format (paste its whsec_…), or an ed25519 or ecdsa-p256-sha256 recipe (paste the sender's public key). Paste it on the relay's page (Relays), or pipe it to npx gemmein relays secret <name> |
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 |
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 |
duplicate_grant_target | 400 | A relay has two grant or take actions on the same target: a product, an access key, a plan, or credits. A relay grants or takes each target once; merge the two into one action (for credits, one grant_credits with the summed amount). The message names the action and its target |
relay_capped | 400 | The environment holds 150 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_saved | 503 | Updating a relay that gains an action moving money or access: its waiting events couldn't be prepared for the change, so nothing was saved. Try again |
not_replayable | 409 | Replay applies to a dead, failed or done event; a queued or running one is already on its way |
trigger_kind_fixed | 400 | A relay's trigger kind can't change once it exists: its receiver address, secret, schedule or hook would not carry over. Give the relay with the new trigger a new name in gemmein/relays/, or delete this one and create it again; the message says which applies |
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 |
not_callable | 404 | g.relays.call: no relay of that name has an app_call trigger. Check the name, and that the relay's trigger is { "kind": "app_call" } |
relay_paused | 503 | g.relays.call: the relay is paused, so nothing was charged. Resume it on the Relays page, or try again later |
relay_failed | 502 | g.relays.call: what the charge paid for failed (the collection was removed, the write was refused). The credits came back once; the answer carries refunded: { credits, balance }, or refundPending while Gemmein retries the refund until it lands. It is also the alert a relay raises when an event ends dead |
call_in_progress | 202 | g.relays.call: this key's call is still working. Ask again with the same key, never a new one. The answer says when to ask |
call_not_finished | 409 | g.relays.call: this key's call ended without charging, or was refunded. Send a new key to try again |
too_many_calls | 429 | g.relays.call: one person, or the app, passed the calls it may make in a minute; resetAt says when. A person gets 30 a minute per relay |
daily_cap | 429 | g.relays.call: the app has taken all the calls it can today, or all it takes from people who are not signed in. Try again tomorrow, or sign the person in |
internal_error | 500 | g.relays.call: the call could not be recorded, so nothing ran and nothing was charged. Try again with the same key |
Functions
| Code | Status | Meaning · what to do |
|---|---|---|
functions_unavailable | 501 | Functions isn't switched on for your account. put_function, running a function, and a relay's run_function step all answer this the same way until it is. The npx gemmein dev local runtime answers the same code for a different reason: it runs no Edge functions at all — push with npx gemmein functions push <name> and run them in your Development (the cloud one), with the same limits |
edge_function_lock_busy | 503 | Another deploy, save or remove of this same function is already running — this one answered busy rather than wait indefinitely. Nothing about the function changed. Run the same command again in a moment; Edge serializes one function's deploys so this should clear within seconds once the one ahead of it finishes |
secret_hosts_in_browser | 403 | A function's Live secret is bound to the host(s) your human ticks in their own browser, in the approval Gemmein opens — never typed or chosen by the CLI or an AI directly. Nothing was stored by the call that got this refusal; stage the value (piped from stdin, never on the command line) and open that approval with npx gemmein functions secret <function> <SECRET> --host <host> |
remove_failed | 502 | Deleting a function timed out partway through, so it may not have been removed from Cloudflare. Nothing about the stored listing changed otherwise. Run npx gemmein functions delete <function> again to confirm it either way |
AI tools and credits
| Code | Status | Meaning · what to do |
|---|---|---|
unknown_tool | 404 | No AI tool by that name in this environment. See AI tools |
tool_disabled | 403 | The owner switched this AI tool off |
tool_incomplete | 409 | On g.ai.run: the tool composes nothing, no prompt template and no inputs |
invalid_inputs | 400 | On g.ai.run: an input is unknown, missing, the wrong type or over its cap; the message names it |
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 |
model_pinned | 403 | This AI tool's model is fixed; leave model out of the body |
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 |
provider_required | 400 | More than one provider key is set and the call named none; pass provider |
ai_not_configured | 409 | No provider key on this app and environment; the owner pastes one on the AI page. message is for the person ("This feature isn't available right now."); in Development the owner's sentence is in ownerDetail, and Live never carries it. See AI tools |
invalid_base_url | 400 | An OpenAI-compatible provider's base URL was refused when it was saved: it is http://, an IP address, a gemmein.com host, or carries a user and password, a ?query or a #fragment. The message names the rule. Give the https:// base URL the provider's docs show |
endpoint_refused | 502 | An OpenAI-compatible endpoint's base URL resolved to a private, loopback, link-local or cloud-metadata address when the call ran, or the endpoint answered a redirect, which is never followed. Nothing was spent and no connection was made. message is for the person ("This feature isn't available right now."); the AI page's last error names the rule. Point the base URL at the final, public address |
endpoint_budget | 429 | An OpenAI-compatible endpoint's answers passed this environment's budget: 50 MB in an hour or 500 MB in a day in Development, 1 GB and 4 GB in Live. Refused before anything is spent, until the window opens again. The owner's sentence names when; the person reads a neutral one outside Development. Lower the tools' output caps; in Live, write to hello@gemmein.com to raise it |
endpoint_busy | 429 | 5 calls to the endpoint were already open at once in Development (50 in Live). Nothing was spent: "Too many AI answers are running at once for this app — try again in a moment." Retry after a moment |
environment_lookup_failed | 503 | Gemmein couldn't confirm this app's environment just now. Nothing was spent; try again in a moment |
session_required | 401 | g.ai.chat() or g.credits.balance() without a signed-in person; sign in first |
ai_capped | 429 | 20 AI calls per person per minute; wait for resetAt |
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 AI tools |
too_many_tools | 409 | An environment holds at most 150 AI tools |
invalid_tool | 400 | Creating or updating an AI tool with a bad field; the message names it |
unsupported_response_format | 400 | A transcribe tool asks its provider for a format Gemmein can't store: text, srt, vtt, a template, or verbose_json on a model that doesn't offer it. Refused when the tool is saved, never during a run; the message names the formats that work |
ai_test_capped | 429 | The AI page's test call; one a minute per app |
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 |
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 |
audio_not_accepted | 400 | g.ai.upload named a tool that isn't a transcribe tool. Audio uploads go only to a transcribe tool; upload images and documents to one of your collections and pass the ref |
unsupported_audio_type | 400 | g.ai.upload: the file isn't audio this tool takes, or has no type. Send mp3, m4a, wav, webm, ogg or flac with its audio content type |
input_not_accepted | 400 | On g.ai.run or g.runs.start: a file input is one the tool doesn't take as a file, or a ref that isn't a file it accepts; the message names the input |
input_not_supported | 409 | The tool's model doesn't take this kind of file (image, audio, video or document). The owner picks a model that does, or unticks that kind on the tool |
input_too_large | 413 | A file input is over the size this tool takes for its kind; the message names both sizes |
input_busy | 429 | One large file at a time per person, or too many files are being handed to providers right now. Try again when the last one is handed over |
input_rejected | 502 | The provider didn't accept the file, or the request to it couldn't be put together; the message carries the reason |
runs_capped | 429 | g.runs.start: the person already has 10 runs open (queued or running). Wait for one to end, or cancel one with g.runs.cancel |
unknown_run | 404 | g.runs.get, watch or cancel: no run by this id for the signed-in person |
run_ended | 409 | g.runs.cancel on a run that already ended, or your pipeline called /complete, /fail or /progress on one. Read its status |
run_busy | 409 | Your pipeline: a completion of this run is already in progress. Wait for its answer, up to five minutes if that call was lost |
not_external | 409 | Your pipeline called /complete, /fail or /progress on a run a provider executes; such a run completes on its own |
token_required | 401 | Your pipeline called /complete, /fail or /progress without Authorization: Bearer <token>. The token came with the hand-off |
token_invalid | 401 | Your pipeline's token doesn't match this run, or no run has this id (the same answer either way) |
hook_capped | 429 | Your pipeline: more than 120 calls a minute to the run's /complete, /fail or /progress from the same sender. Wait a minute and send again |
result_refused | 422 | Your pipeline's /complete couldn't be stored, or its tool no longer exists. The run failed and its hold was returned |
ai_key_rejected | 400 | The provider rejected the key in its one test call, so the key isn't saved or copied (on the AI page, a key page, or when Live takes Development's key). Paste a key that works |
ai_key_unreachable | 400 | The provider couldn't be reached for the key's test call, so the key isn't saved or copied. Try again in a moment |
ai_verify_capped | 429 | More than 6 keys were tried in a minute for this app. Try again after resetAt |
reserved_name | 400 | An OpenAI-compatible endpoint's name includes a provider Gemmein connects directly. Choose that provider from the list, or give the endpoint a name of its own (like OpenRouter or Azure) |
Sync and going live
The codes npx gemmein sync, npx gemmein go-live and the MCP server can receive. Each message names the fix.
| Code | Status | Meaning · what to do |
|---|---|---|
use_promotion | 400 | A community, shared or direct collection was created straight into Live. Create it in Development, then go live or promote; it reaches Live that way, with its shape confirmed |
invalid_unlocked_by | 400 | A collection's unlockedBy isn't a list of plan or product names: it names an access key instead of a name, names more than the limit, or the app has no plans or products yet to unlock with. The message names the fix |
if_name_required | 400 | Renaming the app needs ifName, the name you last saw, so a rename never overwrites one it didn't see |
name_changed | 409 | The app was renamed since you read its name; nothing was renamed. The body carries current; read it and try again |
invalid_declaration | 400 | gemmein/payments.json can't be read; the message names the problem. Fix the file and run npx gemmein sync again |
declaration_ambiguous | 409 | gemmein/payments.json could match more than one plan or product already in Stripe; the message says which. Give the item its own name or key |
file_not_uploaded | 400 | A product in gemmein/payments.json delivers a file this sync hasn't uploaded yet. Run npx gemmein sync, which uploads it first |
payments_live_refused | 409 | Payments are set in Development only: npx gemmein sync never changes Live's payments. Going live and promoting copy Development's payments into Live |
live_paused | 402 | Going live or a promote while Live is paused (npx gemmein go-live, Promote on the Go live page). The message is the founder's sentence; npx gemmein go-live says it before asking for any approval, and the preview of what would go still answers. Pay first from Settings → Usage & billing, then go live |
undeclared_collections | — | Going live was refused before the card step, and nothing was charged: a community, shared or direct collection has no fields declared. The message names each one. Declare its fields in gemmein/collections/<name>.json and run npx gemmein sync |
unknown_collection_setting | — | A collection file carries a key the grammar doesn't know, refused by name at gemmein dev and before sync calls the cloud: "feed.json: "whoCanStart" isn't a collection setting — the settings are rule, means, unlockedBy, publishing, openFields, directInitiation, fields". Nothing is created or changed |
invalid_fields | — | A collection file's "fields" is refused: not an object, a field type Gemmein doesn't know, or a field shape it can't read. The message starts with the file name and names the field. Nothing is created or changed |
invalid_direct_initiation | — | A collection file's "directInitiation" is refused: it is on a collection that isn't direct (who starts a conversation means nothing there; remove it), or its value isn't one Gemmein knows |
payments_live_refused | 403 | The Live push key (sync --live) asked to change payments. Live's payments come from Development by going live and promoting |
not_available | 404 | On npx gemmein dev, gemmein/payments.json is read from your files directly; npx gemmein sync sets the cloud's Development |
not_available | 501 | Exporting a customer's data isn't available here. Nothing was exported |
app_session_cannot_manage | 403 | A person signed in to the app called a route that only the CLI's management sign-in reaches. An app sign-in is a customer, the owner's own included; the CLI manages after npx gemmein login, approved in the browser, and everything else is in the dashboard |
insufficient_permissions | 403 | The key wasn't approved by the account's owner. Run npx gemmein login and approve it in your browser |
invalid_input | 400 | A field the CLI sent is missing or not one Gemmein accepts, for example a collection name, a domain, a relay's ifVersion or a secret scan's result. The message names the field |
missing_name | 400 | Deleting a collection without naming it. Name the collection |
invalid_alias | 400 | A model alias in gemmein/ai has a name, provider or model Gemmein doesn't accept; the message names the fix |
alias_in_use | 409 | A model alias that AI tools run on can't move to another provider, or to a model a tool can't use; the message names the tools. Point them elsewhere first |
too_many_aliases | 409 | An environment holds at most 50 model aliases. Remove one first |
version_required | 428 | Applying a payments sync without the version the plan read. Update the gemmein CLI and run npx gemmein sync again |
invalid_plans | 400 | gemmein/payments.json, or Live's plans when going live or promoting, would be refused; the message names the plan and the fix |
invalid_products | 400 | gemmein/payments.json, or Live's products when going live or promoting, would be refused; the message names the product and the fix |
name_conflict | 409 | A plan and a product share a name. Rename one of them |
invalid_consent | 400 | The 14-day waiver switch isn't { "digitalWaiver": true, "termsUrl": "https://…" } or { "digitalWaiver": false }, or termsUrl isn't an https address |
consent_terms_url_required | 400 | Turning on the 14-day waiver needs termsUrl, the link to your Terms of Service. Nothing was changed |
payments_closed | 409 | Payments are turned off in this environment, so nothing can be added or copied into it. To sell again, delete payments on the Payments page and set them up anew |
stripe_key_required | 409 | npx gemmein sync: a plan or product sells through Stripe and Stripe isn't connected here. Connect Stripe with a restricted key on the Payments page, then sync again |
price_required | 400 | npx gemmein sync: a plan or product sells through Stripe without a price; the message names it. Gemmein makes its Payment Link from the price, so give it one |
invalid_receipts_collection | 400 | npx gemmein sync: the receipts collection on the Payments page no longer exists here, or isn't addressed (each buyer sees only their own). Pick an addressed collection for receipts, then sync again |
invalid_library_path | 400 | The Library path starts with / and names a page on your own domain, like /library (at most 200 characters) |
invalid_return_path | 400 | The return path starts with / and names a page on your own domain, like /thanks (at most 200 characters) |
invalid_mappings | 400 | npx gemmein sync: a “When Stripe says” rule on the Payments page no longer holds, for example its collection was deleted; the message names the problem. Fix or remove the rule, then sync again |
stripe_account_in_use | 409 | npx gemmein sync changing payments: this environment's Stripe account is connected to another Gemmein account, so payments here can't change. Contact support to move it. Connecting that account's key on the Payments page is refused the same way |
stripe_key_needed | 409 | A plan or product priced to sell through Stripe needs a Stripe key here; the message names the item. Connect Stripe with a restricted key on the Payments page, then sync again |
stripe_key_broken | 409 | Stripe no longer accepts the key Gemmein holds (revoked or changed), so Gemmein stopped using it. Connect a new key, then try again. Payment Links already out keep selling |
stripe_key_too_broad | 409 | The Stripe key was changed and can now do more than Gemmein needs, so Gemmein stopped using it. Fix it in Stripe, then try again |
stripe_key_rejected | 409 | Stripe refused the key while Gemmein worked in Stripe; nothing was saved. Connect Stripe again with a new restricted key |
stripe_key_permissions | 409 | The Stripe key lacks a permission this change needs; the message names it. Add it to the key in Stripe, then try again |
stripe_key_plan_rows | 409 | A first plan sold through Stripe (by a sync, on the Payments page, or by a promote into Live) on a key made with the five permissions for products. A key that sells plans needs two more: Customer portal and Subscriptions, both under Billing, set to Write. Add them to the key in Stripe and save again; nothing was changed |
payments_unreadable | 503 | Connecting a Stripe key on the Payments page: Gemmein couldn't read this environment's payments to check the key. Nothing was saved; try again in a moment |
stripe_unreachable | 502 | Gemmein couldn't reach Stripe. Nothing was saved; try again in a moment |
stripe_terms_url_missing | 409 | Stripe needs your Terms of Service link before a checkout can ask buyers to agree to it. Add it in Stripe (Settings → Business → Public details), then try again. Nothing was changed |
stripe_link_off | 409 | A Payment Link Gemmein turned off can't sell again; the message names the item. Set a price to make a new one |
payments_not_saved | 503 | Gemmein couldn't save payments just now. Nothing was changed; try again in a moment |
payments_unconfirmed | 503 | Gemmein couldn't confirm the payments save. Read payments again (npx gemmein payments --cloud) to see what was saved |
bad_response | the status answered | A payments sync got an answer Gemmein couldn't read; the message carries the start of it. Try again |
no_template | 404 | gemmein emails: that email has no template in this environment, so it sends in Gemmein's plain design. Add gemmein/emails/<name>.html and sync |
template_empty | 400 | An email template with no HTML. Put the template's whole source in the file |
provider_unavailable | 502 | Removing a domain: the mail provider couldn't take its email half down. Nothing was removed; try again in a minute |
already_live | 400 | npx gemmein go-live on an app that is already live. To send later changes to Live, run it again to promote |
not_live | 400 | Promoting before the app has gone live. Go live first |
payment_required | 402 | Going live needs a card on Gemmein billing. Add a payment method and finish billing setup, then go live; charging starts when you go live |
billing_past_due | 402 | Going live or buying a plan: your last payment for Gemmein didn't go through. Update your card in Stripe (Settings → Usage & billing → Open the billing portal), then go live. No payment page is made until then |
billing_paused | 402 | Going live or buying a plan: your Gemmein subscription is paused. Resume it in the billing portal, then try again |
billing_unavailable | 503 | Going live or buying a plan couldn't check billing with Stripe. Nothing changed; try again in a moment |
go_live_blocked | 400 | A collection your users write to has no confirmed shape yet; the message names it. Confirm its shape, then go live |
go_live_busy | 503 | Going live or buying a plan: Gemmein is still finishing your last try. Nothing new was charged. Try again in a minute |
go_live_unfinished | 503 | Going live didn't finish. The message says which of four things happened: the charge was credited; nothing was charged; it was paid, with one step left; or nothing is live yet and nothing was charged, so press Go live again in a minute. Then press Go live again (from an AI app: ask for a new approval) |
plan_changed | 409 | Going live: the plan or its price changed since it was shown. Nothing was charged. Look at the plan again, then go live |
plan_unconfirmed | 503 | Going live: Gemmein couldn't confirm your plan change with Stripe. Nothing is live yet. Press Go live again in a minute |
plan_unreadable | 409 | Going live or buying a plan: Gemmein couldn't read your plan. Try again; if it keeps happening, contact support |
invalid_interval | 400 | Going live: the billing interval must be month or year. Pick one and try again |
invalid_field_decision | 400 | Promoting: a decision for a new field is missing or isn't one Gemmein knows; the message names the field |
shape_changed | 409 | Promoting: a collection's shape changed in Development since the list was shown. Run promote again to see the current shape |
offer_changed | 409 | Promoting: a field changed in Development since the list was shown; the message shows it then and now. Run promote again to see the current shape |
gate_target_missing | 409 | Promoting: a collection or AI tool in Live is unlocked by a plan or product Live doesn't have and this promote won't add; the message names each one. Pick who unlocks it on Live's Data or AI page, then promote again |
payments_changed | 409 | Promoting: Live's payments changed since you looked. Load them again and promote again |
review_required | 428 | Promoting without naming the Development you reviewed. Update the gemmein CLI, then promote again |
development_changed | 409 | Promoting: Development changed since you reviewed it. Review again, then promote |
changed_since_review | 409 | Promoting: an item changed in Development since you reviewed it; the message names it. Review again, then promote |
development_unreadable | 503 | Going live or promoting: Gemmein couldn't read your Development just now. Nothing was changed; try again |
no_paid_plan | 409 | Promoting would leave Live with no paid plan; the message names the plan. Pick Live's paid plan on Live's Payments page first |
not_live | 409 | The app isn't live yet, so Live has nothing to promote or sync to and no Live keys to make. npx gemmein go-live takes it live first; until then, make a Development key |
field_missing_from_declaration | 400 | Promoting: a field Live has is missing from the collection's file; the message names it. Put it back, or mark it "deprecated": true to retire it. A field is never retired by leaving it out |
field_type_change | 400 | Promoting: a live field changed kind in Development. A live field never changes kind: add a new field with the new kind, and mark the old one "deprecated": true |
tighten_needs_decision | 400 | Promoting: a field becomes shorter or required, and the message counts the records it affects. Existing records are never cut or hidden. To apply it to new writes and edits only, send tighten: { "<collection>.<field>": "new-writes-only" }; the dashboard's Promote asks for you |
invalid_relay | 400 | A change to Live you asked to approve names a relay Gemmein would refuse. Fix its file in gemmein/relays/ (npx gemmein sync names what is wrong), then ask again |
too_many_changes | 400 | One approval covers at most 100 changes. Split the change into smaller ones |
beyond | 400 | Your AI's buy_plan, npx gemmein plan buy or Pay now asked for the Beyond tier (above 25 apps), which has no checkout. Talk to Gemmein at hello@gemmein.com; nothing was set up |
plan_ended | 402 | Buying a plan: the account's Gemmein plan has ended, so its Live apps are paused. Restart the plan in Settings → Usage & billing, then buy this app's plan |
not_on_cloud | 503 | Your AI's buy_plan or npx gemmein plan buy on the local runtime (npx gemmein dev). A plan is bought on Gemmein's cloud |
checking_with_stripe | 503 | The plan payment page is asking Stripe whether a payment went through before it closes. Nothing is said about money until Stripe answers. Reload the page in a minute |
payment_confirming | 409 | Stripe has a payment for this app and Gemmein is confirming it. Reload the page in a moment. Nothing is charged twice |
plan_mode_changed | 409 | The plan payment page changed while it was open. Reload the page |
plan_unfinished | 503 | The plan payment didn't finish. The page says whether the charge was credited or nothing was charged. Try again in a few minutes |
Sites
The codes publishing a site can receive: the site tools in a chat (site_draft, site_put_file, site_publish and the rest), npx gemmein publish and npx gemmein site. A publish that fails answers FAILED with its step, the file when known, the reason and the fix; nothing changes at the site's address. See Publish your site.
| Code | Status | Meaning · what to do |
|---|---|---|
sites_unavailable | 503 | Publishing sites is not switched on where this request was answered. Nothing was published; try again later |
development_only | 403 | A site is published from Development, with the Development key. Publish there; the Live site changes only when you approve putting a Development version live |
secret_file | — | A chat put named a file that is never sent to a site: a secret-shaped file (.env, *.pem, *.key, id_rsa and the like) or a dot-file. Nothing was put. A key belongs in a relay or the app's own server; the page uses its public key |
invalid_path | 400 | A path was refused. A site file’s path starts without a slash, has no empty, . or .. parts, uses letters, digits and . _ - ~ (also ! $ & ' ( ) * + , ; = : @), is at most 300 characters and is not under __gemmein/; the message names the rule it broke. A request’s own path with an empty, . or .. segment, a backslash or an encoded separator is refused too: call each route by its own path |
draft_not_found | 404 | No such draft for this app. A draft not published within 7 days is discarded. Start a new one (site_draft) and put the files again |
file_too_large_for_chat | 413 | A file put through a chat carries at most 1 MB (a base64 file about 750 KB). Publish larger files from a computer with npx gemmein publish |
too_many_files | 413 | A site holds at most 2,000 files. Remove files the site does not need, or serve them from storage made for them |
site_too_large | 413 | The draft would be over 50 MiB, the most a site holds. Remove or shrink files, or serve large ones from storage made for them. On a download: that version is too large to hand back as one zip |
invalid_manifest | 400 | npx gemmein publish sent a file list Gemmein can't read: each entry is a path, a hash, a size and a content type, and the message names what is wrong. Update the CLI and publish again |
publish_in_progress | 409 | Another publish of this site is running. Try again in a few seconds |
publish_not_found | 404 | No such publish for this app, or one that was not finished within an hour and was discarded. Run the publish again |
publish_done | 409 | That publish was already finished. Publish again for a new version |
site_file_mismatch | 409 | A file Gemmein holds, or the copy it received, does not match its hash, so it is not served; nothing changed at the site's address. A publish from a computer answers it as FAILED at upload (422), naming the file. Publish the files again |
site_file_missing / size_mismatch | 422 | FAILED at upload: a file in the publish was not sent to Gemmein, or the copy Gemmein received is a different size from the one the publish named. The answer names the file; nothing was put up. Run the publish again: it sends only what is missing |
finalize_timeout | 422 | FAILED at upload: checking the files took longer than the time a publish has, and nothing was put up. Run the publish again: files already checked are kept and are not sent again |
site_publish_cap | 429 | Development publishes each app's site up to 200 times a day, and today's are used. Try again after the time the message names. Batch several changes into one publish; a publish that changes nothing does not count |
site_file_cap | 429 | Development takes up to 4,000 file puts a day for each app from a chat, and today's are used. Try again after the time the message names, or publish from a computer |
site_rollback_cap | 429 | Development rolls each app's site back up to 100 times a day, and today's are used. Try again after the time the message names |
site_revoke_cap | 429 | Development turns each app's previews off up to 20 times a day, and today's are used. Try again after the time the message names |
not_published | 409 | This app has no site published, or nothing is served on the side asked for (rollback, rebuild, turning previews off). Publish first |
already_offline | 409 | The live site is already offline. Rebuilding it, or putting a version live, brings it back |
version_not_found | 404 | This site has no such version. npx gemmein site or site_status lists them |
version_not_published | 409 | That version never went up (it failed, was discarded or is still waiting for its files). Pick a version that was served |
version_files_removed | 410 | Gemmein keeps the files of the newest 50 versions and of what is served. This version keeps its history row but not its files, so it can't be rolled back to, promoted, rebuilt or downloaded. Publish again, or pick a newer version |
approval_plan_changed | 409 | The Live site serves another version than the one you approved rebuilding. Nothing was rebuilt and the approval is not used. Ask again (site_rebuild with env live, or npx gemmein site rebuild --live) |
link_expired | 403 | The site download link has expired. Ask for a fresh one: npx gemmein site pull, site_download or the Site room |
platform_domain | 409 | The domain is the app's Gemmein address, which comes with its site and is not removed through domains. Your own domain can be added alongside it |
publish_in_progress | 503 | Your preview is being updated: another update of this site is finishing. Try again in a minute |
provider_unavailable | 503 | Gemmein couldn't reach the hosting network just now to update the preview. Try again in a few minutes |
provider_unavailable | 502 | Changing or turning off the preview link: not everyone is cut off yet; ask again and Gemmein finishes it. Unpublishing the preview: Gemmein couldn't reach the hosting network and nothing changed; try again in a minute |
publish_budget | 503 | A publish has a fixed time. Checking the files took too long to also test and put up the site in it. Publish again: files already checked aren't checked again |
nothing_to_test | 409 | Testing a site: this app has no draft and no published site to test, the draft has no files yet, or it has no index.html. Open a draft with site_draft and put the files in |
site_test_unavailable | 503 | Testing a site isn't switched on where this request was answered. Publishing still works |
site_test_not_found | 404 | Reading a test run: no run with that id for this app. Reports are kept 7 days, the newest 20 |
site_test_running | 409 | A test of this site has been running longer than a test can. Try again in a minute; site_test_result shows the last runs |
site_test_cap | 429 | Development tests a site up to 20 times a day for each app and 60 times a day for each account, and a publish is tested up to 200 times a day for each app. Today's are used. Try again after the time the message names |
invalid_journey | 400 | Testing a site: journeys is not readable. A run walks at most 3 journeys of 12 steps; the message names the journey and step, and lists the steps the test reads |
site_test_failed | 422 | FAILED at test: an uncaught JavaScript error in the draft's own files, or a page that never finishes loading, stopped the publish. The answer names the file and line and the fix; nothing was put up. Run site_test to see the page, fix it, publish again |
site_test_failed_to_run | 502 | The test could not run. A publish goes ahead and says “Published UNTESTED” with the reason; site_test on its own says it could not run. Try again |
site_test_no_pages | 503 | The test ran out of time before a page loaded, so nothing was tested. A publish answers publish_budget; publish again |
preview_link_off | 409 | The preview link is off, so it has no link to change. Turn it on first; that makes a new link: site_share_link with on: true, npx gemmein site share --on, or Turn on on the Site page |
share_link_changes_capped | 429 | Development changes each app's share link up to 20 times a day, and today's changes are used. The link you have still works; change it again after the time the message names |
not_verified | 409 | Showing your site on a domain that isn't proved yours yet (domain_site, npx gemmein domains show, the domain's page). Add its TXT record and wait for it to verify, then show the site |
email_only | 409 | Showing your site on a domain that only sends the app's email. Add the address you want the site at (like www.yourapp.com) as its own domain |
site_errors_capped | 429 | A site sent its error reports for the hour (120 on Development, 600 on Live), or one visitor sent too many in a few minutes. More reports are dropped until the limit resets |
site_errors_off | 403 | Error reports are switched off for this environment, so its pages' errors aren't collected right now |
stale_report | 401 | An error report's time is more than 5 minutes from now, so it was refused |
wrong_host | 403 | An error report came from an address that isn't this app's site, so it was refused |
Approvals, the CLI and the connector
The codes npx gemmein login, the hosted connector (https://api.gemmein.com/mcp), each Live action, npx gemmein keys and npx gemmein domains can receive, and the ones the approval link's page and Settings → Approvals show. Your app's own requests never receive them. Each message names the fix.
| Code | Status | Meaning · what to do |
|---|---|---|
login_required | 401 | A Live action was asked for by a machine whose Development approval has ended or was revoked. Run npx gemmein login first; the approval lasts 7 days |
local_rail | 404 | An approval was asked for on npx gemmein dev, which needs none. npx gemmein login signs this machine into your cloud app |
invalid_machine | 400 | The approval request carries no machine name (up to 64 characters). The founder reads it on the page the approval link opens |
invalid_scope | 400 | The approval request's scope is neither "development" (npx gemmein login) nor "live" (one Live action) |
invalid_action | 400 | A Live approval request names an action Gemmein doesn't approve. The message lists the ones it does |
invalid_target | 400 | A Live approval request is missing what it changes: the domain, the new name, the template or the key's name |
plan_required | 400 | A Live approval request states no plan: the exact body it will send, or for sync --live each relay, tool and alias it will write |
too_many_pending | 429 | Too many approval requests from this address or network are already waiting. Approve or cancel them on Settings → Approvals, or wait 10 minutes for them to expire |
rate_limited | 429 | Too many approval requests, or too many codes tried, from this network, or more than 20 plan payment pages from one account in an hour (use the page already sent). resetAt says when to try again |
slow_down | 429 | The CLI asked whether a request was approved more often than every few seconds. The answer carries the interval to wait; the CLI follows it |
unknown_request | 404 | The CLI asked about a request Gemmein doesn't have. Run the command again for a fresh link and code |
unknown_code | 404 | On the page the approval link opens: the code doesn't match a request. Check it against the one your terminal printed (a code lasts 10 minutes), or run npx gemmein login again |
not_pending | 409 | On the page the approval link opens: the request can't be answered because it expired (a request lasts 10 minutes) or was already answered. Run the command again for a fresh code |
pick_an_app | 400 | On the page the approval link opens: you have several apps. Choose which one this Development approval is for |
wrong_app | 400 | On the page the approval link opens: a Live request is for the app that asked, and can't be approved for another |
owner_only | 403 | Only the app’s owner switches Customer details for AI assistants (Settings → AI assistants); it is never switched from a chat or the CLI |
owner_required | 403 | On the page the approval link opens: the email signed in to Gemmein doesn't own this app. Sign in with the email that owns it, then approve |
approval_limit | 409 | On the page the approval link opens: 10 approvals already work for this app. Revoke one on Settings → Approvals, then approve this one |
parent_ended | 409 | On the page the approval link opens: the machine that asked has lost its own approval (it ended or was revoked), so there is nothing to approve. Ask your AI to sign in again |
not_active | 409 | On Settings → Approvals: the approval isn't working any more (it ended, was used, or was cancelled), so there is nothing to revoke |
approval_key | 400 | On Settings → API keys: the key was handed to the CLI by a browser approval, so it isn't rotated or revoked there. Revoke the approval on Settings → Approvals; the key ends with it |
live_approval_required | 403 | A Live action arrived without its approval: "this Live action needs your approval in the browser, each time — run it through the CLI (npx gemmein go-live, npx gemmein sync --live), which asks for it" |
live_approval_used | 403 | That approval was already used; each Live action takes its own. Run the command again |
live_approval_expired | 403 | That approval expired: an approval is good for 10 minutes after the click. Run the command again |
live_approval_wrong_action | 403 | That approval was given for a different action; each covers exactly the one it names. Run the command again |
approval_plan_changed | 403 | What your AI is sending differs from what you approved (or Development changed before it ran). Run the command again; it asks for a fresh approval |
sign_in_required | 401 | The hosted connector was asked for a tool that needs your app before the AI app signed in. The answer carries a WWW-Authenticate challenge, and the AI app opens your browser on Gemmein's approval page; after Approve it repeats the call. Approve it, if you asked for it |
invalid_token | 401 | The connector's access token is unknown, expired, or its approval was revoked or ran out (an approval lasts 30 days). The AI app asks you to sign in again; approve it again |
insufficient_scope | 403 | A tool that reads Live was called on a connection without Live reads. The AI app asks for that approval; allow it only for an AI app you trust with your customers' data |
key_in_browser | 403 | An AI app asked for a secret key's value in the chat. A secret key it asks for is made when you approve it in the browser and shown there once; it never comes back into the chat. Use the connector's request_server_key; paste the key yourself into the app host's secret settings |
token_in_url | 400 | The connector's token was sent in the address. Send it in the Authorization header (Bearer), never in the address |
host_refused | 403 | On npx gemmein dev: a request to the local /mcp was addressed to another host. The local runtime answers only requests to this computer (localhost or 127.0.0.1) or the address gemmein dev --host bound |
host_not_allowed | — | A relay's call_url addressed a host that isn't on this app's outbound-host allowlist yet. The relay itself moves to waiting rather than failing outright — add the host at Settings → Hosts, and it retries on its own |
origin_refused | 403 | On npx gemmein dev: a web page on another site called the local /mcp. Only pages served from this computer reach it |
connector_unconfigured | 503 | The hosted connector or its sign-in isn't configured on the server that answered (no public address set for it). Nothing your AI app sends can fix it; on npx gemmein dev the local /mcp needs no sign-in. Write to hello@gemmein.com if https://api.gemmein.com/mcp answers it |
answer_too_large | 200 | A JSON-RPC error inside a 200 answer from /mcp: the tool's answer is larger than the server sends at once. Use search_docs for one passage, or ask for less |
parse_error | 400 | The body sent to /mcp isn't valid JSON (JSON-RPC error -32700). Send one JSON-RPC message, or a batch, as JSON |
unsupported_media_type | 415 | A request to /mcp wasn't sent as JSON. Send JSON-RPC with content-type: application/json |
invalid_request | 400 | A request to /mcp wasn't one JSON-RPC message or a batch of at most 10, or a connector sign-in request is missing what OAuth requires (such as the PKCE code_verifier, 43 to 128 characters). The connector's sign-in addresses answer 405 with the same error when called with the wrong method. Your AI app's MCP client sends these; update it, or connect again |
gone | 410 | A connector sign-in link can't be used: it doesn't match a request, it was opened in a different browser from the one that started the sign-in, or the sign-in was already completed. Connect again from your AI app, or close the tab if it already finished |
unknown_relay | 404 | A connector Live read named a webhook relay Live doesn't have. Check the relay's name on Live's Relays page |
relay_not_found | 404 | An AI asked to delete a relay Live doesn't have, so nothing was asked of you. Check the name on Live's Relays page; a relay deleted in Development stays in Live until Live's own copy is deleted |
ai_tool_not_found | 404 | An AI asked to delete an AI tool Live doesn't have, so nothing was asked of you. Check the name on Live's AI page |
connector_error | — | A tool on the hosted connector couldn't finish: something in the input isn't the shape the guide teaches, or Gemmein hit an error of its own. Whatever was applied before it stopped stands. The AI calls pull to see Development as it is now, checks the input against the guide (search_docs), then tries again. See The hosted connector |
interrupted | — | A Live action was approved and Gemmein was interrupted while carrying it out. live_action_status, the approval page and the CLI report it. When the answer says it may have gone through, check the place it names (Logs, the domain, the site) before asking again. Otherwise nothing changed and the approval can no longer be used. Ask again, and approve the new request |
carried_out_by_approve | 409 | A Live request was polled for a one-use token, which only an older CLI does. Your Approve in the browser carries a Live action out, once, and the CLI reads what happened. Run npx gemmein@latest |
seal_key_required | 400 | A Live key, or a Live push that makes secrets, was asked by a CLI that sent no key to seal them to. Nothing was asked of you. Run npx gemmein@latest and the command again |
seal_key_required | 409 | A Live key was approved for a CLI that sent no key to seal it to, so the key was made and revoked at once. Run npx gemmein@latest and the command again |
invalid_seal_key | 400 | sealTo on a Live request isn't the machine's X25519 public key (base64) for that request. Only a broken or foreign CLI sends one. Run npx gemmein@latest and the command again |
not_retryable | 409 | Try again on the approval page for a request that can't run again: it is still being carried out, it finished, or it was already tried again. Read where the request stands on the page, or with live_action_status |
nothing_to_retry | 409 | Try again on a request Gemmein doesn't carry out, so there is nothing to run again |
not_carried_out | 409 | A Live action that no approval carries out. It is recorded as the reason on a failed answer from live_action_status and on the approval page |
site_failed | — | A site promote or a Live rebuild failed. The site's own FAILED words, with the step, the file and the fix, are the reason on a failed answer from live_action_status and on the approval page |
replace_every_key_setup_failed | — | Approving “Replace every key” (the API keys page, request_replace_every_key, or npx gemmein keys replace-every): Gemmein couldn't start replacing your Live key, so nothing was made or changed, the reason on a failed answer from live_action_status and on the approval page. Ask again |
receipt_required | 403 | The CLI confirmed a hand-over without the receipt that came inside it, or with a wrong one. Only the machine holding the request's key can open the receipt. Run the command again |
handover_expired | 410 | The CLI confirmed a hand-over after its approval ended. A Live key, or the secrets a Live push makes, that was never collected is revoked. Run the command again for a new one |
nothing_handed_over | 404 | The CLI confirmed a hand-over on a request that made nothing for that machine |
live_read_required | 403 | Reading Live's keys or the Live site's errors without the Live-read approval. Your human grants it: an AI app's connection asks again with its Read Live line, and the command line runs npx gemmein login --live-read. Every Live read is on Logs |
step_not_found | 404 | The link of a key page or plan payment page isn't for this Gemmein account, or it has ended. Sign in with the account your AI uses, or ask your AI for a new link |
step_expired | 409 | The link of a key page or plan payment page expired, and nothing changed. Ask your AI for a new link |
step_closed | 409 | A key page had already ended, and nothing changed. Ask your AI for a new link |
step_env_changed | 409 | A key page is for another environment than the one open now. Reload it |
step_attempts | 429 | A key page has had its 10 tries. Ask your AI for a new link |
step_rate_limited | 429 | One account tried more than 30 keys on key pages in an hour. Wait a little, then try again |
wrong_step | 400 | A key page is for another kind of key (Stripe or an AI provider) than the one pasted. Open the page your AI sent for this key |
step_used | 409 | A key was already saved on this page, so nothing more is taken from it |
step_busy | 409 | A key is still being checked on the page. Try again in a moment |
step_open_capped | 429 | An account opened 30 key pages in an hour. Use the page already sent, or try again later |
invalid_provider | 400 | A key page was asked for a provider Gemmein doesn't connect. The provider is openai, anthropic, google, replicate, fal, assemblyai, deepgram, elevenlabs, runway or openai-compatible; a pipeline on your own server has no provider key, so set the tool's URL on its page |
development_only_key | 409 | On a Development key page, the Use Development's key button was pressed. That page is Development's, and its key is pasted on it |
door_error | — | A key page's last try hit an error on Gemmein's side while checking the key, and nothing was saved. Try again |
last_call_failed | — | The problem a provider_key answer reports for a saved key whose last call failed. Make a test call from the key's page on the dashboard to see why |
unknown_ai_tool | 404 | Deleting an AI tool with dev_delete_ai_tool: Development doesn't have a tool by that name. Check the name on the AI page |
not_suspended | 409 | Lifting a suspension with test_member_unsuspend: the test member isn't suspended, and nothing changed |
dashboard_only | 403 | Asked for something an AI never does from a chat: customer details for AI assistants (Settings → AI assistants), revealing or rotating keys, Stripe settings (made in Stripe; the key itself on Payments), sender domains (Domains), or deleting the app or the account. Each is yours to do on the dashboard. The same answer comes for a team or ownership change (an account is one person, so none applies), a retention setting (retention is fixed), or a bulk export, bulk record change or bulk email (Gemmein sends no bulk email). Nothing changed |
refund_in_stripe | — | A refund is never made from a chat. The answer gives you the exact steps: the amount, the customer and the payment in Stripe → Payments → Refund. Once Stripe confirms it, a full refund ends what the payment unlocked and takes back its credits; a part refund leaves what it unlocked in place and takes back its credits in proportion |
already_refunded | 409 | The payment is already refunded in full. Nothing to do |
already_suspended | 409 | The customer is already suspended. Nothing was asked |
nothing_to_cancel | 409 | The customer has no paid subscription to cancel |
subscription_from_relay | 409 | The customer's plan came from a relay (grant_plan), so the system that sent that event decides it — cancel it there, not here |
subscription_unknown | 409 | The customer's plan has no Stripe subscription on record to cancel. Cancel it in Stripe; Gemmein hears it from Stripe's webhook |
reply_has_secret | 400 | A reply to a customer holds something shaped like a secret key. A key never goes to a customer; nothing was asked or sent — rewrite the reply without it |
record_not_found | 404 | An AI's update_record, delete_record or publish_record named a record id Live doesn't have in that collection, so nothing was asked of you. Check the record's id on Live's Data page |
relay_event_not_found | 404 | No relay run with that id in Live. relay_runs lists them |
relay_event_busy | 409 | The run is still queued or running. Only a finished run (done, failed or dead) runs again |
no_approval_needed | 409 | Your AI asked you to approve a Development key. A Development key needs no approval: npx gemmein keys create … --env development makes it at once under this machine's approval, and Logs record it |
Your dashboard app code
The codes the dashboard's sign-in and its Live changes to how the business runs can answer once you turn on an authenticator app (Settings → Security). Your app's own requests never receive them. Lost your phone and your recovery codes? Email support@gemmein.com from your account's address; support checks it by hand.
| Code | Status | Meaning · what to do |
|---|---|---|
app_code_required | 403 | This act needs the code from your authenticator app (6 digits, a new one every 30 seconds), or a recovery code: every Live change to how the business runs (a promote or a Live push, a domain, keys, deleting a collection, relay, AI tool or email template, the Live site), on the dashboard or approving it for an AI, plus deleting the account and turning the app code off. A change to one customer never asks it, and neither does going live. Enter it and try again |
app_code_invalid | 401 | "That code didn't work. Use the newest code from your authenticator app — each one works once — or a recovery code." |
app_code_sign_in_expired | 401 | The sign-in waited more than 5 minutes for the app code, or that step was already used. "Start again with a new email code." |
app_code_wait | 429 | Ten wrong codes in a row: the next try waits (1 minute, then 5, 15, 1 hour and longer, up to 24 hours) and is not checked before then. "Too many tries. Try again in …, or use a recovery code — it works now." A right code after the wait always works, so the account never locks |
app_code_already_on | 409 | Your authenticator app is already on. To change it, turn it off first, which asks for a code from it or a recovery code |
app_code_not_pending | 409 | Setup was never started, or the code you scanned has been replaced. Start setup again |
app_code_not_on | 409 | Your authenticator app isn't on, so there is nothing to turn off |
app_code_spend_unrecorded | 503 | Gemmein couldn't record a spend from the dashboard's own app code just now, and nothing was changed. Try again in a moment |
Test kit
The codes the test kit's commands receive: npx gemmein members, mail, approve, send-back, suspend, sign-out, pay and refund. The kit works in Development, on test members only. From a chat, the hosted connector’s prove_app tool signs in as two test members on the server and proves the app: each person sees only their own records, its relays fire (their email lands in the test mailbox), and each AI tool answers and spends its credits.
| Code | Status | Meaning · what to do |
|---|---|---|
prove_needs_test_members | 409 | The connector’s prove_app tool: a proof signs in as two test members, and Development has fewer than two. Add them first (test_member_add alice, test_member_add bob, or npx gemmein members add alice). It also answers when Development has no public key to sign test members in with: open the app's Development on the dashboard once, then try again |
test_kit_development_only | 403 | A kit command in Live. Nothing changed: Live's people are real, so approving, suspending and signing out there are done on the dashboard |
test_members_full | 409 | Development holds 20 test members. Reuse one; npx gemmein members lists them |
test_kit_capped | 429 | The kit's daily allowance in Development is spent: 200 changes, 20 test checkouts or 20 refunds. resetAt says when it opens again |
not_a_test_member | 403 | The command names a real person, or a post or payment of one. The kit acts on test members only; real members are handled on the dashboard, and real payments are refunded in Stripe |
not_moderated | 409 | approve or send-back in a collection whose posts publish when their author says so. Set "publishing": "owner approves" in its file to review posts first |
not_waiting | 409 | The post isn't waiting: it is already published, was sent back, or its author took it down. A post sent back waits again once its author edits it |
changed_since_read | 409 | The post changed since it was read. Run the command again to read it afresh, then decide |
stripe_not_connected | 409 | pay or refund: Development has no Stripe key. Connect a sandbox (rk_test_) key on Payments first |
test_pay_live_key_refused | 403 | pay or refund: Development's Stripe key isn't a test-mode (sandbox) key, so nothing was charged or refunded. The kit never touches live money |
test_pay_local | 409 | pay on npx gemmein dev, which simulates checkout itself with no Stripe and no money: as the member, g.payments.buy() and g.subscriptions.checkout() return a link that completes the purchase when opened. Test real Stripe in your cloud app's Development |
unknown_item | 404 | pay: the item isn't a product or paid plan sold in Development; the message names what is |
payment_not_found | 404 | refund: no payment with that reference in Development yet. It appears once Stripe's webhook has fulfilled it, which npx gemmein pay waits for |
test_refund_refused | 409 | refund: Stripe refused the refund; the message gives Stripe's reason. If the key lacks the refund permission, the answer is stripe_key_permissions, naming Charges and Refunds: Write |
test_refund_outcome_unknown | 502 | refund: Stripe didn't answer, so the outcome is unknown and the refund may have been made. Check Payments in Logs before trying again; the same refund run again within 24 hours never refunds twice |
test_checkout_unknown | 502 | pay: checking a slow test payment with Stripe failed; the message gives Stripe's reason. Check Payments in Logs, then try again in a moment |
test_pay_unavailable | 501 | This server can't run test payments or refunds through Stripe |
Only on the Errors page
Your app's requests are never answered with these. Each one is a row on the dashboard's Errors page, in the environment it happened in, carrying a sentence that names what happened.
| Code | Status | Meaning · what to do |
|---|---|---|
dev_key_on_live_site | — | In Live, once a day per domain: a page on one of the app's verified domains still calls with the Development key, so customers there reach Development data. "Your live site <domain> is still using the Development key, so customers there reach Development data." npx gemmein domains and npx gemmein check print it too. Run npx gemmein keys app and switch the live build to the Live key (also on Settings → API keys, Live). A preview address that is not a verified domain never raises it |
relay_unsigned_event | — | A relay acts on events its sender doesn't sign, without the opt-in, so every event is refused: "this relay acts on events its sender doesn't sign — every event is refused until you set trigger.trustUnsignedEvents: true on it, or keep it to write_record and call_url". See Unsigned events |
relay_key_as_secret | — | A relay that checks a shared secret holds a public key, so every event is refused: "this relay's stored value is a public key; paste the sender's signing secret". Paste the sender's signing secret on the relay's page |
relays_delayed | — | In Live, a relay's events waited more than 30 seconds to run. They are kept and run in order; the message says whether your own relays were at their limit. See Relays |
relays_held | — | A relay's event waited more than 60 seconds behind an earlier event for the same record or person; the message names the relay it waits behind and why. Resume or fix that relay |
relays_dev_capped | — | A Development app passed 120 immediate relay runs in an hour (300 across your account); the rest run within minutes, none dropped. For volume, run on npx gemmein dev |
email_suppressed | — | 10 of your customers' addresses have bounced or marked your mail as spam, and again at 20, 30 and so on. Gemmein no longer mails those addresses. Check how addresses are collected and what your emails say |
inbound_capped | — | Inbound mail passed this hour's limit (20 from one sender, or 300 from people who aren't your customers); the rest was dropped, not stored. Usually a mail loop or spam. Inbound mail belongs to no environment, so this row is in your app's default environment |
stripe_wrong_mode | — | Stripe sent a test-mode event to Live's address, or a live-mode one to Development's. Nothing was written. Point the Stripe webhook at the right environment |
stripe_payments_closed | — | A payment arrived after payments were turned off. Nothing was granted; refund it in Stripe or grant it by hand |
stripe_payment_unresolved | — | A payment arrived that no plan or product can be named for. Nothing was granted; grant it by hand or refund it in Stripe |
stripe_account_in_use | — | This Stripe account is connected to another Gemmein account, so payments here can't be changed. Connecting that account's key on the Payments page is refused with the same code (409) |
stripe_connection_broken | — | Gemmein's checks found your Stripe key revoked or changed, or its webhook deleted, disabled, pointed elsewhere or missing events. Open Payments to fix it |
payment_erased_payer | — | A payment for an address whose owner deleted their account. Nothing was granted; refund it, or grant it by hand if it is a new purchase |
erased_subscription_active | — | A deleted customer's Stripe subscription is still running. Gemmein can't cancel it for you; cancel it in Stripe |
checkout_ref_invalid | — | A checkout didn't come from your app's checkout for this app, environment and plan, so it granted nothing. Customers buy through your app; refund or cancel it in Stripe |
trial_ref_invalid | — | A free-trial checkout didn't come from your app's trial offer, so it granted nothing. Cancel it in Stripe |
trial_already_used | — | The customer already had a free trial or a paid plan here, so this trial grants nothing. Cancel it in Stripe |
duplicate_subscription | — | One customer has two live subscriptions in Stripe. Gemmein can't cancel in Stripe; cancel one there |
refusals_not_logged | — | More than 200 refused purchases from signed-out visitors in one hour; the rest of that hour were counted, not listed one by one |
Client-side
| Code | Status | Meaning · what to do |
|---|---|---|
key_scope_mismatch | 0 (client-side) | gemmeinServer(sk, { expects }) compared the key's access with what the app needs before its first call, and the key has less or more. A key too broad is refused like a key too narrow. err.missing and err.extra say what, one plain sentence each ("this key cannot write mentor, which the app needs"). Make a key with exactly that access (npx gemmein keys create) and swap it in; revoke the old one |
secret_key_in_client | 0 (client-side) | A secret key (sk_) was handed to gemmeinServer() running in a browser or a mobile app; it refuses to run there. Keep the key on your own server (an API route, a server action, a worker) and call that from the app |
invalid_secret_key | — | gemmeinServer() got a missing or pk_ key; pass the sk_ from a server env var |
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 } 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) | Sign-in couldn't save the session token, so it throws instead of returning a session that would be lost on the next launch. All three of the SDK's stores raise it:
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 itself is valid, so an app that should keep running 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) |
aborted | 0 (client-side) | g.runs.watch stopped because its signal was aborted (in Swift, the calling Task was cancelled). The run itself carries on; call g.runs.get to read it |
Thanks for telling us.
Thanks for telling us.
Last updated