Status codes
| Status | When |
|---|---|
200 | A successful read or write. |
201 | An entry or asset was created. |
204 | A delete succeeded. |
400 | Malformed input — a bad filter, an unknown fields name, an unknown locale, a missing workspace header on a multi-workspace token. |
401 | Authentication failed. Always bare. |
403 | Authenticated, but not permitted — a read token asking for drafts, or a workspace outside the token’s bucket. |
404 | Not found, or not visible to this caller. |
409 | A conflict — most often a duplicate (translation group, locale). |
413 | The request body exceeded MAX_REQUEST_BODY, or an upload exceeded its cap. |
422 | The input was well-formed but failed validation. |
429 | Rate limited. Applies to login, not to content reads. |
Validation failures
A 422 carries a list of issues, each naming a field:
{
"statusCode": 422,
"message": [
{ "field": "title", "message": "must be at most 200 characters" },
{ "field": "author", "message": "must reference an existing author" }
]
}The same shape comes back from a save, from a publish that fails the gate, and from a failed item inside a bulk save.
Where two causes answer identically
Several distinctions are deliberately collapsed. They are not bugs, and reading them as bugs will send you looking for a fix that should not exist.
| The answer | The causes it covers |
|---|---|
One bare 401 | No header, wrong scheme, unknown token, revoked token, expired token |
404 | The entry does not exist, is in another workspace, is soft-deleted, or is unpublished and you hold a read token |
422 on a relation or media target | The target does not exist, is in another workspace, or is disallowed by accept |
In each case, answering differently would confirm whether an id you cannot otherwise see is real. That is an enumeration signal, and the API gives it up everywhere it can.
Silent successes worth knowing about
Two things return success while doing less than you might assume.
An unknown field name in a write is dropped. Not stored, not rejected, 200 returned. A misspelt field writes nothing and says nothing. Read back what you wrote when working against the API by hand.
A bulk save is always 200. Items are written one transaction at a time, so the batch cannot roll back as a unit. The response carries a verdict per item in request order; a failing item reports the status and message the single-entry call would have returned. Check the verdicts — the HTTP status will not tell you.
GraphQL errors
GraphQL answers 200 with an errors array, whatever went wrong. The status
the equivalent REST call would have returned is in extensions.status:
{
"data": null,
"errors": [
{
"message": "…",
"extensions": { "status": 422, "issues": [ … ] }
}
]
}This is the one thing a consumer porting from REST has to adjust to. Do not
branch on the HTTP status over GraphQL — branch on extensions.status.
An ungranted type over GraphQL is a validation error rather than a 404, because the field genuinely does not exist in your schema.
Cost-limit rejections
A GraphQL document over the depth, field-count, complexity or length budget is refused before execution, with an error naming the limit. See GraphQL.
Rate limiting
Login is rate limited. Content reads and writes are not — a token that hammers the API is bounded by page size and the cost limits, not by a request counter.
If you need one, it belongs in the reverse proxy in front of Apograph. See
configuration for TRUST_PROXY, which you must
set correctly for any of this to attribute requests to the right client.