Never retry
invalid_request, invalid_amount, unknown_asset_or_rail, asset_not_offered, unauthorized. The request is at fault: retrying it unchanged gives the same result. Fix it, or display the reason.
Developers
Every error carries a stable, machine-readable code and a coherent HTTP status. The code is what your integration should branch on: the text may evolve, the code will not.
The current shape is deliberately minimal. It is stable, and that is what matters: a code, and nothing that leaks an internal detail.
{ "error": "rate_stale" }{
"error": "rail_not_open",
"reason": {
"fr": "La retenue TDS de 1 % et l’enregistrement FIU-IND exigent une entite locale…",
"en": "The 1% TDS withholding and FIU-IND registration require a local entity…"
}
}Some errors carry a bilingual reason object, meant to be shown as-is to the user. A closed rail is one: the reason explains a named regulatory constraint, not an outage, and showing it saves a support request. The versioned API will add a request id to this envelope, so you can point us at a precise line in our logs.
These codes are returned today by the quote endpoint.
| code | HTTP | What it means | What to do |
|---|---|---|---|
invalid_request | 400 | The request body fails schema validation: missing field, wrong type, value outside the allowed bounds. | Check that amount is a string and not a number, and that direction is exactly “sell” or “receive”. |
invalid_amount | 400 | The amount cannot be parsed, or carries more decimals than the asset or currency accepts. | Truncate to the unit precision: 6 decimals for USDT, 8 for BTC, 2 for the euro, 0 for the CFA franc. |
unknown_asset_or_rail | 404 | The asset or rail identifier does not exist in the catalogue. | Identifiers are stable and never reassigned. Reload the catalogue rather than guessing them. |
rail_not_open | 409 | The rail exists but is not open. The response carries the exact reason, in both languages. | Show the reason as-is: it explains a regulatory constraint, not an outage. Offer an open rail in the same country. |
asset_not_offered | 409 | The asset is in the catalogue but not offered — the case for enhanced-anonymity assets. | Do not offer it in your selector. The catalogue returns it with its reason so you can explain it. |
rate_stale | 503 | The last known price exceeds the maximum tolerated age. The engine refuses to quote rather than serve a stale price as a firm one. | Retry after a few seconds. Do not cache the last successful quote to fill the gap: that would be exactly the mistake this code prevents. |
rate_unavailable | 503 | No price is available for this asset / currency pair. | Disable the pair in your interface rather than showing an estimate. A displayed estimate becomes an expectation. |
quote_failed | 500 | Unexpected error during computation. No quote is returned. | Retry once; if the error persists, write to us with the exact timestamp of the call. |
These codes accompany endpoints that are not open yet. They are published so your error handling is written once.
| code | HTTP | What it means | What to do |
|---|---|---|---|
unauthorized | 401 | Key missing, revoked, or used on the wrong environment. | An sk_test_ key does not work in production, and the reverse does not either: that is deliberate. |
idempotency_conflict | 409 | The same idempotency key has already been used with a different request body. | A key belongs to one intent. If the content changes, the key changes; otherwise there is no telling which of the two orders to replay. |
quote_expired | 409 | The quote has passed its lock window. | Re-quote and have the new amount accepted. We never silently re-price against the customer. |
beneficiary_rejected | 422 | The beneficiary details fail the rail validation: invalid checksum, non-conforming format, or a name that does not match the verified identity. | The response names the offending field. The beneficiary name must be the verified identity: no third-party payout is possible. |
country_not_served | 451 | The destination country is not served: sanctions, restrictive measures, or the absence of a local framework. | The 451 code is chosen deliberately over a 404: when we refuse, we say it is a refusal and why. |
rate_limited | 429 | Too many calls within the sliding window. | Honour the Retry-After header. Quotes are cheap to queue, expensive to hammer. |
not_found | 404 | The resource does not exist, or does not belong to your key. | Both cases return the same code: a response that told them apart would let anyone enumerate other people’s references. |
An amount outside the rail bounds returns a valid quote, with a limitError object. It is not a failure: it is information your interface should display.
"limitError": { "code": "below_min", "limit": "20.00" }Treating this case as an HTTP error would deprive you of the computed amount, and therefore of the ability to tell the user “you are 16.53 EUR short of the minimum for this payout method”. The bound applies to the net amount, the one the beneficiary receives.
Three families, three behaviours. Retrying a validation error in a loop only fills your logs.
invalid_request, invalid_amount, unknown_asset_or_rail, asset_not_offered, unauthorized. The request is at fault: retrying it unchanged gives the same result. Fix it, or display the reason.
rate_stale, rate_unavailable, quote_failed, and any rate limiting. Wait a few seconds, with a growing gap between attempts. Do not fill the gap with a cached quote.
rail_not_open, quote_expired, beneficiary_rejected, country_not_served. The situation needs a human choice: offer another rail, a fresh quote, corrected details.