Error Responses
Every request the ODUS API rejects answers with the same JSON body, whatever went wrong:
{
"statusCode": 400,
"requestId": "dfc10de3-36bb-4bcc-b081-316216194033",
"message": "amount must be greater than 0",
"type": "invalid_request_error",
"code": "validation_failed",
"errors": ["amount must be greater than 0"]
}
| Field | Type | Description |
|---|---|---|
statusCode | number | The HTTP status code, repeated in the body. |
requestId | string | Identifier for this request. Quote it in a support ticket — it is what we search the logs by. |
type | string | Always present. The kind of failure. This is the field to branch on. See Error types. |
message | string | Human-readable description, written for a developer reading logs. Not stable — do not match on it. |
code | string | Optional, finer detail where it adds something type does not. See Error codes. |
errors | string[] | Present on a validation failure. Every problem found, not just the first. Field-specific entries are prefixed with the path. |
Branch on type
message is prose. We reword it whenever a clearer phrasing turns up, and it is deliberately
not a contract — matching on it will break.
statusCode is the opposite problem: in v1 a 400 covers everything from a missing field to a
payment the issuer refused, so it cannot tell you which of those happened. v2 splits the decline
out onto 402, but the rest of the coarseness stays.
type is the stable value in between, and it is on every error response including 5xx.
The set is closed and every member is permanent, so you can handle it exhaustively.
const response = await fetch(`${BASE_URL}/payments/${id}/authorize`, {
method: 'POST',
headers: { Authorization: `Bearer ${secretKey}` },
body: JSON.stringify(payload),
});
if (!response.ok) {
const error = await response.json();
switch (error.type) {
case 'invalid_request_error':
// Your request is wrong. `error.errors` says how.
return showFieldErrors(error.errors);
case 'invalid_state_error':
// Your request is fine; the resource is not in a state that allows it.
return refreshPayment(id);
case 'payment_error':
// The payment was refused. Nothing is wrong with the request — read the
// payment for the reason, with a secret key.
return askForAnotherPaymentMethod();
case 'conflict_error':
// Retry with backoff — unless it is an idempotency conflict, which will
// never succeed with a different body.
return error.code === 'idempotency_conflict' ? fail(error) : retryLater();
case 'rate_limit_error':
return retryWithBackoff();
case 'api_error':
// Our side, and `statusCode` is what tells you which of the three you have.
if (error.statusCode === 503) return retryWithBackoff(); // transient dependency failure
if (error.statusCode === 504) return reconcile(id); // may still have taken effect
return reportFailure(error.requestId); // 500 — retrying gets the same answer
default:
return reportFailure(error.requestId);
}
}
Error types
Closed set. Every error response carries exactly one.
type | Status | Meaning |
|---|---|---|
invalid_request_error | 400 | The request is malformed, missing a field, or fails a field-level rule. Fix the request. |
authentication_error | 401 | No API key was supplied, or the key is not recognised. |
permission_error | 403 | The key is valid but does not carry the permission this operation requires. |
not_found_error | 404 | No resource exists with the requested identifier, or it is not visible to this key. |
invalid_state_error | 400 | The request is well-formed and permitted, but the resource is not in a state that allows it. |
conflict_error | 409 | The request collides with another — a replayed idempotency key, or a resource held in flight. |
rate_limit_error | 429 | Too many requests. Back off and retry. |
payment_error | 400 (v1) / 402 (v2) | The payment was refused by the issuer, the processor, or risk. |
api_error | 5xx | Something went wrong on our side, or a dependency was briefly unavailable. See api_error is three statuses. |
invalid_request_error versus invalid_state_error
Both are 400, and telling them apart is most of the reason type exists.
invalid_request_error means fix the request: a field is missing, malformed, or out of
range. errors names each one.
invalid_state_error means the request was fine, but the resource was not. Capturing a
payment that is already captured, cancelling one that has settled, retrying one that was never
set up to retry. Nothing about the request will make it succeed — the resource has to change
first. Read it before you retry.
payment_error is not your fault either
A refused payment is not a bad request. Your payload was correct and the platform worked; the issuer said no. Retrying the identical request will get the identical answer — offer another payment method instead.
v2 answers a decline with 402 Payment Required; v1 answers the same decline with 400.
Only the status differs — the body is identical and type is payment_error in both — so an
integration that branches on type needs no change when it moves to v2. See
Upgrading from v1 to v2.
To find out why it was refused, read the payment: outcome.errorCode on the transaction
carries the normalised decline reason, along with declineType and retryStrategy. See
Error Codes under Payments. Those fields require a secret key —
they are not returned to a publishable or checkout key, and the error body does not carry
them.
Error codes
code is the finer level, present only where it distinguishes something type cannot.
Most errors carry no code at all.
code | Under | Meaning |
|---|---|---|
validation_failed | invalid_request_error | The request failed schema or field validation; errors lists every problem. |
idempotency_conflict | conflict_error | The X-Idempotency-Key was already used with a different body. Retrying will not help. |
resource_locked | conflict_error | Another in-flight request holds the resource. Retry with backoff. |
timeout | api_error | The request exceeded the time limit and was abandoned. It may still have taken effect. |
api_version_sunset | invalid_request_error | Answered with 410 from 4 November 2026: v1 is no longer served. Send the request under /v2 — see Upgrading from v1 to v2. Retrying will not help. |
The set of codes is open and grows over time. New codes appear as we add payment methods
and processors. Treat a code you do not recognise as if it were absent and fall back to
type, which is closed and will not surprise you. Never fail on an unfamiliar code.
payment_error carries no code today — the decline reason is a secret-key field, and the
error body has no equivalent of the key-based gating the payment resource has.
The two 409s are the clearest case for why code exists: resource_locked succeeds if you
retry with backoff, and idempotency_conflict never will. The status cannot tell you which
you have.
timeout is the equivalent on 5xx. A plain api_error means the request did not take
effect; code: "timeout" means it might have. Reconcile with a GET rather than retrying
blindly, or retry with the same X-Idempotency-Key.
api_error is three statuses, not one
type alone does not tell you whether to retry a 5xx. The status does:
| Status | Meaning | What to do |
|---|---|---|
500 | Something failed on our side while serving the request. | Retrying the same request usually gets the same answer. Report it with the requestId. |
503 | A dependency we needed was briefly unavailable. Nothing about your request is wrong, and it never reached the operation. | Retry with backoff. This is the one 5xx that is worth retrying as-is. |
504 | The request exceeded our processing limit and was abandoned. It may still have taken effect. | Reconcile with a GET, or retry with the same X-Idempotency-Key. |
Declines versus errors
A payment the issuer declines still creates a transaction, and its full detail lives on the
outcome object — errorCode, declineType, retryStrategy, safeErrorMessage. That is
richer than anything the error body carries, and it is the right place to read from when you
have a payment to inspect. See Error Codes.
The error body's payment_error exists so you can tell a decline from a bad request
without a second call.