API error and status reference
The unified API error shape and what each status means across the route surface.
Every ErmisAI API route handler answers failures with the same JSON envelope. This page documents that envelope, the status vocabulary the codebase actually uses, and the domain error codes you will see in practice — with the verbatim English message each one carries.
The error envelope
All typed errors are produced by two helpers in src/lib/utils/error-format.ts.
createRouteErrorResponse (src/lib/utils/error-format.ts:330-345) writes:
{
"queuePosition": 3,
"queuedCount": 12,
"deliveredToday": 20,
"code": "story_queued",
"error": "Story is currently queued.",
"message": "Story is currently queued."
}errorandmessagealways carry the same string.codeis present only when the thrower supplied one.- Any
payloadkeys are spread at the top level of the object, not nested.errorandmessageare written after the spread and always win; a payloadcodesurvives only when the thrower supplied no code of its own.
createInternalRouteErrorResponse (error-format.ts:313-328) is the catch-all for unexpected throws.
It logs to stderr, forwards to Sentry, and returns { error, message } with no code and status
500 unless overridden.
RouteError
Domain modules throw RouteError (error-format.ts:38-54), which carries status, code, and
payload. Route handlers convert it with a single guard:
import { createRouteErrorResponseFromThrown } from '@/lib/utils/error-format'
catch (err) {
return (
createRouteErrorResponseFromThrown(err) ??
createInternalRouteErrorResponse({
context: 'api:stories::storyId:draft:patch',
error: err,
message: 'Failed to save the story draft',
})
)
}createRouteErrorResponseFromThrown returns null for anything that is not a RouteError, so the
handler decides the fallback. Not every route uses this path — several hand-write Response.json(...)
with an inline status.
Not every error response is uniform. Bare Response.json({ error: 'Forbidden' }, { status: 403 }) is
common for auth denials and carries no code. POST /api/stories/[storyId]/completion returns
{ code, message } with no error key on 401 and 400
(src/app/api/stories/[storyId]/completion/route.ts:183-189, 243-249). Treat error and message as
interchangeable and both as optional when writing a client.
Reading errors from a client
Client components never call fetch directly; they go through the typed helpers in src/lib/api/*,
which share one wrapper (src/lib/api/client.ts:76-103). It throws ApiError with:
Proprietà
Tipo
A response that parses as JSON but fails its zod contract throws ApiError with status 500 and
code invalid_response_payload — a client-side synthetic error, never something the server sent
(client.ts:113-121).
X-Ermis-Trace and X-Ermis-Source are set by the client on outbound requests and echoed back by
middleware on every response (src/proxy.ts:180-193), so the trace id in an ApiError matches the
[request-origin] line in the server log. These headers are only populated inside the tenant
workspace — AppTelemetryProvider is mounted in src/app/(tenant)/app/layout.tsx only, so admin and
marketing requests carry no trace id.
What each status means here
| Status | Meaning in this codebase |
|---|---|
| 400 | Payload failed zod validation, or a URL/endpoint failed format validation |
| 401 | No Clerk session, missing API key, or a bad webhook signature |
| 402 | Capacity or quota exhausted — the delivery cap and the AI envelope both use this |
| 403 | Role gate, plan gate, or BotID rejection |
| 404 | Not found, or a flag-gated feature that is switched off |
| 409 | State conflict: optimistic-concurrency loss, wrong editorial state, seat limit, plan already active |
| 410 | A one-time WordPress connect session was missing or expired |
| 422 | Content filtered, context limit exceeded, or an unsupported alert delivery mode |
| 429 | Edge rate limit, AI usage window, concurrent-stream limit, or platform spend cap |
| 502 | The AI provider returned something unusable |
| 503 | A dependency is unavailable (Postgres, Polar, the AI runtime) or a kill switch is on |
| 504 | The AI provider timed out |
402 is not a payment prompt. It is the status for "you are over an allowance": story_queued when the
daily delivery cap is reached, and four ai_* codes when the monthly AI envelope is reached.
Authentication and authorisation
There is no global middleware auth gate for API routes — every handler authenticates itself, and
tests/api-route-auth-guard.vitest.ts fails CI if a new route.ts carries no recognised gate. See
Route surfaces, proxy and auth gates.
The two denials are literal and uncoded:
| Status | Body |
|---|---|
| 401 | {"error":"Unauthorized"} |
| 403 | {"error":"Forbidden"} |
Both appear verbatim in the admin session helpers (for example
src/app/api/admin/flags/route.ts:13-36), the tenant routes, and src/lib/api/integrations-access.ts:35-57.
Because they carry no code, a client cannot distinguish "wrong role" from "wrong plan" from the code
alone — only the message differs, and only for plan gates.
Bot-gated routes answer {"error":"Bot traffic blocked","code":"botid_rejected"} with status 403
(src/lib/api/bot-guard.ts:32). Only three routes verify server-side: POST /api/integrations/webhook/test,
POST /api/newsroom/profile/custom-sources, and POST /api/newsroom/profile/custom-sources/validate.
Cron routes reject with {"error":"Unauthorized"} and status 401 when the
Authorization: Bearer ${CRON_SECRET} header does not match under a constant-time comparison.
isAuthorizedCronRequest returns false when CRON_SECRET is unset, so a missing variable cannot
open the endpoint (src/lib/api/cron-auth.ts:46-65).
Rate limiting
buildRateLimitErrorResponse (src/lib/api/rate-limit.ts:218-241) returns status 429 with:
{
"error": "Too many requests from this network. Please retry shortly.",
"code": "rate_limited",
"scope": "ip",
"retryAfterSeconds": 42
}The per-user message is "You have sent too many AI requests. Please wait and retry.". Headers on
every rate-limit response: Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining,
X-RateLimit-Reset.
Limits in force (rate-limit.ts:42-77), IP first then user:
| Key | Per IP | Per user | Applied at |
|---|---|---|---|
ai:chat | 20 / 60 s | 30 / 60 s | POST /api/stories/[storyId]/chat |
ai:completion | 30 / 60 s | 60 / 60 s | POST /api/stories/[storyId]/completion |
ai:draft-compose | 10 / 60 s | 15 / 60 s | POST /api/stories/[storyId]/draft/compose |
integration:m2m | 60 / 60 s | 120 / 60 s | CMS/WordPress export, connect exchange, disconnect |
integration:webhook-test | 10 / 60 s | 15 / 60 s | POST /api/integrations/webhook/test |
integration:source-validate | 20 / 60 s | 30 / 60 s | custom-sources create and validate |
team:invite | 10 / 60 s | 20 / 60 s | POST /api/team/invite |
account:delete | 5 / 3600 s | 3 / 3600 s | DELETE /api/account |
email:unsubscribe | 10 / 60 s | — | /unsubscribe |
The keys ai:compose and ai:global are declared in the config maps but have no call site.
Edge rate limiting fails open. When the Upstash environment variables are absent getLimiter
returns null, and when Redis is configured but unreachable the limiter catch block returns an
allowed decision (rate-limit.ts:203-215). This is deliberate — burst limiting is abuse prevention,
not billing — but it means a missing Redis silently removes every limit in the table above. Spend
stays bounded by the per-tenant guardrail, which does not fail open on standing checks.
Story access and the feed
| Code | HTTP | Message |
|---|---|---|
story_queued | 402 | Story is currently queued. |
story_access_unavailable | 503 | Story access is temporarily unavailable. |
stories_feed_unavailable | 503 | Stories feed unavailable |
story_queued is returned when the story is over the tenant's daily delivery cap. The payload carries
queuePosition, queuedCount, and deliveredToday (src/lib/stories/story-access-response.ts:7-18).
The cap resets at 00:00 UTC. See Plan and quota reference.
The same story_queued code ships two different messages. createStoryAccessErrorResponse says
Story is currently queued.; the chat route's own copy says Story is currently unavailable.
(src/app/api/stories/[storyId]/chat/route.ts:236-244). Branch on the code, never on the message.
story_access_unavailable is only the fallback. When the underlying failure is a
TenantEntitlementUnavailableError, its code is passed through instead — in practice
cost_envelope_unavailable, because the delivery cap resolution needs the billing entitlement
(src/lib/platform/local-platform-data.ts:2044-2065).
stories_feed_unavailable carries an extra details string with the underlying error message and is
returned when the feed build throws or exceeds the 95-second route budget
(src/app/api/stories/route.ts:23, 104-117).
Draft lifecycle and editorial review
| Code | HTTP | Message |
|---|---|---|
story_draft_owner_conflict | 403 | Members can only edit drafts they own. (and per-route variants) |
story_draft_initial_compose_required | 409 | Generate the first article draft before submitting for review. |
story_draft_published_locked | 409 | Published drafts cannot be moved back to review. |
story_draft_locked | 409 | Draft cannot be updated in its current state |
story_draft_compose_state | 409 | Only draft-state stories or placeholder drafts can be generated from compose. |
story_draft_compose_state_conflict | 409 | Only draft-state stories or placeholder drafts can be generated from compose. |
editorial_draft_version_conflict | 409 | This story was updated by someone else. Refresh and try again. |
invalid_chat_history | 409 | Stored chat history was reset due to schema mismatch. Please retry your message. |
story_draft_owner_conflict is the member-ownership gate — a member may only mutate a draft they own
or an unclaimed one. The message is per route: Members can only edit drafts they own.
(draft/route.ts:75), Members can only submit drafts they own. (draft/submit-review/route.ts:89),
Members can only compose drafts they own. (draft/compose/route.ts:106),
Members can only use story chat for drafts they own. (chat/route.ts:308-312),
Members can only generate completions for drafts they own. (completion/route.ts:230-235),
Members can only add notes to drafts they own. (notes/route.ts:109-111).
story_draft_initial_compose_required is also returned by the chat route with a different message —
Generate the first article draft before using story chat. (chat/route.ts:316-323). This is the hard
block that makes compose the only way to produce a story's first article: refinement chat on a
placeholder draft is a 409, not an empty response.
The same failure has two codes on the compose route. The pre-flight check returns
story_draft_compose_state; the runtime re-check under the write throws
StoryDraftComposeStateError, which carries story_draft_compose_state_conflict
(src/app/api/stories/[storyId]/draft/compose/route.ts:115-119, 222-230;
src/lib/platform/errors.ts:12-17). Both are 409 with an identical message. Handle both.
editorial_draft_version_conflict comes from the optimistic-concurrency guard on editorial draft
upserts (src/lib/contracts/editorial-draft-conflict.ts:16-26). The constant
EDITORIAL_DRAFT_VERSION_CONFLICT_CODE is exported for client comparison and is used by both
StoryWorkspace and EditorialReviewWorkspace, which replace the server message with a localised
toast: Someone else updated this draft. The latest version was loaded - review and retry.
(messages/en.json, app.editorial.toast.versionConflict).
The editorial review routes (/api/editorial/review/...) otherwise return only uncoded
Unauthorized / Forbidden / Invalid request payload / Review item not found.
AI capacity and guardrails
These are thrown by the admission guardrail before any model call
(src/lib/ai/usage-errors.ts). Messages are verbatim.
| Code | HTTP | Message |
|---|---|---|
ai_paused | 503 | AI features are temporarily paused. Please try again later. |
ai_platform_spend_cap_reached | 429 | The platform-wide {daily|monthly} AI spend limit has been reached. Please try again later. |
ai_subscription_billing_blocked | 402 | AI usage is blocked because the subscription billing state is not active. |
ai_envelope_not_configured | 402 | AI capacity has not been configured for this workspace yet. Contact support to activate your contracted monthly envelope. |
ai_monthly_hard_limit_reached | 402 | AI monthly hard limit reached for the current billing period. |
ai_overage_disabled | 402 | AI usage is above the included allowance and overage is disabled. |
ai_usage_limit_reached | 429 | AI usage limit reached for the current window. |
ai_user_window_limit_reached | 429 | AI usage limit reached for the current user window. |
ai_concurrent_stream_limit_reached | 429 | Too many concurrent AI streams are active for this tenant. |
AiUserWindowLimitReachedError also has a spend variant passed at the call site:
AI usage spend limit reached for the current user window.
The table is in evaluation order. ai_paused and ai_platform_spend_cap_reached are kill switches
checked first — they apply even when aiGuardrailsMode is disabled and even to platform-scoped
pipeline runs. Everything below them is per tenant. Full ordering and the reservation mechanics are in
AI usage accounting, guardrails and metering.
ai_overage_disabled is the common one: overage is off by default on every plan, so the monthly
envelope doubles as the hard limit and reaching 100 % blocks rather than bills. Only an operator can
enable overage. See Billing and AI capacity.
None of these nine codes is localised. getLocalizedComposeFailureMessage
(src/components/features/story/StoryWorkspace.tsx:142-183) maps six AI provider codes to
translated strings and has no case for any billing or guardrail code, so they fall through to
getApiErrorMessage and a Greek, Polish, or Swedish newsroom sees the raw English server string in a
toast.
AI provider and runtime errors
classifyAiRuntimeError (src/lib/ai/error-classification.ts) turns AI SDK, gateway, and provider
failures into one code set. createAiOperationRouteError wraps the result into a RouteError whose
message is "<Operation label> ..." — for example Story completion generation timed out while waiting for the AI provider. Please retry. The payload carries aiErrorType, aiErrorSource,
aiAudience, providerStatusCode, retryable, and upgradeRequired.
| Code | HTTP | Cause |
|---|---|---|
ai_runtime_not_configured | 503 | Provider credentials or settings could not be loaded |
ai_gateway_activation_required | 503 | Gateway account not activated (no card on file) |
ai_provider_auth_failed | 503 | Provider rejected the credentials |
ai_provider_forbidden | 503 | Provider denied the request |
ai_provider_deployment_missing | 503 | Configured deployment not found |
ai_provider_model_missing | 503 | Configured model not found |
ai_provider_quota_exceeded | 503 | Provider-side quota or credit exhausted |
ai_provider_unavailable | 503 | Provider returned 5xx or 424 |
ai_network_error | 503 | The provider could not be reached |
ai_unsupported_functionality | 503 | Model does not support the required output format |
ai_request_timed_out | 504 | Provider deadline exceeded |
ai_provider_rate_limited | 429 | Provider-side 429 |
ai_provider_content_filtered | 422 | Safety system blocked prompt or response |
ai_context_limit_exceeded | 422 | Story context exceeded the model window |
ai_request_invalid | 502 | Provider rejected the request format |
ai_invalid_provider_response | 502 | Response body was unparseable |
ai_structured_output_invalid | 502 | Structured output failed schema validation |
ai_output_truncated | 502 | Response was cut off |
ai_no_output_generated | 502 | No usable output |
ai_unknown_error | 502 | Nothing matched |
Only six of these get a translated message in the story workspace — ai_provider_rate_limited,
ai_provider_quota_exceeded, ai_provider_content_filtered, ai_context_limit_exceeded,
ai_request_timed_out, and the pair ai_network_error / ai_provider_unavailable
(StoryWorkspace.tsx:148-179). The rest render the server's English message.
Provider details are in AI runtime: providers, stages and prompts.
Billing and checkout
| Code | HTTP | Message |
|---|---|---|
polar_checkout_not_configured | 503 | Polar checkout is not configured at runtime. Missing: <env list>. |
polar_portal_not_configured | 503 | Portal equivalent of the above |
polar_customer_lookup_failed | 503 | Polar customer lookup failed. Confirm POLAR_ACCESS_TOKEN is available in the deployed runtime and valid for the target Polar environment. |
polar_checkout_session_failed | 503 | Polar checkout session creation failed before a checkout URL was returned. ... |
polar_portal_session_failed | 503 | Portal equivalent of the above |
billing_plan_contact_only | 409 | This plan is handled through direct contact. Use the contact form to request access. |
The 503 responses carry provider: "polar" and, where relevant, server and missingEnv in the
payload (src/app/api/billing/checkout/route.ts:56-69).
Two billing conflicts carry no code:
{"error":"Plan is already active"}— 409, when the requested plan matches the active subscription, it has apolarSubscriptionId, standing grants entitlements, and the seat count is unchanged (checkout/route.ts:135-145). Apast_dueorcanceledsubscription on the same plan is a win-back and passes.{"error":"Seat limit reached for the <plan> plan. Remove an active or invited member before sending another invitation."}— 409, fromassertSeatAvailabilityForInvite(src/lib/auth/clerk-organization-management.ts:295-298).
Plan gates return 403 with a message and no code:
| Message | Gate |
|---|---|
API keys require Pro or Enterprise | authorizeApiKeysAccess |
Webhook delivery requires Plus or above | authorizeWebhookDeliveryAccess |
WordPress integration requires Pro or Enterprise | authorizeWordPressConnectionsAccess |
Team management requires a Business or Enterprise plan. | POST /api/team/invite |
CMS story export requires Pro or Enterprise | public CMS/WordPress export |
All of these resolve the plan through resolveEffectivePlanIdForStanding, so a past_due or
canceled subscription is evaluated as Free.
The settings page uses plan-only helpers while the API routes use standing-aware ones. A past_due Pro
tenant still sees the Team and Integrations tabs and then gets 403s from every route behind them
(src/app/(tenant)/app/settings/page.tsx:199, 372-380).
Monitoring and alerts
| Code | HTTP | Message |
|---|---|---|
monitoring_rules_limit_reached | 403 | Monitoring rules are not available on the current plan. (limit ≤ 0) or Monitoring rule limit reached for the current plan (<n>). |
monitoring_rule_limit_unavailable | 503 | Monitoring rule limits are unavailable right now. |
cost_envelope_unavailable | 503 | Monthly AI capacity is unavailable right now. |
alert_delivery_mode_unavailable | 422 | Email digest delivery is not available in the current runtime. |
monitoring_rules_limit_reached carries limit in the payload and is thrown inside the row-locked
state mutator, so two concurrent creates at limit − 1 cannot both succeed
(src/lib/platform/errors.ts:19-33, src/lib/platform/alerts/index.ts:329-350).
The two 503s come from TenantEntitlementUnavailableError and carry entitlement in the payload. When
the failure is schema drift the message is rewritten to
... is unavailable because the database schema is behind the current app migrations.
(src/lib/platform/shared/entitlements.ts:25-37, error-format.ts:254-277).
alert_delivery_mode_unavailable is hard to reach through the API: the route schema is
z.enum(['instant']) (src/app/api/alerts/rules/route.ts:24), so any other value fails validation
first with an uncoded 400 Invalid alert rule payload. The 422 fires when the domain assertion sees a
non-instant mode — for example a legacy stored rule. The combined-mode variant reads
Combined instant + digest delivery is not available until email delivery is configured.
Newsroom profile and sources
| Code | HTTP | Message |
|---|---|---|
invalid_newsroom_profile | 400 | Validation-specific |
invalid_custom_source_feed | 400 | Source validation failed: <detail> or Source URL did not resolve to a valid feed |
newsroom_custom_source_conflict | 409 | Conflict-specific (duplicate feed) |
newsroom_custom_source_limit_reached | 409 | Source limit reached for <tier> tier |
newsroom_custom_source_not_found | 404 | Custom source not found |
newsroom_profile_concurrent_update | 409 | Newsroom profile for <tenantScopeId> was modified concurrently |
newsroom_profile_storage_unavailable | 503 | Newsroom profile storage is temporarily unavailable. Changes were not saved. |
newsroom_profile_unavailable | 503 | Newsroom profile is unavailable right now. |
newsroom_source_catalog_unavailable | 503 | Newsroom source catalog is unavailable right now. |
invalid_custom_source_feed carries details.triedUrls and details.discoveredFeedUrls, which is what
the UI needs to explain a failed feed probe
(src/app/api/newsroom/profile/custom-sources/route.ts:86-100).
newsroom_profile_concurrent_update is only surfaced after three optimistic write retries are
exhausted (src/lib/platform/newsroom-preferences.ts:82-105).
The three 503s are the fail-closed production path: when isLocalStateFallbackAllowed() is false —
which is the case whenever VERCEL_ENV is set — a repository error throws instead of silently falling
back to platform_state. The last two also swap in the schema-drift hint when the underlying Postgres
error is a missing relation, type, column, constraint, or function.
Integrations and the public export API
The public integration endpoints have a narrower envelope than the rest of the app: they return
{ error } only, with no message and no code
(src/lib/platform/integrations/public-cms-story-export.ts:95-102).
| Status | Message |
|---|---|
| 401 | Tenant API key is required |
| 401 | Invalid tenant API key |
| 403 | CMS story export requires Pro or Enterprise |
| 500 | Failed to load stories for export |
The published OpenAPI document declares PublicIntegrationErrorResponse as
{ error (required), message, code, additionalProperties: true }
(src/lib/openapi/public-integration-api.ts:178-196) and its 500 example for the disconnect endpoint
shows code: "wordpress_disconnect_failed". The runtime 500 comes from
createInternalRouteErrorResponse, which emits { error, message } and no code. Do not key
integration logic on a code from that surface.
Tenant-side integration errors:
| Code | HTTP | Source |
|---|---|---|
invalid_webhook_endpoint | 400 | WebhookEndpointValidationError, src/lib/platform/errors.ts:51-56 |
tenant_scope_required | 400 | Webhook config write or API-key create with no resolvable tenant scope |
Endpoint validation messages are literal, for example
Webhook endpoint must use HTTPS in production. and
Webhook endpoint must resolve to a public network address. Saving a webhook config does not run
the DNS check — only dispatch does — so a config can save cleanly and every delivery still fail. The
delivery-log status codes (400, 422, 502, 504) are a separate vocabulary recorded in the log, not HTTP
statuses returned to you; see Outbound webhooks.
WordPress connect
| Code | HTTP | Message |
|---|---|---|
invalid_wordpress_connect_request | 400 | Invalid WordPress connect request., Return URL must use the same origin as the WordPress site URL., Return URL must point to a WordPress admin endpoint. |
invalid_wordpress_connect_url | 400 | <field> must be a valid absolute URL. / must use http or https. / must not include credentials. / must use HTTPS in production. |
invalid_wordpress_connect_exchange | 400 | Invalid WordPress connect exchange payload. |
wordpress_connect_session_missing | 410 | WordPress connect session is missing or expired. |
wordpress_integration_unavailable | 403 | WordPress integration requires Pro or Enterprise |
wordpress_disconnect_missing_key | 401 | Tenant API key is required |
The 410 is the one to handle in a connector client: connect codes are single-use with a 15-minute TTL, so a retried exchange always gets it.
POST /api/integrations/wordpress/connect/authorize is different from every other route on this page.
On success and on failure it answers with a 303 redirect back to the plugin's returnUrl
rather than a JSON body (src/app/api/integrations/wordpress/connect/authorize/route.ts:110-133).
Failures append:
ermisai_status=errorermisai_code=one ofunauthorized,forbidden,wordpress_integration_unavailable,invalid_wordpress_connect_request,wordpress_connect_authorize_failedermisai_message=a rewritten English string (Authentication required.,You do not have permission to authorize WordPress.,WordPress integration requires the Pro or Enterprise plan.,Invalid WordPress connection request., orFailed to authorize WordPress connection.)state=the original state value
A JSON error body is returned only when the request could not be parsed far enough to know the
returnUrl. Cancellation from the approval page uses ermisai_status=cancelled. Full handshake in
WordPress connector handshake.
Feature-gated 404s and stream 204s
Flag-gated routes return 404, not 403, when their flag is off:
{ "error": "Feature disabled", "code": "feature_disabled" }Applied to /api/stories/queue and /api/stories/queue/[storyId] (flag editorPersonalQueue),
/api/stories/[storyId]/notes (storyNotes), and /api/stories/[storyId]/review-nudge
(reviewNudges). Flags are per platform with optional per-tenant overrides; see
Feature flags and runtime controls.
Two SSE endpoints answer 204 with no body instead of erroring:
GET /api/notifications/eventswhen thenotificationsRestPollingflag is on (src/app/api/notifications/events/route.ts:50-55).GET /api/feed/eventswhen there is no tenant scope (src/app/api/feed/events/route.ts:35-37).
EventSource does not reconnect after a 204, which is the point — the client falls back to polling
cleanly. Treat 204 on those routes as "streaming is off", not as a failure. See
Realtime streams and polling fallback.
Inbound webhooks and health
The three signed receivers — /api/webhooks/clerk, /api/webhooks/resend,
/api/billing/webhooks/polar — share a status vocabulary:
| Status | Condition |
|---|---|
| 503 | The signing secret env var is not configured |
| 400 | Empty or unparseable body |
| 401 | {"error":"Invalid webhook signature"} |
| 200 | Processed, ignored, or permanently failed |
| 500 | Only for retryable database errors, so the provider retries |
The Polar receiver acks 200 for every non-retryable processing failure by design, including unmapped
products and a missing customer externalId. A tenant can be stuck on the wrong plan while the
provider's delivery log stays green. Details in
Inbound webhooks: Clerk, Polar and Resend.
GET /api/health is the readiness probe and the one route whose status is a signal rather than an
error. It returns 200 with status: "ok" or 503 with status: "unavailable", driven solely by the
Postgres check — which includes an explicit platform_state probe. If that table is missing the detail
reads platform_state unreachable (run db:migrate before serving traffic): <error>
(src/app/api/health/route.ts:52-76, 134-163). Redis degradation is reported in checks.redis but
never fails readiness.
Operations
| Code | HTTP | Message |
|---|---|---|
erasure_grace_window_active | 409 | Erasure grace window has not elapsed (scheduled for <ISO timestamp>). Pass force=true to override. |
invalid_app_origin | 503 | APP_URL or NEXT_PUBLIC_APP_URL must be a valid absolute URL. and three sibling messages |
missing_app_origin | 503 | APP_URL or NEXT_PUBLIC_APP_URL must be configured for external redirects. |
tenant_summaries_unavailable | 503 | Tenant summaries are unavailable right now. |
erasure_grace_window_active is returned by POST /api/admin/erasure when an operator confirms a
purge before the 14-day window elapses. There is no second confirmation prompt — force: true in the
body is the override. See GDPR erasure runbook.
The two app-origin errors come from src/lib/auth/clerk-session.ts:118-188. They fire on any route
that builds an external redirect when APP_URL / NEXT_PUBLIC_APP_URL is unset, non-absolute, carries
credentials, or is non-HTTPS in production. See
Environment variable reference.
The admin AI config route (PUT /api/admin/ai/config) validates each stage model and returns 400 with
invalid_stage_model_id, unsupported_stage_model_provider, provider_mode_stage_model_mismatch, or
unknown_stage_model_id (src/app/api/admin/ai/config/route.ts:143-196).
Naming inconsistencies to expect
These are real and unlikely to change without a coordinated refactor. Code defensively.
- Most codes are
snake_case. The completion and chat routes use colon-namespaced codes:unauthorized:completion,bad_request:completion,bad_request:chat_history. - Auth denials and plan gates carry no
codeat all. Only the message distinguishes them. story_draft_compose_stateandstory_draft_compose_state_conflictare the same condition.story_queuedhas two messages depending on which route emitted it.- The public export surface omits
messageandcodeentirely. - The OpenAPI document's error examples include codes the runtime does not emit.
Related pages
Route surfaces, proxy and auth gates
Where the 401 and 403 gates live and why every handler carries its own.
AI usage accounting, guardrails and metering
The admission order behind the 402 and 429 guardrail codes.
Public integration API
The three published endpoints and their narrower error envelope.
Troubleshooting
The same failures described from the user's side.
