Anjin Media
Docs navigation

Docs

Errors & limits

Every failure this API produces is an RFC 9457 problem document with a stable code to branch on. This page is the full catalogue, the limits that produce some of them, and what to do about each.

Two things make error handling here mechanical rather than defensive. Every error has the same shape, and every error has a code that does not change. Write your handling against code and status, and the only thing left to decide is whether to wait, fix the request, or stop.

The problem document#

Errors are served as application/problem+json with five members. There is no nested error object anywhere in the API, and never has been:

MemberTypeWhat it is
typestring (URI)A stable identifier for the problem type, always https://anjin.media/errors/ followed by the code. It is an identifier, not a link to fetch.
titlestringA short summary of the type, derived mechanically from the code: underscores become spaces and each word is capitalised. That is why it reads Invalid Api Key rather than the typographically nicer version - it is generated, not written.
statusintegerThe HTTP status, repeated in the body so a logged document is complete on its own.
detailstringWhat went wrong with this request, in words. Useful in a log and in front of a person. Do not parse it - the wording can change.
codestringThe one member to branch on. Stable, lower_snake_case, and unique per failure mode. Every code in the catalogue below is one of these.
a complete refusal
HTTP/1.1 422 Unprocessable Entity
content-type: application/problem+json
x-ratelimit-remaining: 58

{
  "type": "https://anjin.media/errors/invalid_settings",
  "title": "Invalid Settings",
  "status": 422,
  "detail": "target_duration_s: required integer in [10, 600]",
  "code": "invalid_settings"
}

Some problems carry extension members alongside the five - extra facts about that particular failure. They sit at the top level and can never overwrite a reserved member, so a parser that reads the five and ignores the rest is always correct:

429 · with extensions
{
  "gate": "render",
  "limit": 3,
  "in_flight": 3,
  "requested": 1,
  "type": "https://anjin.media/errors/concurrency_limit",
  "title": "Concurrency Limit",
  "status": 429,
  "detail": "too many concurrent render operations - 3 in flight, 1 requested, limit 3",
  "code": "concurrency_limit"
}

Every code#

Grouped by status. This is the complete list - a response from this API carries one of these codes or it is a bug.

400, 401, 403 and 404

codeStatusWhen
invalid_json400Body must be JSON. The body did not parse. Nothing was read, so nothing was validated.
invalid_api_key401Invalid API key. Missing header, malformed key, or a revoked one - the API does not say which.
insufficient_scope403The key resolved and lacks the scope this route requires. The detail names it: this API key lacks the required "write" scope.
not_found404One code, several details - source group not found, cut not found, render not found, file not found, brand kit not found, plan version not found, one or more source groups not found. A resource belonging to another account is invisible rather than forbidden, so this is also what you get for an id that exists and is not yours.
403 Forbidden · application/problem+json
{
  "type": "https://anjin.media/errors/insufficient_scope",
  "title": "Insufficient Scope",
  "status": 403,
  "detail": "this API key lacks the required \"admin\" scope",
  "code": "insufficient_scope"
}

409 - the object is busy or in the wrong state

Six codes, and all of them mean the request was well formed and arrived at the wrong moment. None of them is fixed by changing the body:

codeWhereDetail
busycuts, rendersSomething already holds this object. plan is being generated from a revise or a plan edit while the cut is still planning; cut is already rendering when a second batch tries to claim it; cut is <status>, not ready to render otherwise.
no_plancutsno plan to edit yet. A manual edit arrived before the first plan was committed.
conflictcuts, groups, rendersThe object is in a state that refuses the operation: plan changed concurrently - re-fetch and retry, group is ingesting or perceiving - wait for it to finish or fail, render is queued or processing - wait for it to finish, or file is <status> on a second upload confirmation.
not_readytranscriptstranscript is <stage>. Transcription has not finished on this group yet.
upload_incompleteuploadsobject not found in storage. The confirmation call probed storage for the key it minted and found nothing there.
idempotency_in_progressany idempotent writea request with this Idempotency-Key is still in flight - retry shortly. See Idempotency.

