Skip to navigation

Errors

Branch on error.code. Never parse error.message.

Every failure from this API — every status, every endpoint — answers with the same envelope:

{
"error": {
"code": "INSUFFICIENT_CREDITS",
"message": "This request needs 1 credit and the account has 0.",
"request_id": "c7a4e2f0-5b6d-4a18-9e3c-2f1d0b8a7c64",
"details": []
}
}

The same request_id is also sent as the X-Request-Id response header.

The contract is split down the middle

FieldAudienceStability
error.codeyour codestable. Renaming or removing one is a breaking change.
error.messagea humannone. Wording changes without notice.
request_idsupportpresent on every error. Log it.
detailsboth, cautiouslyalways an array of {field, code, message}; empty when nothing more is known

That split is the whole design. A stable machine code means you never have to write if "insufficient" in message.lower(), which is the pattern that breaks when someone improves a sentence. So:

if error["code"] == "INSUFFICIENT_CREDITS": # yes: codes are the contract
alert_billing()
if "credit" in error["message"]: # no: messages change without notice
alert_billing()

A 400 INVALID_REQUEST carries one details[] entry per rejected field, and its details[].code — duplicate_product_category, for instance — is as stable as error.code. Branch on either; never on a message.

Adding a new code is a normal, non-breaking change, so treat an unrecognised code as “a failure of this status class” rather than crashing on it.

Statuses are never collapsed

An authentication failure, an exhausted quota, a rate limit and a dependency outage each get their own status. None of them are answered as 400.

400 your request is malformed do not retry unchanged
401 no usable key do not retry unchanged
402 out of credits do not retry until topped up
403 key usable, lacks the scope do not retry unchanged
404 no such job or asset, or not yours do not retry
409 idempotency conflict retry only IDEMPOTENCY_IN_PROGRESS
410 the input asset expired upload again
413 upload too large do not retry unchanged
415 not a supported image type do not retry unchanged
422 an image that cannot be used do not retry unchanged
429 slow down retry after Retry-After
500 our bug report with the request_id
503 unavailable retry after Retry-After

The point of not collapsing them is that your retry logic is a function of the status. If every failure were a 400, a correct client would be impossible to write: it could not distinguish “you are wrong” from “come back in a second”.

Two pairs worth knowing apart:

  • 429 TENANT_QUEUE_LIMIT_EXCEEDED is your account’s own queue being full. 503 QUEUE_CAPACITY_EXCEEDED is ours. Both are retryable; only the first is something you can fix by submitting less.
  • 503 SERVICE_UNAVAILABLE during authentication is a 503 and not a 401 because your key was never checked. Do not discard a key over it.

Work failures are not HTTP failures

A submission that returns 202 and then fails during analysis reports that failure on a 200 poll, with the same envelope inside the job:

{ "status": "failed", "error": { "code": "INPUT_UNPROCESSABLE", "message": "..." } }

See Asynchronous jobs. An HTTP error code is about your request; job.status is about the work.

What an error will never contain

By design, and enforced in our own tests: no image bytes, no facial landmarks, no API key or fragment of one, no internal file path, no storage or cache key, no stack trace, no upstream URL.

If you are wiring errors into your own logs and alerts, the safe fields are error.code, error.message, request_id, the job id, the operation, the HTTP status, and your own timing. That is also exactly the set support will ask for.

The full list

Error codes is generated from the code that raises them, so it cannot drift from what you will actually receive.