Home › Documentation › API › Errors

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"
  }
}
FieldAlways presentMeaning
error.codeYesMachine-readable. Branch on this and never on message.
error.messageYesWritten for a person to read. Safe to log, and often safe to show.
error.detailsNoAn 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

StatuscodeMeansRetry
400bad_requestThe request was understood and refusedNo — fix it
401unauthorizedNo key, an unknown key, or a revoked keyNo
403no_deskThe key is valid and its desk is goneNo, never
403not_permittedThe desk has not been granted that class of workNo — needs a grant
404not_foundNo such thing on this deskNo
500internal_errorA fault at the desk endYes, 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.

ResponseWhy it is a success
drafted: false with a reasonThe 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 rowThe message went out and an answer is owed. The work is not finished, and it is not supposed to be.
timed_out: true from /chatThe desk did not answer in time. HTTP 200, nothing changed, and the same question can be asked again.
ok: false from /runThe 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.

  • 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.