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:
| Endpoints | Body |
|---|---|
/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-…"
}
}
codeis the canonical, stable identifier of the exact failure, always of the formERR_NNNN. It is on both envelopes — build your branching on it.messageis human-readable and may change over time. It is on both envelopes.typeis the payment surface's coarse class of the failure, andfield_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 aretry_after_secondshint 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 — anduser_action(/v1/*only) is a merchant-actionable next step.
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:
| Band | Meaning | Examples | Typical handling |
|---|---|---|---|
ERR_1xxx | Auth | ERR_1001 missing key, ERR_1002 invalid key, ERR_1004 insufficient scope | Fix credentials/config; never retry blindly |
ERR_2xxx | Request validation | ERR_2001 missing field, ERR_2002 invalid format, ERR_2003 out of range | Fix the request; field_name (payments) or field (/v1/*) says where |
ERR_3xxx | Business state | ERR_3002 duplicate idempotency, ERR_3003 payment not found, ERR_3004 invalid state transition, ERR_3051 mode mismatch | Usually a logic bug or race on your side; inspect, do not loop |
ERR_4xxx | Payment / decline | decline and instrument errors surfaced on failed attempts | Customer-facing retry UX, not an alert |
ERR_5xxx | Server | ERR_5099 internal error | Safe to retry with the same Idempotency-Key; alert if persistent |
ERR_6xxx | Account policy and compliance | onboarding, document, and compliance-state errors | Resolve 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_3002duplicate_idempotency_key: you reused anIdempotency-Keywith a different body onPOST /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_3004invalid_state_transition: you called an operation the payment's current status does not allow (for example capturing a payment that is notrequires_capture). Retrieve the payment and branch on its actualstatus.ERR_3051mode_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:
| Surface | Same key, same body | Same key, different body |
|---|---|---|
POST /payments, POST /refunds | Replays the original resource | Replays the original resource. The differing body is silently ignored — no error |
POST /v1/refunds | Replays the original response | ERR_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.