Errors
What a refused request tells you, and what to do about it.
Every failed request answers with the same body. Read errorCode, not the text: the text is written for a person and may be reworded, the code is a contract.
type ErrorResponse = {
message: string; // one sentence, safe to show a user
errorCode?: string; // the stable reason — branch on this
details?: Record<string, unknown>; // the same answer as data
validation?: { errors: string[] }; // one entry per failed field, on a 400
}details carries what you need to correct the request without parsing the sentence. Every code below sets leg (deposit or payout) and field (for example deposit.amount), and the amount codes add min, max, step, currencyCode and sometimes derived. Get order limits carries the same object inside its unsupportedReason, so one errorCode has one shape wherever it reaches you.
Example:
{
"message": "The deposit amount is below the minimum of 1494 NGN. Call GET /api/v2/order-limits for the amount limits of this corridor.",
"errorCode": "AMOUNT_BELOW_MINIMUM",
"details": {
"leg": "deposit",
"field": "deposit.amount",
"min": 1494,
"max": 747367,
"currencyCode": "NGN"
}
}min and max appear only when there is a real bound to give. A route with no minimum, or no maximum, leaves the field out rather than reporting a zero.
No offer matched — 404
Create quote and Create order search for a pair of offers that can serve your request. When nothing matches, the code says which part of the request has no offer behind it.
CURRENCY_TYPE_NOT_SUPPORTED
A real currency type, with no route behind it on that leg
The message lists the types that have one
CURRENCY_CODE_NOT_SUPPORTED
The currency type exists, the code does not
Read Get available currencies
COUNTRY_NOT_SUPPORTED
We do not sell that currency in that country
The message lists the countries we do
PAYMENT_CHANNEL_NOT_SUPPORTED
That channel does not serve that currency and country
The message lists the channels that do
CARRIER_NOT_SUPPORTED
That carrier does not run on that channel
The message lists the carriers that do
TRANSFER_TYPE_NOT_SUPPORTED
That transfer type is not offered on that channel
Leave transferType out, or use a listed one
QUOTE_REQUIRED
The route works and is kept out of ordinary search on purpose
Use another channel, or contact us
ROUTE_NOT_AVAILABLE
The corridor exists and is not usable right now
Retry later, or offer another channel
AMOUNT_BELOW_MINIMUM
Under the smallest amount any route takes
details.min
AMOUNT_ABOVE_MAXIMUM
Over the largest amount any route takes
details.max
AMOUNT_STEP_INVALID
The amount breaks the step every route requires
details.step
AMOUNT_NOT_COVERED
Inside the overall window, in a gap between two routes
Read Get order limits and pick another amount
QUOTE_REQUIRED
Some routes are kept out of ordinary offer search on purpose. In production that is a route Fonbnk has restricted. In sandbox this covers almost everything, because every real fiat offer is forced quote-only there, so a corridor with no sandbox provider behind it answers this code.
No call of yours opens the route. Create quote runs the same search and answers the same code, so a retry with a quote does not help. Use another payment channel, or contact Fonbnk support to have the route opened for your integration.
AMOUNT_NOT_COVERED
The one you cannot fix by arithmetic on min and max. The amount falls between two routes: one runs to 100, the next starts at 500, and you asked for 300. It carries no min and no max on purpose — those are the union across every route, so 101 to 499 sits inside them and is refused all the same. Get order limits reconciles both legs and is the number to aim at.
Only from Get order limits — 200
LIMITS_DO_NOT_OVERLAP
The two sides cannot meet: both legs have routes and no amount satisfies both, or one side publishes no usable bounds, or a balance sits under every minimum
Nothing to read. Use another corridor, or contact us
This one never rides a 404. It arrives inside unsupportedReason on a 200, and it is the one code with nowhere to send you: the corridor is in the currency list and its window is the zeros you are already reading. Several different dead ends raise it, so read the message rather than the code alone. The usual cause is a per-transaction limit on your account that leaves the deposit floor above the payout ceiling.
The request itself is wrong — 400
CURRENCY_TYPE_INVALID
That currencyType is not one we route at all. The message lists the ones we do. Not the same as CURRENCY_TYPE_NOT_SUPPORTED above, which is a real type with no route behind it right now.
CURRENCY_PAIR_NOT_SUPPORTED
We do not pay out in that currency type for that deposit currency type. The message lists the payout types we do.
COUNTRY_REQUIRED
A fiat leg carries no countryIsoCode.
AMOUNT_REQUIRED
Neither leg carries an amount.
AMOUNT_AMBIGUOUS
Both legs carry an amount. Send exactly one.
A 400 carries no details. A field that fails its own validation answers 400 with no errorCode either, and a validation.errors array instead, one entry per failed field.
Rate limited — 429
A 429 carries a Retry-After header in seconds and a retryAfter field in the body, in epoch milliseconds. Wait the stated time. errorCode names the limit that was hit.
Authentication — 401
The signature, the client id or the timestamp did not check out. See Signing requests. A timestamp more than a few minutes from ours is the most common cause.
What we will not change
errorCode values are stable. We may add a new code, so treat an unknown code as a generic failure and read message. We may also reword any message, so do not match on its text.

