Payments Engine

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

any failure
{ "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

StatusMeansYour move
400The 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.
401Not authenticated. Four possible causes, one answer — see below.Do not retry until something changes. Quote the request ID.
404No such resource for you. A payout belonging to another operator answers identically to one that does not exist.Check the identifier and the operator.
409An idempotency key was reused with a different instruction.Stop. Never retry with a fresh key. See Idempotency.
429Rate limited.Back off and retry. The SDK does this for you.
501A 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".
5xxThe engine failed. Nothing is implied about whether your instruction took effect.Retry with the same idempotency key. That is what makes it safe.
The 501 deserves attention. Answering an empty array would be a lie an integration cannot detect: your reconciliation would compare an empty list against your own records and find nothing wrong, forever. A 501 is loud on purpose.

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

SituationRetry?Why
Connection refused, timeout, socket hang-upYes, same keyThe instruction may or may not have landed. The key resolves it either way.
502 / 503 / 504Yes, same keySame reasoning. The SDK does this automatically, re-signing each attempt.
429Yes, after a pauseThe engine is asking for a delay, not rejecting the request.
400NoThe request is wrong. Fix it.
401NoRepeating a rejected credential repeats the rejection. Diagnose first.
409NeverRetrying past a conflict is how a customer gets paid twice.
Re-sign every retry. A signature is admitted once, so replaying the previous attempt's headers turns a transient failure into a 401. Keep the idempotency key; change the timestamp and the signature.

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.

node
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;
}