Every error has the same shape:
{
"error": {
"code": "insufficient_scope",
"message": "This credential is missing the \"contacts:write\" scope.",
"request_id": "req_8f2c…",
"details": { "required": "contacts:write", "granted": ["contacts:read"] }
}
}
Switch on code. It is a closed set and part of the contract; message is for humans and may be reworded.
| code | status | what it means |
|---|---|---|
invalid_request | 400 | The body or query string failed validation. details.issues has the specifics. |
unauthenticated | 401 | Missing, expired, revoked, or malformed credential. |
insufficient_scope | 403 | Valid credential, wrong scopes. details.required names the one you need. |
access_denied | 403 | The workspace revoked your app, or never authorized it. |
not_found | 404 | No such object for this workspace. |
conflict | 409 | A uniqueness constraint — e.g. two API keys with the same name. |
unprocessable | 422 | Well-formed but the domain refused it. |
compliance_blocked | 422 | A send was refused by the compliance gate. message names the reason. |
rate_limited | 429 | Slow down; see Retry-After. |
server_error | 500 | Ours. request_id is what support needs. |
404 vs 403
Another workspace's object id returns 404, not 403. A 403 would confirm the row exists somewhere, which turns every id endpoint into a platform-wide existence oracle. "Not found" is the honest answer from your credential's point of view.
request_id
Every response — success or failure — carries X-Request-Id, and errors repeat it in the body. Log it. It is the fastest path to an answer from support.