Skip to content

Errors

Every failure has the same shape:

{
"error": {
"code": "SCOPE_INSUFFICIENT",
"message": "Cette requête demande la portée « ws:write », que cette clé n'a pas. Elle porte : ws:read."
}
}

Never a bare { "message": … }, on any route — an invariant in this repository holds that shape.

Some refusals carry extra fields alongside the two above — a plan refusal names the plan you are on and the one that lifts it. They are documented on the operations that produce them in the reference.

Status Code What happened
400 AMBIGUOUS_CREDENTIALS An API key and a session cookie in one request. Refused rather than settled arbitrarily.
401 UNAUTHENTICATED No credential at all.
401 KEY_UNKNOWN This key never existed. Check what your CI is sending.
401 KEY_REVOKED Somebody took it away. Mint a new one.
401 KEY_EXPIRED The clock did its job. Rotate.
403 SCOPE_INSUFFICIENT The message names the scope this request wanted, and the ones your key holds.
403 KEY_CANNOT_REACH No scope opens this route to a key. Not an oversight — see Authentication.
403 KEY_CANNOT_INVITE A key lets nobody into the organization, whatever its scope.
403 KEY_CANNOT_DELETE_WORKSPACE Delete from the application.
403 FORBIDDEN The caller may not manage this workspace.

404 answers both “this does not exist” and “this exists and is not yours”, identically and on purpose: INBOX_NOT_FOUND, MESSAGE_NOT_FOUND, WORKSPACE_NOT_FOUND.

Telling the two apart would let anybody map a colleague’s workspaces one request at a time.

A search over an inbox that is not yours therefore answers 404, not an empty list. An empty list reads as “the mail has not arrived” and sends somebody debugging a pipeline that works.

Code What happened
CRITERIA_EMPTY No criterion given. An empty search is not a search — for “the last messages”, read GET /v1/inboxes/{id}/messages.
CRITERIA_UNKNOWN A field that is not a criterion. The message names it.
CRITERIA_INVALID A criterion whose value is unusable — the message names the field.
CURSOR_INVALID Not a cursor we issued. Take the one from the previous response, or omit it.
INVALID_JSON The body did not parse.

CRITERIA_UNKNOWN is worth a sentence, because it is a refusal you might read as strictness and it is the opposite. Write subjet for subject and the request fails, naming the field. Ignored instead, that typo would have matched every message in the inbox, your test would have asserted on the first one, and it would have passed until the day a second message arrived. The 400 costs a minute; the silence costs an afternoon.

402, always with currentPlan and requiredPlan beside the code, so a script can report what to change:

Code What happened
INBOX_LIMIT_REACHED The plan includes fewer inboxes. Counted for the organization, not per workspace.
WORKSPACE_LIMIT_REACHED Same, for workspaces.
INBOX_QUOTAS_UNAVAILABLE Raising a per-inbox ceiling needs a higher plan. Removing one stays open on every plan — a customer who cannot undo their own configuration has to write to us.
Status Code What happened
429 TOO_MANY_WAITS Your organization holds 20 open waits already. Usually a suite firing searches without awaiting their answers: lower the concurrency, or lower wait.

A message can carry an error of its own, in place of its parsed fields, when it was accepted but not processed:

{
"id": "",
"status": "quota_exceeded",
"error": { "code": "PLAN_QUOTA_EXCEEDED", "message": "" }
}

The derived keys — subject, otp, links, extract — are absent rather than null. An absent key pushes a client to read status; a plausible-looking null is something a test asserts against and passes for the wrong reason.