Skip to main content
Version: v1

Error Responses

Every request the ODUS API rejects answers with the same JSON body, whatever went wrong:

400 Bad Request
{
"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"]
}
FieldTypeDescription
statusCodenumberThe HTTP status code, repeated in the body.
requestIdstringIdentifier for this request. Quote it in a support ticket — it is what we search the logs by.
typestringAlways present. The kind of failure. This is the field to branch on. See Error types.
messagestringHuman-readable description, written for a developer reading logs. Not stable — do not match on it.
codestringOptional, finer detail where it adds something type does not. See Error codes.
errorsstring[]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.

typeStatusMeaning
invalid_request_error400The request is malformed, missing a field, or fails a field-level rule. Fix the request.
authentication_error401No API key was supplied, or the key is not recognised.
permission_error403The key is valid but does not carry the permission this operation requires.
not_found_error404No resource exists with the requested identifier, or it is not visible to this key.
invalid_state_error400The request is well-formed and permitted, but the resource is not in a state that allows it.
conflict_error409The request collides with another — a replayed idempotency key, or a resource held in flight.
rate_limit_error429Too many requests. Back off and retry.
payment_error400 (v1) / 402 (v2)The payment was refused by the issuer, the processor, or risk.
api_error5xxSomething 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.

codeUnderMeaning
validation_failedinvalid_request_errorThe request failed schema or field validation; errors lists every problem.
idempotency_conflictconflict_errorThe X-Idempotency-Key was already used with a different body. Retrying will not help.
resource_lockedconflict_errorAnother in-flight request holds the resource. Retry with backoff.
timeoutapi_errorThe request exceeded the time limit and was abandoned. It may still have taken effect.
api_version_sunsetinvalid_request_errorAnswered 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.
warning

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:

StatusMeaningWhat to do
500Something failed on our side while serving the request.Retrying the same request usually gets the same answer. Report it with the requestId.
503A 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.
504The 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.