422 - the request cannot be processed

Eight codes. These are all fixable by you, and every detail names the field or the value that failed:

codeWhereDetail
invalid_requesteverywhereA field is the wrong shape. The detail always names the field: prompt must be 1-2000 non-blank chars, source_group_ids must be 1-4 uuids, name and 1-8 files required, reorder must be a complete ordering of the remaining entry numbers, and so on.
invalid_settingscuts, rendersA settings value is out of range or not one of the allowed strings, keyed by field: target_duration_s: required integer in [10, 600], pace: must be one of relaxed|standard|tight. Also the refusal for crop_mode: "speaker", which is not available.
group_not_readycutsgroups not ready: <id>. At least one source group has not finished ingest and perception.
unsupported_extensionuploadsunsupported file extension: <ext>. Seven extensions are accepted; see Uploading video.
invalid_formattranscripts?format= was not one of segments, words, srt or vtt.
ssrf_blockedsource URLs, webhooksA URL you supplied is not HTTPS, or resolves somewhere private. The detail says source_url even for a webhook URL - one guard serves both call sites.
unknown_labelspeakersunknown speaker label(s) for this group: <label>. The label is not one the group's segments carry.
idempotency_conflictany idempotent writeIdempotency-Key was already used with a different request (method, path or body).

429 - too much at once

Six codes across three different mechanisms: request rate, work in flight, and account-level ceilings. All six carry retry-after:

codeDimensionWhen
rate_limitedper keyToo many requests - slow down and retry. The per-key bucket is empty. Carries retry-after.
account_rate_limitedper accountToo many requests for this account - slow down and retry. Every key on the account shares this one. Carries retry-after.
concurrency_limitper gateToo much work of one kind already in flight - too many concurrent render operations - 3 in flight, 1 requested, limit 3. See Concurrency ceilings. retry-after: 5.
account_daily_limitper accountAn account-level backstop for a day's work has been reached. The extension members name the window, the unit and the ceiling, and retry-after is the seconds remaining until the window resets.
account_monthly_limitper accountThe same backstop over a calendar month.
account_egress_limitper accountThe monthly ceiling on bytes served out of storage. It exists because downloads are the one dimension the other two cannot see accurately.

402 - billing refusals

Two codes belong to the source-minutes gate, which meters footage once, at ingest, and never at planning or rendering: minutes_plan_required and minutes_allowance_exhausted. Both are raised only by POST /v1/source-groups, and only where the platform has the gate enabled - so whether a given environment can produce them is not something this page can assert. Handle them the way you would any refusal you cannot retry your way out of: surface it to a person, stop the loop, and do not re-send. What each one means:

  • minutes_plan_required - the account has no plan and no live trial. Carries upgrade_url.
  • minutes_allowance_exhausted - the account's source-minute allowance for the current period is used up. The gate refuses when the period's usage is already at or over the allowance, so one group can overshoot and the next create is refused. Carries allowance, used (to one decimal), period_end (ISO 8601, when the allowance resets) and upgrade_url. Nothing already ingested is affected, and renders of existing cuts still run.

One code belongs to the camera entitlement, checked when a group is created with two or more camera files:

  • multicam_not_entitled - the group carries more camera files than the plan allows: one on Creator and Pro, two on Studio, four on Enterprise. The camera count is fixed at creation and never re-checked. Raised only where billing enforcement is on.

Two codes remain for legacy credit accounts only. An account on a published plan, an Enterprise contract or a live trial is never refused by the credit ladder:

  • insufficient_credits - the account balance will not cover more work.
  • account_read_only - the account can still read everything it owns and cannot start new work.

Pricing is where the commercial model lives; this page describes only the shape of the refusal.

500 and 503

