Backend

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:

422 Unprocessable Entity
{
"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 code
if (isRetryable(code)) return retryLater() // server, upstream, rate limit, not configured
toast(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_ERROR
respond.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 it
respond.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_REQUEST400request

The request could not be understood at all.

What to do: Fix the request. Retrying the same one will fail the same way.

INVALID_BODY400request

The 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_FAILED400request

The 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_ERROR422request

The 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_LARGE413request

The request body is larger than the server accepts.

What to do: Send less, or upload the file directly to storage with a presigned URL.

UNAUTHORIZED401auth

The 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_TOKEN401auth

No token was sent where one is required.

What to do: Send the access token as Authorization: Bearer <token>.

INVALID_TOKEN401auth

The 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_LINK400request

A 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_REVOKED401auth

The 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_INVALID403permission

The 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.

FORBIDDEN403permission

The 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_FOUND404notfound

No 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.

CONFLICT409conflict

The 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_CONFLICT409conflict

Somebody 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_LIMITED429limit

Too many requests from this caller.

What to do: Back off. Honour Retry-After if it is present rather than retrying immediately.

INTERNAL_ERROR500server

A 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.

MAINTENANCE503disabled

The API is in maintenance mode and is refusing everything.

What to do: Retry later. Show a maintenance state rather than an error.

PERSIST_FAILED500server

The 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_ORGANIZATION400request

The 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_CREDENTIALS401auth

The 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_PASSWORD401auth

The 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_EXISTS409conflict

An account already has that address.

What to do: Offer sign-in or password reset rather than registration.

EMAIL_NOT_VERIFIED403permission

The 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_DISABLED403permission

The account has been deactivated.

What to do: Do not retry. This needs an administrator, not a different password.

ACCOUNT_LOCKED429limit

Too 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_VERIFIED400request

The address on this link is already confirmed.

What to do: Treat it as success and continue to sign-in.

SOCIAL_AUTH_ONLY400request

The account signs in with a social provider and has no password.

What to do: Offer the provider button instead of the password form.

NO_PASSWORD400request

The account has no password set, so it cannot be confirmed with one.

What to do: Send them through set-a-password first.

UNKNOWN_PROVIDER404notfound

No social provider is configured under that name.

What to do: Only offer the providers the API reports as enabled.

TOKEN_ERROR500server

The 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_ERROR500server

The 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_SIGNATURE401auth

The 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_CODE401auth

The 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_CODE401auth

That 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_TOKEN401auth

The 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_ENABLED409conflict

Two-factor authentication is already on for this account.

What to do: Show it as enabled rather than offering setup.

TOTP_NOT_ENABLED400request

The account has no second factor, so there is nothing to confirm or turn off.

What to do: Offer setup instead.

TOTP_ERROR500server

The 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_CONFIGURED501disabled

This 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_REJECTED410state

The 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_ADDRESS422request

The recovery email or phone number is not usable.

What to do: Validate the format before sending, and show which one was rejected.

INVALID_CODE422request

The recovery code is wrong or has expired.

What to do: Offer to send a new one rather than retrying the same code.

SMS_NOT_CONFIGURED501disabled

No SMS provider is configured, so phone recovery cannot run here.

What to do: Offer email recovery instead.

SMS_FAILED502upstream

The SMS provider refused or failed to send.

What to do: Retry once, then offer email. The number may be unreachable.

API keys

API_KEY_REQUIRED401auth

The 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_KEY401auth

The key is unknown, revoked or expired.

What to do: Issue a new key. Do not retry with the same one.

ENDPOINT_NOT_ALLOWED403permission

The 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_ALLOWED403permission

The 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_ALLOWED403permission

A 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_FILE400request

No file was attached, or it could not be read.

What to do: Send multipart form data with the documented field name.

INVALID_FILE_TYPE400request

The 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_LARGE400request

The file is larger than this endpoint accepts.

What to do: Show the limit before the upload starts rather than after it finishes.

UPLOAD_FAILED500server

The 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_FOUND404notfound

Nothing is stored under that key.

What to do: Treat it as absent: the key is wrong, or the object was deleted.

PRESIGN_FAILED500server

A presigned upload URL could not be produced.

What to do: Retry once, then fall back to uploading through the API.

STORAGE_UNAVAILABLE503disabled

Object storage is not configured here, or is not answering.

What to do: Retry later. Nothing the client sends will fix it.

AI gateway

AI_UNAVAILABLE503disabled

No AI provider is configured in this deployment.

What to do: Hide AI features unless the API reports one as available.

AI_UNAUTHORIZED502upstream

The 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_FORBIDDEN502upstream

The provider refused this request, usually its own policy.

What to do: Do not retry the same prompt unchanged.

AI_RATE_LIMITED429limit

The provider is rate-limiting this deployment.

What to do: Back off and retry with a delay. Queue rather than loop.

AI_MODEL_NOT_FOUND502upstream

The 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_ERROR502upstream

The 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_UNAVAILABLE503disabled

Redis 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_STATUS400request

That queue state is not one the endpoint accepts.

What to do: Use one of the documented states.

RETRY_FAILED500server

The job could not be re-queued.

What to do: Retry once. The job is still where it was.

CLEAR_FAILED500server

The queue could not be cleared.

What to do: Retry once, then look at the Redis connection.

CSV import

JOB_ERROR500server

The import job could not be started.

What to do: Retry once. Nothing was imported.

TEMP_ERROR500server

The upload could not be buffered to disk before importing.

What to do: Retry once. Check free disk on the server if it repeats.

INVALID_CSV400request

The 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_AVAILABLE400request

That 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_SCHEDULE400request

The schedule is not a cron expression this API accepts.

What to do: Validate the expression client-side, or offer fixed choices.

EXTRACT_FAILED400request

The 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_FAILED500server

The 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_FAILED500server

The erasure did not complete.

What to do: Do not assume anything was erased. Retry, and check the audit log.

SELF_ERASE400request

An account cannot erase itself through this endpoint.

What to do: Have another administrator run it, so the action has an actor who remains.

QUERY_FAILED500server

The audit query failed.

What to do: Retry once, then report it.

VERIFY_FAILED500server

The 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_REFUSED409conflict

The 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_SETTING404notfound

No setting is registered under that key.

What to do: Read the settings list rather than guessing keys.

SETTING_REJECTED422request

The 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_UNAVAILABLE500server

The settings store could not be read or written.

What to do: Retry once, then report it.

NO_SCOPE400request

The 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_USE409conflict

Variants 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_USE409conflict

That value is part of existing variants.

What to do: Delete those variants first.

CANNOT_GENERATE422request

The combinations could not be generated from the options given.

What to do: Check that every option has at least one value.

Access reviews

REVIEW_CLOSED400state

The review is closed, so its decisions cannot change.

What to do: Open a new review rather than editing a closed one.

REVIEW_INCOMPLETE400state

Some items still have no decision, so the review cannot be completed.

What to do: Show which items are outstanding.

ITEM_LOCKED400state

That item already has a decision and will not take another.

What to do: Re-read the review before submitting again.

INVALID_DECISION400request

That is not a decision this review accepts.

What to do: Use one of the documented decisions.

CANNOT_COMPLETE400state

The review cannot be completed in its current state.

What to do: Re-read it: the message says what is missing.

Public forms

PASSWORD_REQUIRED401auth

The shared form is password-protected.

What to do: Prompt for the form's password and send it with the submission.

SUBMISSION_FAILED400request

The 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_MODEL400request

The request did not name a model to sync.

What to do: Send the model name the sync manifest lists.

UNKNOWN_MODEL400request

No model is registered under that name.

What to do: Read the manifest rather than hard-coding names.

NOT_SYNCABLE400request

That model is not exposed to offline sync.

What to do: Only sync models the manifest marks as syncable.

INVALID_SINCE400request

The since parameter is not an RFC3339 timestamp.

What to do: Send the cursor the last sync returned, unchanged.

Workflows

INVALID_TRANSITION422state

That 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_REFUSED422state

A 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_MOVE422request

That 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_FAILED400request

The chart could not be built from those parameters.

What to do: Check the resource and preset against the ones the dashboard offers.

STATS_FAILED400request

The 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_ERROR500server

The PDF could not be rendered.

What to do: Retry once. The record itself is unaffected.

Security and metrics dashboards

DB_ERROR500server

A query behind a dashboard failed.

What to do: Retry once, then report it.

SENTINEL_OFF503disabled

Sentinel 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_OFF503disabled

Pulse 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.