Error catalog
Every error body is {"error": "<code>"}, sometimes with extras
(existing_video_id, retryAfter, message). Branch on the
code, never on the HTTP status alone — in particular, a 429 is either a
rate limit (rate_limited, carries retryAfter) or a
quota (upload_quota_exceeded/storage_quota_exceeded, resets at
UTC midnight or after deletions); quota checks deliberately run before rate limiting.
Foreign-tenant resources always return 404, never 403 — existence is not disclosed.
| Code | Status | Meaning | What to do |
|---|---|---|---|
unauthorized | 401 | Missing, malformed, or wrong credentials for this route. | Send Authorization: Bearer with the right credential family: domain ek_ key for /v1/*, admin key for /v1/admin/*, upload token for tus, service token for /v1/internal/*. Note: a foreign tenant’s resource returns 404, not 401/403. |
forbidden | 403 | Authenticated, but the credential type or permission is wrong for this route. | Internal routes need a service token carrying video:process; upload tokens and domain keys are always rejected there. Re-issue the token and check its permissions array. |
invalid_credentials | 401 | Service-account name/secret pair rejected (unknown name and wrong secret are deliberately indistinguishable). | Check SERVICE_ACCOUNT_NAME/SERVICE_ACCOUNT_SECRET on the worker. After a rotation, the old secret works for 1 hour only. |
name_already_exists | 409 | A service account with this name already exists. | Pick a unique name (recommendation: one account per worker host, name = hostname). |
service_account_not_found | 404 | No service account with that id. | List accounts via the admin plane and use the id from creation. |
service_account_revoked | 422 | The account is revoked; it cannot be rotated. | Create a new account. Revocation is permanent by design. |
invalid_content_type | 400 | content_type must be a video/* MIME type. | Send the file’s real MIME type (e.g. video/mp4). The worker re-probes server-side regardless. |
file_too_large | 400 | Declared file_size exceeds the domain’s max_file_bytes. | Check GET /v1/domain for your limit (default 200 MB). Compress or split the recording client-side. |
duration_too_long | 400 | Declared duration exceeds the domain’s max_duration_seconds (instance ceiling 900 s). | Trim the video, or raise the domain limit via PATCH /v1/domain/config (within the instance cap). |
concurrent_upload_exists | 409 | This uploader_ref already has a pending upload (response includes existing_video_id). | Finish or cancel the existing upload first — resume it via tus HEAD rather than starting over. |
too_many_incomplete_uploads | 429 | Three or more uploads from this uploader_ref expired in 24 h. | This is an abuse brake, not a rate limit. Investigate why uploads abandon (network? app killed mid-upload?); it clears as the expired uploads age past 24 h. |
missing_video_id | 400 | tus creation lacked the video_id metadata field. | Pass metadata: {video_id} to your tus client — the id from POST /v1/videos. |
size_mismatch | 400 | tus Upload-Length (or final bytes) differs from the declared file_size. | Declared size is a contract, byte-exact. Initiate after the file is finalized; never guess the size. |
upload_not_pending | 409 | tus request against a video that is no longer in pending_upload. | The upload window closed (finished, expired, or cancelled). Initiate a new video. |
video_not_found | 404 | No such video in your domain (foreign-tenant ids also 404 — existence is never disclosed). | Check the id and that you are using the owning domain’s key. |
invalid_state | 409 | The operation is invalid for the video’s current state (e.g. cancelling a terminal video). | Fetch GET /v1/videos/:id and act on the current state; states are documented in the state machine page. |
video_not_deletable | 422 | Deletion refused in the current state. | Cancel in-flight work first, or wait for a terminal state. |
video_not_claimable | 409 | The conditional claim UPDATE matched no row — another worker won, or the video left queued. | Normal under contention: poll next again. No backoff reset needed. |
not_lock_holder | 403 | Your lock expired and another worker (or a reaper) took over; your writes are fenced. | Abandon the job silently — do not call fail. Heartbeat every 30 s to keep locks alive; check for long stages blocking the heartbeat loop. |
invalid_video_state | 409 | Heartbeat/complete/fail against a video not in locked/encoding (often: it was cancelled). | Treat like cancellation: stop work, clean up, move on. |
invalid_video_id | 400 | The id is not a UUIDv4. | Use ids exactly as returned by the API. |
file_not_found | 404 | The raw upload file is gone (already cleaned after completion, or never finished). | Raw files are deleted once a video is available (no re-encode without re-upload, by design). |
invalid_path | 400 | Delivery path failed validation (bad UUID, traversal attempt, or empty path). | Use playback URLs exactly as reported in the video object. |
upload_quota_exceeded | 429 | Daily upload byte quota exhausted (domain or instance). Deliberately distinct from rate_limited. | Check GET /v1/usage for headroom; quotas reset at UTC midnight. This fires even if you are also rate-limited — quota wins. |
storage_quota_exceeded | 429 | The domain’s total storage cap is reached; new upload bytes are refused. | Delete unused videos (storage recomputes immediately) or ask the operator to raise storage_bytes_limit. |
invalid_cursor | 400 | Pagination cursor failed signature verification — truncated, modified, or from another instance. | Pass next_cursor back byte-for-byte. Never construct or edit cursors. |
slug_already_exists | 409 | Domain slug taken. | Choose another slug. |
domain_not_found | 404 | No such (non-deleted) domain. | Check the id; deleted domains cannot be operated on. |
validation_error | 400 | Request shape rejected by schema validation (unknown keys are errors, not ignored). | Read message for the failing field; remove unknown keys — the API never silently strips them. |
bad_request | 400 | Generic malformed request. | Compare your request against the reference at /docs/api. |
internal_error | 500 | Unexpected server error; details are logged server-side, never leaked. | Retry with backoff; report persistent 500s to the operator with the request time. |
invalid_zip_entry | 400 | Output zip contained an entry escaping the target directory (zip-slip). | Archive entries must be relative paths. Use the stock worker’s upload module. |
zip_too_large | 413 | Extracted output exceeded the byte cap (default 2 GiB). | Inspect what the pipeline emitted; the cap is env-tunable (ENERGIXER_MAX_ZIP_BYTES) if legitimately exceeded. |
storage_backend_unsupported | 501 | This video’s storage backend (s3) has no download path yet. | S3 lands at M5; keep storage_backend local until then. |
delivery_not_found | 404 | No such webhook delivery in your domain. | Use ids from GET /v1/webhook-deliveries. |
rate_limited | 429 | Too many requests in the window. The body carries retryAfter (ISO-8601). | Honor retryAfter. Per-endpoint limits are in the limits table; quota exhaustion uses distinct codes (upload_quota_exceeded / storage_quota_exceeded), so branch on the error code, not the status. |
sandbox_domain_protected | 409 | The built-in sandbox domain cannot be deleted — it is service infrastructure. | Nothing to fix. Create and manage your own domains via the admin plane; the sandbox exists for zero-signup trials. |
sandbox_not_allowed | 403 | Sandbox session keys are video-plane only — the shared sandbox domain cannot be managed (config, keys, webhooks, usage) by its visitors. | Create your own domain via the admin plane for anything beyond uploading and playback. |
csrf_check_failed | 403 | A state-changing console request arrived without the x-energixer-console header. | Console clients send that header on every non-GET request; it is the CSRF gate (a cross-site form cannot set it). If you are scripting against the API, use a domain ek_ key instead of a session cookie. |
domain_not_selected | 400 | A console session called a tenant route without naming which domain it is acting on. | Send the x-energixer-domain header with the domain id from GET /v1/console/domains. Key-authenticated requests do not need it — the key identifies the domain. |
not_found | 404 | No such route for this credential — including routes that exist but are not yours (the operator plane for a member account). | Nothing to fix from the client side. Foreign and privileged resources are 404 by policy: a 403 would confirm they exist. |
account_disabled | 403 | The console account exists but has been disabled by an operator. | Ask the instance operator to re-enable it. Signing in again will not help. |
oauth_failed | 400 | The Google sign-in round trip failed: bad or replayed state, a rejected code exchange, or an id_token that failed verification. | Start again from /console/login. If it repeats, check the redirect URI registered with Google matches GOOGLE_REDIRECT_URI exactly, including scheme and trailing path. |
invalid_secret | 401 | Self-host console sign-in was attempted with the wrong secret. | Check ENERGIXER_CONSOLE_SECRET on the API. This code is deliberately distinct from unauthorized so the console can say "wrong secret" rather than "session expired". |
storage_unavailable | 503 | The domain’s configured storage backend could not be reached or refused the request — expired credentials, a deleted bucket, or an endpoint that is down. | Check GET /v1/domain/storage: last_error carries the provider’s message and last_verified_at shows when it last worked. Re-run POST /v1/domain/storage/verify after fixing the bucket or rotating the key. Videos already stored are unaffected; only new uploads and in-flight jobs fail. |
storage_verification_failed | 400 | A storage profile was submitted but the mandatory round trip (put, get, presigned fetch, delete) failed, so nothing was saved. | The response body names the step that failed. Usual causes: the key lacks s3:PutObject/s3:DeleteObject on the prefix, the endpoint needs force_path_style (MinIO, Scaleway), or the region does not match the bucket. Your previous storage setting is still in effect. |
outputs_incomplete | 409 | A worker declared output files it uploaded directly to storage (ADR-20), but at least one of them is not there. The video was not marked available and no state changed. | Internal to the worker plane; tenants never see this. The worker uploaded some outputs and then reported completion — usually a presigned PUT that failed and whose error was swallowed, or a URL that expired mid-upload. The video keeps its lock until it expires and is then requeued for a fresh attempt. The response names the missing paths. |
playback_key_required | 401 | The video is private (ADR-21) and the delivery request carried no playback key. Unknown video ids answer with this too, so the delivery plane cannot be used to enumerate. | Mint a key with POST /v1/playback-keys using your domain key (server-side — never ship the domain key to a browser) and send it as Authorization: Bearer on the playback request. Note the transport limit: only fetches your page makes can carry a header, so private videos play through hls.js/MSE, and posters and caption tracks must be fetched to a blob URL. To make a video publicly playable instead, PATCH /v1/videos/:id/visibility to "public". |
playback_key_invalid | 401 | A playback key was presented but is not usable for this video: expired, tampered, wrong token family, issued for another domain, superseded by a revoke-all epoch bump, revoked for this viewer_ref, or narrowed by video_ids/uploader_ref to a set that excludes this video. | The specific reason is deliberately not reported — distinguishing "wrong domain" from "wrong epoch" would be a free oracle. Mint a fresh key and retry. If fresh keys keep failing: check the key is the playback family (aud energixer-playback), not an upload token, service token, console session, or ek_ domain key; confirm you did not call POST /v1/playback-keys/revoke-all after minting; and if you passed video_ids, confirm this video is in that list. Revocations and epoch bumps take effect within 5 seconds. |
playback_revocation_limit | 409 | The domain already holds the maximum number of per-viewer playback revocations (1000). The denylist is capped so the whole set stays cacheable on the delivery hot path. | Use POST /v1/playback-keys/revoke-all instead: it bumps the domain epoch, invalidating every outstanding key at once, and lets the denylist drain. Revocation rows are reaped automatically once they are older than the maximum key TTL (24h), because no live key can predate them. |
outro_not_found | 400 | No outro of this domain answers to that slug (ADR-22). 400 on POST /v1/videos, where the slug is one field of an otherwise valid request; the same code comes back as 404 from GET/DELETE /v1/outros/:slug, where the slug IS the resource. | List your outros with GET /v1/outros and use a slug from there. Slugs are per domain and are mutable pointers — deleting an outro frees its slug immediately, so an upload that names a just-deleted slug fails here rather than silently shipping without a tail card. If the slug came from your domain’s default_outro_slug, either re-upload under that slug or clear the setting with PATCH /v1/domain/config. |
outro_not_ready | 409 | The slug resolves, but that outro asset is not in state "available" yet — it is still uploading or encoding, or it failed. | Poll GET /v1/outros until the outro reports state "available", then initiate the upload. This is checked at initiate on purpose: an outro that is not ready when the video is queued would be silently dropped at encode time, and a loud 409 now beats a missing tail card later. If it reports "failed", check failure_reason and re-upload. |
outro_limit_reached | 409 | This domain already holds the maximum number of outro assets (50). | Delete an outro you no longer use with DELETE /v1/outros/:slug. Videos that already reference it are unaffected — they pinned the asset id at initiate, so deleting only frees the slug and stops NEW uploads from resolving it. |