codeStatusWhat it means
internal500Internal error, and nothing else - no stack, no internal identifiers. Something on our side failed and we were told about it. Not safely retryable without an idempotency-key.
capacity_paused503New work is not being accepted right now. The detail says which stop it was, and retry-after is set: five seconds when a money-safety lookup was unavailable, sixty when the platform switch is engaged, and the remainder of the window when a platform ceiling has been reached. Nothing was started, so a retry after the wait is safe.

Rate limits#

Two token buckets apply to every authenticated request, and they refill continuously rather than resetting on a clock edge:

  • 60 requests a minute per key.
  • 120 requests a minute per account, across every key it holds - minting more keys does not buy throughput.

Every authenticated response carries x-ratelimit-remaining, which is whichever of the two buckets is lower. The per-key check runs first and short-circuits, so a request refused per-key never also spends an account token:

response headers on a 429
x-ratelimit-remaining: 0
retry-after: 3

The two 429s carry different codes on purpose. If you are seeing account_rate_limited while one key looks idle, the traffic is coming from elsewhere on the same account. Authentication covers key hygiene, which is usually the real fix.

Which responses carry retry-after

Responseretry-afterValue
429 rate_limitedyesSeconds until the per-key bucket has a token again, rounded up, minimum 1.
429 account_rate_limitedyesThe same, for the account bucket.
429 concurrency_limityesAlways 5. Work in flight has to finish, and five seconds is a sane first look.
429 account ceilingsyesSeconds to the end of the window that was reached - midnight UTC, or the month.
503 capacity_pausedyes5, 60, or the remainder of the window, depending on which stop fired.
409 busy · conflictnoThese wait on work you can see the state of. Poll the object rather than guessing an interval from a header that is not there.

Concurrency ceilings#

Separate from request rate: these bound how much work one account can have running at once. They exist so that one busy account cannot monopolise the workers, and they are checked before anything is written, so a refusal never leaves a half-started job behind.

GateDefaultWhat it counts
ingest3Source groups in ingesting or perceiving at once. Both states count, because transcoding and transcription are both real work.
plan2Interactive planner runs in flight - a cut in planning, whether from a create or a revise.
economy plan10A separate, larger ceiling for economy planning, which is deliberately slow and would otherwise lock an account out of the interactive one for as long as it runs.
render3Renders queued or processing. A batch counts by its aspects, so a three-aspect request needs all three places at once.

The test is in flight + requested > limit, not in flight ≥ limit. A render batch is admitted whole or not at all, which is why a three-aspect batch cannot start with two places available:

429 Too Many Requests · application/problem+json
{
  "gate": "plan",
  "limit": 2,
  "in_flight": 2,
  "requested": 1,
  "type": "https://anjin.media/errors/concurrency_limit",
  "title": "Concurrency Limit",
  "status": 429,
  "detail": "too many concurrent plan operations - 2 in flight, 1 requested, limit 2",
  "code": "concurrency_limit"
}

The extension members are the useful part - gate tells you which queue you are on, in_flight and limit tell you how far over you are. Wait for the work you already have to finish; five seconds is the suggested first look.

Pagination#

List endpoints are keyset-paginated, newest first. Two parameters, and one field in the response:

  • limit - how many rows, default 50, maximum 100. A larger number is clamped to 100; anything that is not a positive integer falls back to 50. Neither case is an error.
  • cursor - an opaque token from the previous page.
  • next_cursor - in the response body, or null on the last page. Follow it until it is null; do not try to compute the next one.
curl · first page
curl -sS "https://anjin-media-api.fly.dev/v1/source-groups?limit=25" \
  -H "authorization: Bearer $ANJIN_API_KEY"
curl · the next one
CURSOR=MjAyNi0wOC0xNlQwOTo0MTo1NS4wMTdafDJiOGU0ZjAxLTlkNjMtNGE1Ny1iMWMwLThlNWEzN2Q2NGY5Mg

curl -sS "https://anjin-media-api.fly.dev/v1/source-groups?limit=25&cursor=$CURSOR" \
  -H "authorization: Bearer $ANJIN_API_KEY"

