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

HTTPCodeWhen
400invalid_requestMalformed body, missing or unknown field
401unauthorizedMissing or revoked key
403forbiddenValid key, wrong class — an agent key on an org endpoint
404not_foundNo such object, or not yours
409idempotency_conflictSame Idempotency-Key, different body
429rate_limitedOver the limit agreed for the key; retry after Retry-After
5xxserver_errorOurs. 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:

Request
curl https://api.saifuro.com/v1/mandates
Response
{
  "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.

On this page