Errors and failure codes
NATIO separates two different things. An error means the API refused the request. A failure means the request was accepted and the money movement did not happen. They have different shapes, and they need different handling.
The error envelope
Every error response, at every status code, has the same shape:
{
"error": {
"type": "invalid_request_error",
"code": "validation_failed",
"message": "body.currency: unsupported currency",
"param": "currency",
"details": [
{ "path": "currency", "message": "unsupported currency", "code": "custom" }
]
},
"request_id": "req_0uwP4ioIZ8q8dgBW"
}| Field | Always present | Meaning |
|---|---|---|
error.type | yes | The broad class of error. Maps to the HTTP status. |
error.code | yes | The stable machine-readable identifier. Branch on this and nothing else. |
error.message | yes | A human sentence for logs and for your own operators. Wording can change; never parse it. |
error.param | no | The request field that caused the error, when a single field is responsible. |
error.details | no | Structured detail. For validation errors, one entry per failing path. |
request_id | yes | Matches the x-request-id response header. Quote it in support requests. |
Error types and HTTP status
| error.type | HTTP status | Meaning |
|---|---|---|
invalid_request_error | 400 / 413 / 415 / 422 | The request itself is wrong: malformed JSON, an unknown field, a failed validation rule or a body that is too large. |
authentication_error | 401 | No API key was presented, or the key is unknown or revoked. |
permission_error | 403 | The key is valid but not allowed to do this: missing permission, or the caller IP is outside the project allow-list. |
not_found_error | 404 | No object with that id exists inside the merchant, project and mode of the key — or the route does not exist. |
idempotency_error | 409 / 422 | An Idempotency-Key is in flight (409) or was reused with a different body (422). |
rate_limit_error | 429 | Too many requests in the current window. Retry after the window resets. |
state_error | 409 | The object cannot move to the requested state, for example capturing a payment that is not authorized. |
provider_error | 503 | No eligible provider was available for the request. |
risk_error | — | Reserved for risk-layer rejections surfaced as an API error rather than a failed payment. |
internal_error | 500 | An unexpected error. Quote the request_id when you contact support. |
Common error codes
| code | type | Status | Message |
|---|---|---|---|
validation_failed | invalid_request_error | 422 | One or more fields failed validation. details lists each path. |
invalid_json | invalid_request_error | 400 | Request body is not valid JSON |
unsupported_media_type | invalid_request_error | 415 | Use application/json |
payload_too_large | invalid_request_error | 413 | Request body is too large |
invalid_idempotency_key | invalid_request_error | 400 | Idempotency-Key must be at most 255 characters |
test_scenario_not_allowed | invalid_request_error | 400 | test_scenario is only accepted with test API keys |
unauthorized | authentication_error | 401 | Authentication required |
invalid_api_key | authentication_error | 401 | Invalid or revoked API key |
forbidden | permission_error | 403 | You do not have permission to perform this action |
ip_not_allowed | permission_error | 403 | Request IP is not in the project allow-list |
resource_not_found | not_found_error | 404 | <Entity> <id> was not found |
route_not_found | not_found_error | 404 | No route for <METHOD> <path> |
idempotency_in_progress | idempotency_error | 409 | A request with this Idempotency-Key is still being processed |
idempotency_key_reused | idempotency_error | 422 | Idempotency-Key was already used with a different request payload |
invalid_state_transition | state_error | 409 | <entity> cannot move from <status> to <status> |
rate_limited | rate_limit_error | 429 | Too many requests |
no_route_available | provider_error | 503 | No eligible provider is available for this request |
internal_error | internal_error | 500 | An internal error occurred |
429 and 503 are worth retrying with backoff, and 409 idempotency_in_progress is worth retrying shortly. Every 4xx that describes the request itself — validation, an invalid key, an IP that is not allowed, a reused idempotency key — will produce the same answer forever. Retrying is safe only because your mutating calls carry an Idempotency-Key.Payment failures are not errors
A payment that no provider would complete is still a successful API call: 201 Created, with status: "failed" and a failure object. The same object appears on each failed entry in attempts[], and on refunds and payouts that fail.
{
"id": "pay_Bu0waXeXrCN5FbyPnbzy",
"status": "failed",
"failure": {
"code": "stolen_card",
"category": "hard",
"message": "Stolen card, pick up"
},
"route": {
"provider": { "code": "demo_acquirer_a", "name": "NATIO Demo Acquirer A" },
"attempts": 1,
"rule": "Cards → Acquirer A, fallback Acquirer B"
}
}Provider codes are not returned raw as the decision: each adapter normalises the provider vocabulary into one dictionary, so failure.code means the same thing regardless of which acquirer produced it. The unmapped provider values are still available on the attempt as provider_code and provider_message, for provider support tickets.
Categories drive retry policy
failure.category is the field that decides what happens next, inside NATIO and in your own code.
| Category | Cascade behaviour | What it means |
|---|---|---|
soft | May cascade | The issuer or bank refused this attempt but the instrument itself is usable. NATIO may cascade the payment to the next eligible provider. |
hard | Never retried | The instrument or the customer decision is final. NATIO never retries a hard decline on another provider; the payment fails immediately. |
technical | May cascade | The provider, not the payment, failed. NATIO may fail over to the next eligible provider, after confirming that no charge was created. |
policy | No further attempt | The orchestration layer itself stopped the payment: no eligible route, risk block, limits, or every provider already attempted. |
Failure dictionary
The complete set of normalised codes, grouped by category.
Soft declines soft · may cascade
| failure.code | message |
|---|---|
insufficient_funds | The account has insufficient funds |
do_not_honor | The issuer declined the transaction without a specific reason |
issuer_unavailable | The issuer or bank could not be reached |
try_again_later | Temporary decline; retry later |
limit_exceeded | A transaction or velocity limit was exceeded |
generic_decline | The transaction was declined |
Hard declines hard · never retried
| failure.code | message |
|---|---|
card_declined | The card was declined |
stolen_card | The card was reported stolen |
lost_card | The card was reported lost |
expired_card | The card has expired |
invalid_card | The card details are invalid |
invalid_account | The account is invalid or closed |
restricted_card | The card is restricted for this transaction |
fraud_suspected | The provider suspects fraud |
authentication_failed | Customer authentication failed |
cancelled_by_customer | The customer cancelled the payment |
payment_expired | The payment was not completed in time |
Technical failures technical · may cascade
| failure.code | message |
|---|---|
technical_error | The provider returned a technical error |
timeout | The provider did not respond in time |
provider_unavailable | The provider is unavailable |
provider_configuration_error | Provider account is misconfigured |
Policy outcomes policy · no further attempt
| failure.code | message |
|---|---|
unsupported_currency | The currency is not supported by any eligible provider |
unsupported_method | The payment method is not supported by any eligible provider |
no_route_available | No eligible provider route was found |
risk_blocked | The payment was blocked by risk rules |
amount_out_of_limits | Amount is outside provider limits |
attempts_exhausted | All eligible providers were attempted without success |
A code that is not in this dictionary is possible in principle if a provider returns something entirely new; it is treated as a soft decline with the message The transaction could not be completed. Write your handling so that an unknown code degrades to the soft path rather than throwing.
Handling both in one place
Branch on the HTTP status first, then on the failure category. Never branch on a message string.
const res = await fetch("https://api.natio.me/v1/payments", init);
const body = await res.json();
if (!res.ok) {
// Transport-level error: the payment was not created.
switch (body.error.code) {
case "idempotency_in_progress":
return retryShortly(); // the first call is still running
case "rate_limited":
return backOff(res.headers.get("x-ratelimit-reset"));
case "validation_failed":
throw new BugInOurCode(body.error.details);
default:
throw new NatioError(body.error, body.request_id);
}
}
// The call succeeded. The payment may still have failed.
if (body.status === "failed") {
switch (body.failure.category) {
case "hard":
return declineCheckout(body.failure.code); // never retry this instrument
case "policy":
return escalate(body.failure.code); // configuration or risk, not the customer
default:
return offerAnotherMethod(body.failure.code); // soft / technical: already cascaded
}
}| What you got | What to tell the customer | What to do |
|---|---|---|
failed · hard | That payment method was declined. Offer another one. | Do not retry the same instrument. Log the code for your fraud and chargeback analysis. |
failed · soft | The payment did not go through. Try again or use another method. | NATIO already cascaded across the eligible providers. A later retry can succeed; an immediate one usually will not. |
failed · technical | Something went wrong on our side. Please try again. | Every eligible provider failed technically. Alert your operators — this is an availability signal, not a customer problem. |
failed · policy | Nothing, in most cases — this is a configuration or risk outcome. | Check routing coverage and provider limits for no_route_available and unsupported_*; check risk rules for risk_blocked. |
4xx / 5xx | Nothing — the request never became a payment. | Fix the request, or retry with the same Idempotency-Key. Log request_id either way. |
Every failed payment also carries the whole story on its timeline: which providers were eligible, what each one answered and why the cascade stopped where it did.