01
The envelope
An error body is one object under error: a code from the list below, its HTTP status, a message written for a person, and type and doc_url, which are one address — this page, at the code — so the body is RFC 9457-shaped without a second content type. A param names the field at fault where there is one; a reason says which kind of conflict. Every answer, error or not, carries a Request-Id header, and an error repeats it as request_id so a support ticket and a log line meet in one string.
An MCP tool answers the same sentence, with the code in parentheses, as a tool execution error — never a protocol error — so the assistant can read it back to the person.
02
The codes
A published enum: these are only ever added to, never renamed.
- 400bad_request
The request itself is wrong: a parameter out of range, a field the body does not have, a date that does not exist, a money value finer than its currency allows, or a missing Idempotency-Key.
Read `param` for the field at fault and fix the request. Nothing was changed; retrying the same request gives the same answer.
- 402payment_required
The account has no active subscription. Reads keep working through the grace period; writes and proposals need an active plan.
Resubscribe in the app. The token and its scopes are unchanged.
- 403forbidden
The token is real but not for this: a scope it was not granted, or a budget outside the list it was narrowed to. Only ever about your own token.
Create a token at the level and with the budgets you need; a token's level and budgets are fixed once made. The WWW-Authenticate header names the missing scope.
- 404not_found
The id does not exist, or belongs to someone else. The two answer identically, so a token cannot be used to probe for other people's data.
Re-read the id from the list it came from. If a row was deleted since you read it, a delete has done its work.
- 409conflict
The write cannot happen as sent. `reason` says which kind: an Idempotency-Key reused for a different body, a key whose row has since gone, a key still running, a locked reconciled row, or a row changed since the version you read.
Branch on `reason` below. Most are a re-read and a retry with fresh data, never a blind retry.
- 429rate_limited
A window is exhausted: per IP, per token, per user, or one of the per-user write windows.
Wait the seconds in Retry-After. The RateLimit headers on every response say how much room is left before this happens.
- 500internal
Something went wrong on our side. The message says no more than that, because a database error can carry a row's own values.
Retry once after a pause. If it persists, send the Request-Id header's value with your report; it is in the error body too.
03
Conflict reasons
A 409 conflict carries a reason a client can branch on. Most mean re-read and retry with fresh data, never a blind retry.
| reason | Status | Meaning |
|---|---|---|
idempotency_mismatch | 409 | This Idempotency-Key was already used for a different request body. A key means one change; mint a new one for a new change. |
idempotency_gone | 409 | The row this key created has since been deleted, so the first answer cannot be replayed. Nothing is re-created. |
idempotency_in_progress | 409 | The first request with this key is still running. Wait, then retry with the same key to get its answer. |
reconciled | 409 | The row is reconciled: its amount, date, direction, account and transfer destination are locked, and it cannot be deleted or uncleared. The other leg of a reconciled transfer is locked too — its amount, date and account cannot change, and it cannot be deleted — but it can still be uncleared. On an ordinary reconciled row memo, payee and category stay editable; on a transfer leg the memo does. |
superseded | 409 | The row changed since the version you sent, or the proposal's target changed since it was filed. Re-read and send again. |
ceiling_exceeded | 202 | Over a limit set on this token: the per-transaction amount, the daily total, rows per call or rows per day. The change was filed as a proposal instead and nothing was written. Today it only ever arrives on a 202. |
{
"error": {
"code": "conflict",
"message": "This Idempotency-Key was used for a different request.",
"status": 409,
"type": "https://rudopo.app/developers/errors#conflict",
"doc_url": "https://rudopo.app/developers/errors#conflict",
"reason": "idempotency_mismatch",
"request_id": "4f13939c-440e-4743-8fc4-42ec5965c343"
}
}04
When a 202 carries a reason
A token with Make changes can still answer 202 Accepted with outcome: "proposed": the change was filed in the person's review inbox instead of written, and reason says why. It is a 202 and not a 409 because the request did something — a client that treats 409 as failure would lose the proposal it had just made.
| reason | Meaning |
|---|---|
needs_person | A delete from a token that was not allowed deletes. It waits in the review inbox for the person. |
possible_duplicate | The same token wrote a row with this account, date, amount and payee in the last ten minutes under another key. The second waits in the review inbox. |
ceiling_exceeded | Over a limit set on this token. The change waits in the review inbox, badged. |
05
Headers
| Header | On | Meaning |
|---|---|---|
Request-Id | Every response | The id to quote in a report. An error body repeats it as request_id. |
WWW-Authenticate | 401 and 403 | Bearer realm="rudopo" with error="invalid_token" or error="insufficient_scope", and the scope that was missing. |
Retry-After | 429 | Seconds to wait. A write refused by a per-user write window reports that window's own wait. |
RateLimit, RateLimit-Policy | Every response past the token check | The IETF fields for whichever of the per-token and per-user windows would refuse first, beside the older X-RateLimit-* trio. |
Idempotency-Replayed | A replayed write | true when the answer is the stored one for a key already used with this body. |