Cursors are values rather than positions, so a row inserted while you are walking the list cannot shift a page under you or make you read the same row twice. Treat the token as opaque: it encodes a timestamp and an id today, and that is free to change.

One endpoint is not paginated: GET /v1/cuts/:id/renders returns every render a cut has, in one response, because a cut has a handful and never a thousand.

Idempotency#

Send an idempotency-key header on any write you might retry. The header is honoured on these routes:

RouteScopeNotes
POST /v1/source-groupswriteCreating a group and its files.
POST /v1/source-groups/:id/files/:fileId/completewriteConfirming one upload.
POST /v1/cutswriteCreating a cut - the expensive one.
POST /v1/cuts/:id/revisewriteRe-planning.
POST /v1/cuts/:id/renderswriteRequesting a render batch.
PATCH /v1/source-groups/:id/speakerswriteNaming speakers - the one non-POST in the list.
POST /v1/brand-kits · /:id/assets · /assets/confirmadminBrand kits and their asset uploads.
POST /v1/webhook-endpointsadminRegistering an endpoint. The secret is stripped from the stored replay.

It is deliberately not honoured on PATCH /v1/cuts/:id/plan: that call is synchronous, calls no planner, and has nothing to duplicate.

What a key identifies

The whole request - method, path and a hash of the body - scoped to your account. Two different calls can therefore use the same key string without colliding, and the same call retried with the same key is recognised however long it has been.

Claim before execute

The key is claimed before the handler runs, not after. That ordering is the whole point: a read-then-write would leave a window in which two concurrent retries both started a planner run.

  • Replay. Once the original finished with a 2xx, that response is stored. A retry with the same key gets the stored body and status, with idempotency-replayed: true on the response.
  • A different body, same key. 422 idempotency_conflict. Nothing is overwritten and nothing runs.
  • A duplicate arriving while the first is in flight. It waits, polling, for up to two seconds. If the winner finishes inside that budget, the duplicate gets the winner's response - a replay, not an error. If the budget expires first, it gets 409 idempotency_in_progress.

What is stored, and what is not

  • Only 2xx responses are stored. A replay never serves a cached error.
  • A 4xx releases the key immediately. The request was refused before anything committed, so fixing the body and retrying with the same key works.
  • A 5xx holds the key. The handler may have committed and then failed, and releasing would let a retry duplicate whatever it committed. The claim clears itself on a sweep - allow roughly three minutes - during which a retry gets 409 idempotency_in_progress. That is the trade: a bounded delay instead of a second copy of whatever already committed.
  • Secrets are redacted from the stored copy. Registering a webhook endpoint returns a secret once; a replay of that create returns the same shape without it. The caller who needed it already has it.
a replayed response
HTTP/1.1 202 Accepted
idempotency-replayed: true
content-type: application/json

Recovering#

Four patterns cover nearly everything. The status tells you which one you are in.

409 busy - poll, do not retry

Something already holds the object. Retrying the same call in a tight loop just produces more 409s. Read the object instead - GET /v1/cuts/:id and wait for status to leave planning or rendering - and act when it has. Poll on the order of seconds, not milliseconds, and prefer webhooks, which remove the loop entirely.

429 - back off on retry-after

Sleep for the number of seconds in the header, then retry the identical request. All six 429s carry it, and it is a real figure rather than a constant: for a rate limit it is when a token exists again, for a concurrency ceiling it is five seconds, for an account ceiling it is the end of the window. Add a little jitter if several workers share a key, or they will all wake together.

503 - wait, then repeat

Nothing was started. Wait for retry-after and send the same request again.

5xx with an idempotency key - wait out the hold

A 500 is the one case where you cannot tell from the outside whether the work committed. Retry with the same idempotency-key and one of two things happens: you get 409 idempotency_in_progress, meaning wait a little longer, or the request runs and you get a real answer. Retrying without a key is what produces two cuts.

Everything on this page is the same whether you call the REST API or the MCP server - the tools return these exact documents as tool errors. Creating a composition lists the codes that one endpoint can produce, and Uploading video the ones ingest produces.