Errors and status codes
The error envelope, the seven codes, and four behaviours that surprise people the first time.
A transport error and a denial are different things. A request that reaches the policy engine and comes back deny_limit is a successful API call: it returns 201, it is metered, and it lands in the log. Error responses are for requests that never got that far.
{
"error": {
"code": "invalid_request",
"message": "limits.currency is required",
"request_id": "req_8a21"
}
}Every response, success or error, carries the same identifier in an X-Request-Id response header; errors repeat it in the body as request_id. It is the join key into our own trace, so quote it when writing to support.
The seven codes
| HTTP | Code | When |
|---|---|---|
| 400 | invalid_request | Malformed body, missing or unknown field |
| 401 | unauthorized | Missing or revoked key |
| 403 | forbidden | Valid key, wrong class — an agent key on an org endpoint |
| 404 | not_found | No such object, or not yours |
| 409 | idempotency_conflict | Same Idempotency-Key, different body |
| 429 | rate_limited | Over the limit agreed for the key; retry after Retry-After |
| 5xx | server_error | Ours. Safe to retry a POST with the same Idempotency-Key |
Four behaviours worth knowing before you meet them
An unsupported method returns 400, not 405. DELETE /v1/mandates/{id} answers invalid_request with the message DELETE is not supported on /v1/mandates/.... There is no Allow header. If your client branches on 405, it will not take that branch here.
A denial is a 201. Handling deny_limit in an error branch means handling the normal case as a failure. The verdict is in the response body of a successful call.
404 covers "not yours". An object belonging to another organization, or to another agent when you hold an agent key, is reported as absent rather than forbidden. This is deliberate: the alternative confirms that an id exists to someone who should not know it.
Errors can be replayed. A 4xx produced under an Idempotency-Key is cached like any other result for 24 hours. Retrying the same key after fixing your body returns the original error, not a fresh attempt. Fix the body and use a new key.
What an outsider sees
Anyone can confirm the host answers and that the envelope is real, without holding a key:
curl https://api.saifuro.com/v1/mandates{
"error": {
"code": "unauthorized",
"message": "Missing or revoked key. Keys are issued at onboarding: https://docs.saifuro.com/deployment/getting-access",
"request_id": "req_ea9e9d85685e304d75e552ddc3e7dae8"
}
}That is the whole anonymous surface of the API, and it is meant to be. The parts of the system a stranger can check properly are the signed verdicts and the public key set.

