When things go wrong
Errors
Every error is application/problem+json (RFC 9457), extended with the ISO 20022 reason code. Error handling is written once and works everywhere.
The shape
{
"type": "https://docs.silorapay.ai/errors/validation-failed",
"title": "Validation failed",
"status": 400,
"detail": "3 fields failed validation for corridor US-IN",
"correlationId": "01J8KX9P2R7M4Q",
"isoReasonCode": "FF01",
"errors": [ ... ]
}Always log correlationId. It is echoed from your X-Correlation-Id when you send one, threaded through every internal hop, and it is the first thing Silora support will ask for.
400 — validation, with the rule that failed
Each entry points at the field with a JSON Pointer, and — where a corridor rule produced it — at the requirements document that explains why. A developer never has to guess which rule they broke.
{
"type": "https://docs.silorapay.ai/errors/validation-failed",
"title": "Validation failed",
"status": 400,
"detail": "3 fields failed validation for corridor US-IN",
"correlationId": "01J8KX9P2R7M4Q",
"isoReasonCode": "FF01",
"errors": [
{ "pointer": "/purpose", "code": "FF05",
"detail": "Required for US-IN. Use a code from IN_RBI_PURPOSE.",
"requirementsUrl": "/v1/corridors/US-IN/requirements" },
{ "pointer": "/creditor/address/postCode", "code": "RR03",
"detail": "Structured creditor address is mandatory for this corridor under CBPR+" },
{ "pointer": "/instructedAmount/value", "code": "FF01",
"detail": "Amount must be a decimal string, not a JSON number" }
]
}422 — well-formed but rejected
A 422 always carries isoReasonCode. When routing is the cause, routingDecision.evaluated shows every provider considered and why each one did not match — which turns "the payment failed" into "prefund the Bridge USD account".
{
"type": "https://docs.silorapay.ai/errors/payment-rejected",
"title": "Payment rejected",
"status": 422,
"detail": "No capable provider for corridor US-IN in USD at this amount",
"correlationId": "01J8KX9P2R7M4Q",
"isoReasonCode": "AG01",
"routingDecision": {
"preference": "AUTO", "tier": 3, "providerId": null,
"evaluated": [
{ "providerId": "bridge", "matched": false, "reason": "Insufficient prefunding" },
{ "providerId": "xbs", "matched": false, "reason": "Corridor not enabled" }
]
}
}Status codes
| Code | Meaning | Retry? |
|---|---|---|
200 | Read, or a synchronous action completed | — |
201 | Resource created — beneficiary, webhook endpoint, document | — |
202 | Accepted for processing. Not settlement. | — |
400 | Malformed, or failed schema / corridor / CBPR+ validation — FF01 | No. Fix the payload. |
401 | Bad key, signature, or timestamp skew | No. See Authentication. |
403 | Key lacks the scope, or tenant mismatch | No. |
404 | Unknown uetr or resource | No. |
409 | Idempotency-key reuse with a different body, or an invalid state transition | No. Investigate first. |
422 | Well-formed but rejected — always carries isoReasonCode | Only after remediation. |
429 | Rate limited — see the RateLimit-* headers | Yes, after RateLimit-Reset. |
503 | Provider unreachable — AB06 / AB07 | Yes, with the same Idempotency-Key. |
ISO reason codes
isoReasonCode is an ExternalStatusReason1Code, validated against the quarterly ISO code set rather than a hardcoded enum. The ones you will actually see:
| Code | Meaning | Typical remediation |
|---|---|---|
AC03 | Invalid creditor account | Correct the account, run Confirmation of Payee, submit a new payment. |
AC04 | Account closed | Obtain new account details from the payee. |
AC06 | Account blocked | Payee-side. Contact the beneficiary. |
AM02 | Amount exceeds a limit | Check limits on the corridor requirements. |
AM04 | Insufficient funds | Prefund the provider account. See GET /ledger/accounts. |
AG01 | Forbidden, or no capable provider | Read routingDecision.evaluated. |
RR03 | Missing creditor address | Send a structured address — CBPR+ requires it. |
RR04 | Regulatory reason | Usually a missing regulatoryReporting block. |
CURR | Unsupported currency | Check the capability matrix for the corridor. |
TM01 | Past cut-off | Resubmit for the next value date, or accept T+1. |
DT01 | Invalid date, or expired quote | Re-quote and resubmit. |
DUPL | Duplicate | Check whether an earlier attempt succeeded before retrying. |
ED05 | Settlement failed | Provider-side. Retry or reroute. |
FF05 | Invalid local instrument or purpose code | Take the value from the corridor code list. |
AB06 / AB07 | Provider timeout or offline | Retry with the same Idempotency-Key. |
Idempotency
Three identities, deliberately distinct. Conflating them is how a retry becomes a second payment.
| Identity | Carrier | Scope |
|---|---|---|
| The payment | uetr | Whole life, across retries and re-routes |
| The request | Idempotency-Key header | One HTTP call. Replay returns the original response. |
| The provider attempt | Internal, per attempt | One submission to one provider |
- Replaying a key with an identical body returns the original response plus
Idempotency-Replayed: true. - Replaying a key with a different body returns
409. That is a bug in your retry logic, not a transient fault. - Keys are retained 24 hours.
endToEndIdis not an idempotency key — customers legitimately reuse it on a resubmitted instruction.
const idempotencyKey = randomUUID(); // minted once, outside the retry loop
const payload = JSON.stringify(paymentRequest);
for (let attempt = 0; attempt < 4; attempt++) {
const response = await siloraFetch('POST', '/payments', payload, idempotencyKey);
if (response.status === 202) return response.json();
// 503 and 429 are the only retryable statuses. Everything else is terminal.
if (response.status !== 503 && response.status !== 429) {
throw new SiloraProblem(await response.json());
}
await sleep(250 * 2 ** attempt); // 250ms, 500ms, 1s, 2s
}
throw new Error('Provider unavailable after 4 attempts');