Skip to main content

Error intelligence

TensorRail emits one canonical, failure-semantic error catalog across the whole platform. A given failure concept has exactly one code, and a decline means the same thing regardless of which rail produced it. You write your error handling once against stable codes instead of learning each rail's dialect.

This matters more than it sounds. In a multi-rail setup without a unified catalog, "declined" is a different string, a different code, and a different retry semantics on every rail, and your team ends up maintaining a translation table that is wrong somewhere at all times. TensorRail maintains exactly one such translation, at the platform boundary, so the code you branch on is the same in every market you ever expand into.

The wire shape

Every error returns a non-2xx HTTP status and a JSON body nested under a top-level error key. There are two envelopes, and which one you get depends on the endpoint:

EndpointsBody
/payments, /refunds, /customers, /disputes{ "error": { "type", "message", "code" } }, plus context fields such as field_name where they apply
/v1/* (reporting, account, API keys){ "error": { "code", "slug", "message", "retriable", "user_action", "doc_url", "request_id" } }

code is present on both, and is the field to branch on. The payment surface — the one you integrate against to take money — always carries type, message and code, plus context fields that appear only where they apply; the one you will meet most often is field_name, naming the request field at fault:

{
"error": {
"type": "invalid_request",
"message": "Missing required param: api_key",
"code": "ERR_2001",
"field_name": "api_key"
}
}

The /v1/* surface adds handling metadata on top of the same code:

{
"error": {
"code": "ERR_1001",
"slug": "missing_api_key",
"message": "An API key is required.",
"retriable": false,
"user_action": "Include your API key in the Authorization: Bearer <key> header.",
"doc_url": "https://docs.tensorrail.com/docs/error-catalog#err_1001",
"request_id": "9f7c1a2e-…"
}
}
  • code is the canonical, stable identifier of the exact failure, always of the form ERR_NNNN. It is on both envelopes — build your branching on it.
  • message is human-readable and may change over time. It is on both envelopes.
  • type is the payment surface's coarse class of the failure, and field_name — the context field you will meet most often there — names the request field at fault. Other context fields may accompany them on a given error; treat them as diagnostics, not contract.
  • slug (/v1/* only) is a stable machine-readable name for the failure, e.g. missing_api_key, mode_mismatch.
  • retriable (/v1/* only) says whether re-attempting the same request can succeed; when relevant a retry_after_seconds hint accompanies it.
  • doc_url (/v1/* only) deep-links to that code's documentation, request_id (/v1/* only) identifies the request for support — quote it when you contact us — and user_action (/v1/* only) is a merchant-actionable next step.
Branch on status and code, never on message text

Route your handling off the HTTP status plus error.code. It is the one field both envelopes carry. Message strings are for humans and logs, and can change.

Retriable, and where to read more

On the /v1/* surface the error body carries its own handling metadata — retriable (with retry_after_seconds where relevant), user_action, and doc_url — so you can drive retries and customer messaging from the response alone, without a lookup table.

On the payment surface those fields are not sent, so drive the same decisions from the HTTP status and the code band (see Code families below) instead: ERR_5xxx is safe to retry, ERR_1xxx/ERR_2xxx/ERR_3xxx are not. When you retry a retriable create or refund, always retry with the same Idempotency-Key, so the retry can never double-collect; see Orchestration and routing.

Two kinds of "failure" are different things

Distinguish these; they need different handling:

A request error (a 4xx with an error body) means your call was wrong. Fix the request and retry. It is not a payment outcome:

{
"error": {
"type": "invalid_request",
"message": "The currency is not a valid ISO 4217 code.",
"code": "ERR_2002",
"field_name": "currency"
}
}

A failed payment (HTTP 2xx, but the payment object has status: "failed") is a normal collection outcome, not an API error. The customer's rail declined or the collection did not complete. Read error_code / error_message on the payment itself and let the customer try again with a new payment:

{
"payment_id": "pay_N5cPeGw6uS2QIMjnsjVF",
"status": "failed",
"amount": 50000,
"currency": "INR",
"error_code": "ERR_4094",
"error_message": "The payment was declined."
}

Wiring both through the same "error handler" is the single most common integration bug we see. A failed payment should route to your checkout's retry UX; a request error should route to your logs and alerting.

Code families

Codes group into broad bands so you can reason about a class of failure at a glance:

BandMeaningExamplesTypical handling
ERR_1xxxAuthERR_1001 missing key, ERR_1002 invalid key, ERR_1004 insufficient scopeFix credentials/config; never retry blindly
ERR_2xxxRequest validationERR_2001 missing field, ERR_2002 invalid format, ERR_2003 out of rangeFix the request; field_name (payments) or field (/v1/*) says where
ERR_3xxxBusiness stateERR_3002 duplicate idempotency, ERR_3003 payment not found, ERR_3004 invalid state transition, ERR_3051 mode mismatchUsually a logic bug or race on your side; inspect, do not loop
ERR_4xxxPayment / declinedecline and instrument errors surfaced on failed attemptsCustomer-facing retry UX, not an alert
ERR_5xxxServerERR_5099 internal errorSafe to retry with the same Idempotency-Key; alert if persistent
ERR_6xxxAccount policy and complianceonboarding, document, and compliance-state errorsResolve in the dashboard or with support; not a code path

Per-code meaning and handling guidance is in the Error catalog. Treat it as a reference rather than a closed set: it grows as the platform does, and because every error body carries its own code, a code you have not met before is still handleable from the response alone — read the band for the family semantics and handle accordingly.

A few ERR_3xxx codes are worth special-casing explicitly:

  • ERR_3002 duplicate_idempotency_key: you reused an Idempotency-Key with a different body on POST /v1/refunds. Almost always a key-generation bug; do not "fix" it by stripping the key. The payment surface (POST /payments, POST /refunds) does not compare bodies — it replays the original resource instead. See Idempotency behaves differently on the two surfaces.
  • ERR_3004 invalid_state_transition: you called an operation the payment's current status does not allow (for example capturing a payment that is not requires_capture). Retrieve the payment and branch on its actual status.
  • ERR_3051 mode_mismatch: a test key touched a live resource or vice versa. Check which key the environment loaded; test and live data never mix.

Per-code detail is in the Error catalog; the retriable flag and doc_url for a code are also carried on every /v1/* error body.

Idempotency behaves differently on the two surfaces

This is worth getting exactly right, because the two surfaces do the opposite thing with a reused key:

SurfaceSame key, same bodySame key, different body
POST /payments, POST /refundsReplays the original resourceReplays the original resource. The differing body is silently ignored — no error
POST /v1/refundsReplays the original responseERR_3002 (409), request rejected

On the payment surface the key is folded into a deterministic, account-scoped resource id, so a second create with that key collides with the first and the first payment is returned unchanged. Nothing compares the bodies. That is safe for a retry — which is what the mechanism is for — and silent for a key-generation bug: if you reuse one key across two different charges, the second charge never happens and you get the first one's payment back with the first one's amount.

So do not rely on ERR_3002 to catch a key-reuse bug on the payment surface. Generate one key per logical operation, and after a retry compare the returned amount, currency and payment_id against what you sent.

A minimal handling recipe

HTTP 2xx -> read the payment object; branch on status
(failed => customer retry UX via error_code)
HTTP 4xx, ERR_1xxx -> configuration problem; alert, do not retry
HTTP 4xx, ERR_2xxx -> bug in the request; log field, fix, redeploy
HTTP 4xx, ERR_3xxx -> state/logic issue; retrieve the resource, reconcile
HTTP 5xx -> retry with the SAME Idempotency-Key, backoff, alert if persistent

Same contract everywhere

This one error catalog is shared across every integration path, every method, and every market. It is the natural consequence of one API and one account: when you add a market, you add zero error-handling code, and the decline semantics your support and finance teams already understand keep applying.


TensorRail, Limassol, Cyprus.