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: a 400 covers everything from a missing field to a
resource in the wrong state, so it cannot tell you which of those happened. A decline has a status
of its own, 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}/v2/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 only when the resource was held in flight. An
// idempotency conflict never succeeds with a different body, and a stale
// revision never succeeds until you read the resource again.
return error.code === 'resource_locked' ? retryLater() : fail(error);
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, a resource held in flight, or a revision that is out of date. |
rate_limit_error | 429 | Too many requests. Back off and retry. See Rate limits. |
payment_error | 402 | 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.
A decline answers 402 Payment Required. Usually the issuer, the processor or risk refused
it, but a payment that never reached an authorization answers 402 too. Treat 402 as "the
payment did not go through" and 400 as "the request was not accepted".
v1 answers the same decline with 400, and type is payment_error in both versions. So an
integration moving from v1 that branches on type needs no change; one that branches on the status
does. 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 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: the request used an API version that is no longer served. Send it under a current version. Retrying will not help. |
stale_revision | conflict_error | The request named a revision that is no longer the resource's current one, so nothing was changed. Read the resource again before you retry. Retrying unchanged 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 409s are the clearest case for why code exists: resource_locked succeeds if you
retry with backoff, while idempotency_conflict and stale_revision 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. |
Rate limits
A request over a rate limit is answered with 429 and type rate_limit_error. Back off, then
retry when the response tells you to.
Don't build your integration around a fixed number of requests. An integration that backs off on
every 429 keeps working when a limit changes.
Limits can change
We set rate limits, and any limit on concurrent requests, at our discretion. We can change them at any time, on these terms:
- Increases take effect without notice. Your integration needs no change when a limit goes up.
- Reductions are announced in the changelog at least 30 days before they take effect. That gives you time to lower your request rate before the new limit applies.
- Protecting the platform is the exception. If traffic threatens the API's availability, for example abuse or an incident in progress, we can lower a limit for that traffic immediately and without notice.
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.