Error codes
Every error this API can return, what it means, and what a client should do about it. 106 codes, and each one always arrives with the same status.
A client needs two things from an error: a code it can branch on, and the certainty that the code comes with the same status every time. Grit had the first and not the second. VALIDATION_ERROR came back as 422 from thirty-eight handlers and 400 from twenty-five, INVALID_TOKEN as both 401 and 400, and no page listed the codes at all, so the only way to learn one was to trigger it.
This page is generated from the same catalogue that generates internal/respond/codes.go in your project and packages/shared/types/errors.ts beside it. They cannot disagree, and a test in the CLI refuses any code a handler returns that is not listed here, or that is returned with a status other than the one shown.
The shape
Every error is the same envelope, whatever went wrong:
{"error": {"code": "VALIDATION_ERROR","message": "Check the highlighted fields","details": {"email": "That address is already in use","price": "must be greater than 0"}}}
details is present on a validation failure and maps each field to what is wrong with it. Branch on code rather than on the status: the status groups errors, the code says which one it is.
In TypeScript
The union makes the switch exhaustive, so a client that handles five codes and forgets the sixth fails to compile instead of falling through to "Something went wrong".
import { API_ERRORS, documentedErrorCode, isRetryable } from '@shared/types'try {await api.post('/api/v1/invoices', body)} catch (err) {const code = documentedErrorCode(err)if (!code) throw err // not one of ours: a proxy, or your own codeif (isRetryable(code)) return retryLater() // server, upstream, rate limit, not configuredtoast(API_ERRORS[code].client) // what the person should do about it}
In Go
Handlers return a code, and the status comes from the catalogue rather than being typed next to it. That is what keeps one code on one status.
// 422, because that is what the catalogue gives VALIDATION_ERRORrespond.Fail(c, respond.CodeValidationError, "Check the highlighted fields",map[string]string{"email": "That address is already in use"})// 403, with a message written for the person reading itrespond.Fail(c, respond.CodeForbidden, "Only an owner can archive an invoice")
A rule your own code breaks does not need a new code: respond.Rule("debits and credits do not balance") returned from a service or a GORM hook becomes a 422 with that sentence. Add a code to the catalogue only when a client should branch on it.
The catalogue
Grouped by the part of the API that raises them. The first group is the one every endpoint can answer with.
Every endpoint
BAD_REQUEST400requestThe request could not be understood at all.
What to do: Fix the request. Retrying the same one will fail the same way.
INVALID_BODY400requestThe body was not valid JSON, or was not the shape this endpoint reads.
What to do: Send a JSON body matching the documented request type.
READ_BODY_FAILED400requestThe body could not be read to the end.
What to do: Retry. If it keeps happening, the connection is dropping or the body is larger than the server accepts.
VALIDATION_ERROR422requestThe body parsed, and a field in it is missing or not acceptable.
What to do: Read error.details: it maps each field to what is wrong with it. Show those against the inputs.
PAYLOAD_TOO_LARGE413requestThe request body is larger than the server accepts.
What to do: Send less, or upload the file directly to storage with a presigned URL.
UNAUTHORIZED401authThe credentials are missing, expired or not accepted.
What to do: Refresh the access token, and sign in again if the refresh is rejected too.
MISSING_TOKEN401authNo token was sent where one is required.
What to do: Send the access token as Authorization: Bearer <token>.
INVALID_TOKEN401authThe token is malformed, expired, or was issued for something else.
What to do: Refresh it. A refresh token that fails this way has been used already or revoked: sign in again.
INVALID_LINK400requestA one-time link, such as email verification or password reset, is wrong or has expired.
What to do: Offer to send a new link. This is not a sign-in failure: the caller has no credentials to fix, which is why it is 400 and not 401.
SESSION_REVOKED401authThe session behind this token was signed out, on this device or another.
What to do: Sign in again. Do not retry with the same refresh token: reuse is what revoked it.
CSRF_INVALID403permissionThe CSRF token is missing or does not match the cookie.
What to do: Read the CSRF cookie and send it back in the header on every unsafe request.
FORBIDDEN403permissionThe caller is signed in and this action is not theirs to take.
What to do: Do not retry. Hide the action rather than letting it fail, if the role is known to the client.
NOT_FOUND404notfoundNo such row, or none this caller is allowed to see.
What to do: Treat it as absent. On an owned resource this is also the answer for somebody else's row, on purpose: a wrong guess cannot be told from a right one.
CONFLICT409conflictThe write collided with the state already there, such as a unique column.
What to do: Re-read, show what is there, and let the person decide.
VERSION_CONFLICT409conflictSomebody else changed the row since the version in your If-Match.
What to do: The response carries the current version. Re-read, merge, and send the new ETag.
RATE_LIMITED429limitToo many requests from this caller.
What to do: Back off. Honour Retry-After if it is present rather than retrying immediately.
INTERNAL_ERROR500serverA fault on the server. The message is deliberately vague; the detail is in the server log.
What to do: Retry once, then report it. Nothing the client changes will help.
MAINTENANCE503disabledThe API is in maintenance mode and is refusing everything.
What to do: Retry later. Show a maintenance state rather than an error.
PERSIST_FAILED500serverThe change was accepted and could not be written.
What to do: Retry once. Treat the write as not having happened.
Organizations (the multitenant plugin)
NO_ORGANIZATION400requestThe row belongs to an organization and the request has no active one: the caller belongs to none, or to several and named neither.
What to do: Send the active organization as X-Organization-ID. If the caller belongs to no organization, they cannot read this at all: put them in one, or send them somewhere that does not need one.
Sign-in and accounts
INVALID_CREDENTIALS401authThe email and password do not match an account.
What to do: Say only that the details are wrong: which of the two it was is deliberately not reported.
INVALID_PASSWORD401authThe password given for a confirmation step is not correct.
What to do: Ask again. This is the re-authentication prompt, not a sign-in.
EMAIL_EXISTS409conflictAn account already has that address.
What to do: Offer sign-in or password reset rather than registration.
EMAIL_NOT_VERIFIED403permissionThe account exists and its address has not been confirmed.
What to do: Send them to the verification flow, and offer to resend the link.
ACCOUNT_DISABLED403permissionThe account has been deactivated.
What to do: Do not retry. This needs an administrator, not a different password.
ACCOUNT_LOCKED429limitToo many failed attempts, so the account is locked for a while.
What to do: Show the wait, and offer password reset. Retrying sooner extends nothing but the lock.
ALREADY_VERIFIED400requestThe address on this link is already confirmed.
What to do: Treat it as success and continue to sign-in.
SOCIAL_AUTH_ONLY400requestThe account signs in with a social provider and has no password.
What to do: Offer the provider button instead of the password form.
NO_PASSWORD400requestThe account has no password set, so it cannot be confirmed with one.
What to do: Send them through set-a-password first.
UNKNOWN_PROVIDER404notfoundNo social provider is configured under that name.
What to do: Only offer the providers the API reports as enabled.
TOKEN_ERROR500serverThe access and refresh tokens could not be issued.
What to do: Retry once. The credentials were accepted, so do not ask for them again.
USER_ERROR500serverThe signed-in account could not be loaded, which means the token outlived its row.
What to do: Sign in again. If it repeats, the account data is inconsistent and needs a look.
Incoming webhooks
INVALID_SIGNATURE401authThe webhook signature does not match the body and the shared secret.
What to do: Sign the exact bytes sent, with the secret for this endpoint. A reformatted body will not verify.
Two-factor authentication
INVALID_TOTP_CODE401authThe six-digit code is wrong or has expired.
What to do: Let them try the next code. Clock drift on the device is the usual cause of repeated failures.
INVALID_BACKUP_CODE401authThat backup code is wrong, or has been used.
What to do: Each code works once. Offer the remaining count the status endpoint reports.
INVALID_PENDING_TOKEN401authThe short-lived token between password and second factor is expired or unknown.
What to do: Start the sign-in again from the password step.
TOTP_ALREADY_ENABLED409conflictTwo-factor authentication is already on for this account.
What to do: Show it as enabled rather than offering setup.
TOTP_NOT_ENABLED400requestThe account has no second factor, so there is nothing to confirm or turn off.
What to do: Offer setup instead.
TOTP_ERROR500serverThe secret, QR code or backup codes could not be produced.
What to do: Retry once, then report it. Nothing is half-enabled: setup only counts once confirmed.
Passkeys
PASSKEYS_NOT_CONFIGURED501disabledThis deployment has no passkey configuration, so the endpoints are inert.
What to do: Hide passkey buttons unless the API reports the feature as available.
PASSKEY_REJECTED410stateThe challenge no longer exists: it expired, or it was already answered.
What to do: Start the ceremony again. Do not retry the same assertion.
Account recovery
INVALID_RECOVERY_ADDRESS422requestThe recovery email or phone number is not usable.
What to do: Validate the format before sending, and show which one was rejected.
INVALID_CODE422requestThe recovery code is wrong or has expired.
What to do: Offer to send a new one rather than retrying the same code.
SMS_NOT_CONFIGURED501disabledNo SMS provider is configured, so phone recovery cannot run here.
What to do: Offer email recovery instead.
SMS_FAILED502upstreamThe SMS provider refused or failed to send.
What to do: Retry once, then offer email. The number may be unreachable.
API keys
API_KEY_REQUIRED401authThe route is key-guarded and no key was sent.
What to do: Send the key in the documented header. A user token is not a substitute here.
INVALID_API_KEY401authThe key is unknown, revoked or expired.
What to do: Issue a new key. Do not retry with the same one.
ENDPOINT_NOT_ALLOWED403permissionThe key is valid and is not allowed to call this endpoint.
What to do: Widen the key's endpoint list, or call it with one that may.
ORIGIN_NOT_ALLOWED403permissionThe request's Origin is not on the key's allowlist.
What to do: Add the origin to the key, rather than relaxing CORS for everybody.
PUBLISHABLE_KEY_NOT_ALLOWED403permissionA publishable key was used where only a secret key is accepted.
What to do: Call this from the server with the secret key. A publishable key is public by design.
Uploads and storage
INVALID_FILE400requestNo file was attached, or it could not be read.
What to do: Send multipart form data with the documented field name.
INVALID_FILE_TYPE400requestThe file's type is not accepted for this field.
What to do: Check the type client-side before uploading, and say which types are allowed.
FILE_TOO_LARGE400requestThe file is larger than this endpoint accepts.
What to do: Show the limit before the upload starts rather than after it finishes.
UPLOAD_FAILED500serverThe file reached the API and could not be stored.
What to do: Retry once. Nothing was recorded, so there is no half-uploaded row to clean up.
UPLOAD_NOT_FOUND404notfoundNothing is stored under that key.
What to do: Treat it as absent: the key is wrong, or the object was deleted.
PRESIGN_FAILED500serverA presigned upload URL could not be produced.
What to do: Retry once, then fall back to uploading through the API.
STORAGE_UNAVAILABLE503disabledObject storage is not configured here, or is not answering.
What to do: Retry later. Nothing the client sends will fix it.
AI gateway
AI_UNAVAILABLE503disabledNo AI provider is configured in this deployment.
What to do: Hide AI features unless the API reports one as available.
AI_UNAUTHORIZED502upstreamThe provider rejected the server's API key.
What to do: Nothing for the client to do. The key on the server is wrong or out of credit.
AI_FORBIDDEN502upstreamThe provider refused this request, usually its own policy.
What to do: Do not retry the same prompt unchanged.
AI_RATE_LIMITED429limitThe provider is rate-limiting this deployment.
What to do: Back off and retry with a delay. Queue rather than loop.
AI_MODEL_NOT_FOUND502upstreamThe provider does not know the model that was asked for.
What to do: Pick a model the API lists. A model name can disappear without notice.
AI_ERROR502upstreamThe provider failed in a way that is not one of the above.
What to do: Retry once. The message carries what the provider said.
Background jobs
REDIS_UNAVAILABLE503disabledRedis is not configured or not reachable, so the queue cannot be read.
What to do: Retry later. Jobs, cache and cron all depend on it.
INVALID_STATUS400requestThat queue state is not one the endpoint accepts.
What to do: Use one of the documented states.
RETRY_FAILED500serverThe job could not be re-queued.
What to do: Retry once. The job is still where it was.
CLEAR_FAILED500serverThe queue could not be cleared.
What to do: Retry once, then look at the Redis connection.
CSV import
JOB_ERROR500serverThe import job could not be started.
What to do: Retry once. Nothing was imported.
TEMP_ERROR500serverThe upload could not be buffered to disk before importing.
What to do: Retry once. Check free disk on the server if it repeats.
INVALID_CSV400requestThe file is not readable as CSV.
What to do: Check the delimiter, the quoting and that the header row matches the template.
Backups and restore
NOT_AVAILABLE400requestThat backup cannot be downloaded: it is not finished, or it is not stored here.
What to do: Re-read the backup's status before offering a download link.
INVALID_SCHEDULE400requestThe schedule is not a cron expression this API accepts.
What to do: Validate the expression client-side, or offer fixed choices.
EXTRACT_FAILED400requestThe archive could not be opened or does not hold what a restore needs.
What to do: Upload an archive this API produced. A re-zipped one usually fails here.
GDPR and the audit log
EXPORT_FAILED500serverThe subject-access export could not be assembled.
What to do: Retry once, then report it: this is a request with a legal clock on it.
ERASE_FAILED500serverThe erasure did not complete.
What to do: Do not assume anything was erased. Retry, and check the audit log.
SELF_ERASE400requestAn account cannot erase itself through this endpoint.
What to do: Have another administrator run it, so the action has an actor who remains.
QUERY_FAILED500serverThe audit query failed.
What to do: Retry once, then report it.
VERIFY_FAILED500serverThe audit chain could not be verified.
What to do: Report it. This is the check that says whether the log has been tampered with.
RESEAL_REFUSED409conflictThe audit chain was not broken where the reseal claimed, so nothing was resealed.
What to do: Verify the chain again and reseal from the entry the verification names.
Settings
UNKNOWN_SETTING404notfoundNo setting is registered under that key.
What to do: Read the settings list rather than guessing keys.
SETTING_REJECTED422requestThe value did not pass the setting's own validation.
What to do: Show the message against the field: it comes from the setting's rule.
SETTINGS_UNAVAILABLE500serverThe settings store could not be read or written.
What to do: Retry once, then report it.
NO_SCOPE400requestThe request did not say which scope to act in.
What to do: Send the scope the settings list gives for that key.
Product variants
OPTION_IN_USE409conflictVariants are built on this option, so it cannot be removed.
What to do: Clear the combinations that use it first, and say so rather than failing silently.
VALUE_IN_USE409conflictThat value is part of existing variants.
What to do: Delete those variants first.
CANNOT_GENERATE422requestThe combinations could not be generated from the options given.
What to do: Check that every option has at least one value.
Access reviews
REVIEW_CLOSED400stateThe review is closed, so its decisions cannot change.
What to do: Open a new review rather than editing a closed one.
REVIEW_INCOMPLETE400stateSome items still have no decision, so the review cannot be completed.
What to do: Show which items are outstanding.
ITEM_LOCKED400stateThat item already has a decision and will not take another.
What to do: Re-read the review before submitting again.
INVALID_DECISION400requestThat is not a decision this review accepts.
What to do: Use one of the documented decisions.
CANNOT_COMPLETE400stateThe review cannot be completed in its current state.
What to do: Re-read it: the message says what is missing.
Public forms
PASSWORD_REQUIRED401authThe shared form is password-protected.
What to do: Prompt for the form's password and send it with the submission.
SUBMISSION_FAILED400requestThe submission was refused: a field, a file or the form's own rules.
What to do: Show the message. It is written for the person filling the form in.
Offline sync
MISSING_MODEL400requestThe request did not name a model to sync.
What to do: Send the model name the sync manifest lists.
UNKNOWN_MODEL400requestNo model is registered under that name.
What to do: Read the manifest rather than hard-coding names.
NOT_SYNCABLE400requestThat model is not exposed to offline sync.
What to do: Only sync models the manifest marks as syncable.
INVALID_SINCE400requestThe since parameter is not an RFC3339 timestamp.
What to do: Send the cursor the last sync returned, unchanged.
Workflows
INVALID_TRANSITION422stateThat move is not declared in the workflow for this status.
What to do: Offer only the transitions the API lists for the current status.
TRANSITION_REFUSED422stateA transition hook refused the move, and nothing was written.
What to do: Show the message: it is the business rule that said no.
Trees
INVALID_MOVE422requestThat move would put a node inside its own subtree, or under a parent that cannot hold it.
What to do: Refuse the drop in the UI rather than sending it.
Charts and statistics
CHART_FAILED400requestThe chart could not be built from those parameters.
What to do: Check the resource and preset against the ones the dashboard offers.
STATS_FAILED400requestThe statistics could not be computed. This one deliberately conflates an unknown resource with a failed query, so a dashboard widget can render an error state instead of crashing.
What to do: Render the widget's error state. The message says which of the two it was.
PDF rendering
PDF_ERROR500serverThe PDF could not be rendered.
What to do: Retry once. The record itself is unaffected.
Security and metrics dashboards
DB_ERROR500serverA query behind a dashboard failed.
What to do: Retry once, then report it.
SENTINEL_OFF503disabledSentinel is not enabled in this deployment, so there is nothing to report.
What to do: Hide the security dashboard unless the API says it is on.
PULSE_OFF503disabledPulse is not enabled in this deployment.
What to do: Hide the metrics dashboard unless the API says it is on.
Your own codes
Nothing stops a handler you wrote from returning a code of its own, and the envelope is the same either way. Two things to know. The catalogue test only covers the framework's and the generator's handlers, so your codes are yours to document. And documentedErrorCode() returns null for a code it does not know, while apiErrorCode() from @shared/types returns the raw string: use the second one when you branch on your own.
