Errors
One envelope for every failure, so a client never has to guess which shape it received. The status code says what to do; the request ID says where to look.
The envelope
{ "type": "about:blank#conflict",
"detail": "Idempotency key was reused with a different payout instruction.",
"status": 409,
"requestId": "3c1ac997-a29b-4f97-8314-1262048b7d7e" }Always these four fields, on every non-2xx answer, including ones generated before your request reached a handler. A client can parse one shape and be done.
Status codes
| Status | Means | Your move |
|---|---|---|
| 400 | The request is malformed — a missing field, an amount sent as a JSON number, a bad identifier. | Fix the request. Retrying it unchanged repeats the mistake. |
| 401 | Not authenticated. Four possible causes, one answer — see below. | Do not retry until something changes. Quote the request ID. |
| 404 | No such resource for you. A payout belonging to another operator answers identically to one that does not exist. | Check the identifier and the operator. |
| 409 | An idempotency key was reused with a different instruction. | Stop. Never retry with a fresh key. See Idempotency. |
| 429 | Rate limited. | Back off and retry. The SDK does this for you. |
| 501 | A real endpoint that is not implemented yet — GET /v1/payouts today. | Do not treat it as an empty list. It is not "you have none". |
| 5xx | The engine failed. Nothing is implied about whether your instruction took effect. | Retry with the same idempotency key. That is what makes it safe. |
Why every 401 says the same thing
Unknown operator, stale timestamp, wrong signature and replayed signature all produce the identical detail. That is a deliberate cost: a specific message would turn the endpoint into an oracle for enumerating valid operator IDs, and would let an attacker tune a forgery by watching which reason changes.
The verifier also takes the same time for an unknown operator as for a known one — an unknown ID is checked against a placeholder of the same shape rather than returning early — so response timing does not leak the answer either.
The real reason is in the engine's log, keyed by request ID. See the four failures.
What is safe to retry
| Situation | Retry? | Why |
|---|---|---|
| Connection refused, timeout, socket hang-up | Yes, same key | The instruction may or may not have landed. The key resolves it either way. |
| 502 / 503 / 504 | Yes, same key | Same reasoning. The SDK does this automatically, re-signing each attempt. |
| 429 | Yes, after a pause | The engine is asking for a delay, not rejecting the request. |
| 400 | No | The request is wrong. Fix it. |
| 401 | No | Repeating a rejected credential repeats the rejection. Diagnose first. |
| 409 | Never | Retrying past a conflict is how a customer gets paid twice. |
Using a request ID
Log it next to your own withdrawal identifier at the moment of failure. It is the only thing that connects a response your client saw to the line in the engine's log that explains it — which for a 401 or a 5xx is the difference between a five-minute diagnosis and an afternoon.
catch (error) { if (error instanceof PaymentsApiError) { logger.warn({ withdrawalId: withdrawal.id, status: error.status, requestId: error.problem.requestId, // grep the engine log for this detail: error.problem.detail }, 'payout submission failed'); } throw error; }