API reference contents
API reference
Documentation
Errors
Six codes. Every failure in version 1 is one of them, with the status beside it, and each one means something different about what to do next.
The error body
A failure is never a bare status and never a stack trace. The body is always shaped the same way:
{
"error": {
"code": "not_found",
"message": "work item not found"
}
}
| Field | Always present | Meaning |
|---|---|---|
error.code | Yes | Machine-readable. Branch on this and never on message. |
error.message | Yes | Written for a person to read. Safe to log, and often safe to show. |
error.details | No | An object. Present on not_permitted, and on any error raised with extra context. |
Note the difference from success: a successful response wraps its payload in
data, and a failure wraps its reason in error. Nothing is ever
both, and nothing is ever neither.
Every code
bad_request — 400
When it happens. The request was understood and refused. days
outside 1–365 on a snooze. An empty body on a note. An empty
text on a chat. A body that is not JSON the route can parse.
What to do. Read error.message and fix the request. Retrying
the same request unchanged will produce the same response for ever. This is a client bug,
not a transient fault, and it should not be retried automatically.
{
"error": {
"code": "bad_request",
"message": "days must be between 1 and 365."
}
}
unauthorized — 401
When it happens. No key at all, a key that does not exist, or a key that has been revoked. Two messages, and it is worth knowing which one you are getting:
{ "error": { "code": "unauthorized", "message": "Send a key: Authorization: Bearer od_... - or ?token= for a quick check in a browser." } }
{ "error": { "code": "unauthorized", "message": "That key is not valid, or has been revoked." } }
What to do. The first message means the header is missing or malformed — a client-side problem. The second means the key is known to the service and is not usable: it was never issued, or it has been revoked. Neither is retryable. Do not retry a 401 in a loop; a revoked key stays revoked, and a loop of failed authentication is how a key gets noticed for the wrong reason.
no_desk — 403
When it happens. The key is valid, and the desk it belongs to no longer exists. This is the answer when a desk has been closed or removed and its keys were not.
{ "error": { "code": "no_desk", "message": "That key belongs to a desk that no longer exists." } }
What to do. Stop, and stop permanently. Nothing is retryable here and no
header changes the answer. The integration should be disabled and the key revoked, since
it can never work again. Note that a 403 does not always mean
no_desk — see not_permitted below, which is also a 403 and
means something entirely different. Always branch on code.
not_found — 404
When it happens. No such thing on this desk. A work item, an arrangement or another record that does not exist, or that belongs to a different desk. Rows can also disappear between a list call and a detail call, so a 404 is not always a client mistake.
{ "error": { "code": "not_found", "message": "work item not found" } }
The message names the kind of thing — work item or
arrangement — which is the only difference between the cases.
What to do. Treat it as gone and move on: skip the row, and do not retry the same id. There is a deliberate detail worth understanding rather than working around: a record belonging to another desk is reported exactly as one that does not exist. That is not an oversight. Telling a caller that a row exists but is not theirs would leak the existence of other tenants' work.
not_permitted — 403
When it happens. The key is fine and the desk understood the request, and
the desk has not been granted permission for that class of work. In version 1 this is
raised by one endpoint, POST /work/{id}/run, and it is the same gate the
button on the screen sits behind.
What to do. Nothing, from the client. The grant is the owner's decision,
made in the product, and no header or body substitutes for it. Read
error.details.permission to say which class is missing, and surface that to
the person rather than retrying.
A worked 403 not_permitted
The call: have the desk file a quarterly return.
curl -i -X POST "https://outcomedev.com/api/v1/work/58/run" \
-H "Authorization: Bearer $OD_KEY" \
-H "Idempotency-Key: run-58-2026-10-13"
The response:
HTTP/1.1 403 Forbidden
Content-Type: application/json; charset=utf-8
{
"error": {
"code": "not_permitted",
"message": "The desk has not been allowed to do this kind of work. Grant it in the desk's permissions first.",
"details": { "permission": "file" }
}
}
Read it in four steps. The status says the key worked and the request was valid. The
code says the refusal is about permission rather than identity. details.permission
names the class that would be needed — file — which is the exact
string to look up in GET /permissions. The message says where the grant is
made.
The same refusal is predictable before the call. The row's own
allowed field is false, and it is the same check:
curl -s "https://outcomedev.com/api/v1/work/58" -H "Authorization: Bearer $OD_KEY" \
| grep -o '"permission":"[a-z_]*","allowed":[a-z]*'
# "permission":"file","allowed":false
A well-behaved client reads allowed and never makes the call.
details.permission is null when the row's work is not classified
as any of the six classes — and an unclassified row is never delegable, so a null
there is a permanent answer rather than a missing one.
internal_error — 500
When it happens. A fault at the desk end.
{
"error": {
"code": "internal_error",
"message": "The desk could not complete that request."
}
}
Note the message. The real 500 body carries a fixed string
and nothing else. It is not a description of what broke: a fault's own message is written
to the service log and is not returned to a caller, because a stack trace in a response
body is a gift to somebody probing. Match on code and never on the text.
What to do. Retrying is reasonable — this is the one code where it
is. Use the same Idempotency-Key as the original attempt, and back off
between tries. If it persists, report the request path and the approximate time to
support@outcomedev.com, because
that is the only way the log entry will be found.
The table
| Status | code | Means | Retry |
|---|---|---|---|
| 400 | bad_request | The request was understood and refused | No — fix it |
| 401 | unauthorized | No key, an unknown key, or a revoked key | No |
| 403 | no_desk | The key is valid and its desk is gone | No, never |
| 403 | not_permitted | The desk has not been granted that class of work | No — needs a grant |
| 404 | not_found | No such thing on this desk | No |
| 500 | internal_error | A fault at the desk end | Yes, with a backoff |
Four things that are not errors
Each of these looks like a failure and is not, and treating any of them as one produces a client that retries for ever or reports bad news that is not true.
| Response | Why it is a success |
|---|---|
drafted: false with a reason | The desk declines to write a message when there is nothing true to say. HTTP 200. The answer is the reason. |
status: "waiting" after completing a row | The message went out and an answer is owed. The work is not finished, and it is not supposed to be. |
timed_out: true from /chat | The desk did not answer in time. HTTP 200, nothing changed, and the same question can be asked again. |
ok: false from /run | The desk tried and reported a concrete reason. HTTP 200. The message says what was missing. |
Versioning and change
The base path carries the version: /api/v1. There is no date-based version
header and no content negotiation. A field may be added to a response without a version
change, so clients should ignore keys they do not recognise rather than refusing a
response that has one more key than expected.
This is a deliberate trade, and it is stated in the overview too: the API returns the shapes the product's own screens read, computed by the same code. Adding a field to the desk's queue therefore adds a field here. The alternative — a second model of what a desk is, maintained by hand — fails silently, as disagreement between two surfaces rather than as a new key in a response.
Related
- Actions — which write raises which error.
- Idempotency — retrying the one code that is worth retrying.
- Authentication — what produces a 401 and how to replace a key.