Errors
Every error the API raises has the same shape and a stable code. Branch on the code; show or log the message; quote the correlation id when you contact us.
The error shape
A failed request returns a 4xx or 5xx status and a JSON body with exactly these fields:
| Field | Type | Meaning |
|---|---|---|
code | string | Stable and machine-readable. Branch on this. |
message | string | Written for a person. It may be reworded; never parse it. |
correlation_id | string | Identifies this request in our logs. Quote it when you contact us. |
details | object | What you need in order to act — for a validation error, which fields failed. Often empty. |
retry_after_seconds | integer | null | Set when waiting will help. The same value is sent in the Retry-After header. |
HTTP/1.1 429 Too Many Requests
Retry-After: 12
X-Correlation-ID: 3f1c9e0a5b7d4e2f8a6c1b9d0e4f7a2c
{
"code": "rate_limited",
"message": "too many requests — please slow down",
"correlation_id": "3f1c9e0a5b7d4e2f8a6c1b9d0e4f7a2c",
"details": {},
"retry_after_seconds": 12
}The body never contains a stack trace, an internal identifier or the text of an exception. What went wrong in detail is in our logs, under the correlation id.
Handling errors
- Branch on
code, never onmessage. Codes are part of the contract: renaming one is a breaking change we will not make without a new version. Messages are written for people and can be reworded at any time. - Expect codes you have not seen. Adding a code is not a breaking change, and new endpoints bring new codes. Handle an unrecognised code by its HTTP status class.
const res = await fetch(url, { headers })
if (!res.ok) {
const err = await res.json().catch(() => null)
if (err === null || typeof err.code !== 'string') {
// Not an API error: a path or method that does not exist (see below).
throw new Error(`HTTP ${res.status} from ${url}`)
}
switch (err.code) {
case 'rate_limited':
await sleep(err.retry_after_seconds * 1000)
return retry()
case 'unauthenticated':
case 'permission_denied':
throw new ConfigurationError(err) // a person has to fix this
default:
// A code you do not recognise: decide by status class.
if (res.status >= 500) return retryWithBackoff()
throw new RequestError(err)
}
}Error codes
These are every code a published endpoint can return today.
| Status | Code | When | What to do |
|---|---|---|---|
| 401 | unauthenticated | The X-API-Key header is missing; the key is unknown, revoked, expired or malformed; or it is a key for the other environment — a test key where live keys are served, or the reverse. | Check the header name and the key. Do not retry unchanged. |
| 403 | permission_denied | The key is valid but lacks the scope this endpoint requires, or your account is not active. | Use a key with the right scope, or contact us about the account. |
| 404 | not_found | A replay names a delivery that is not yours, or is not in a replayable state. | List your deliveries and check the id and its status. |
| 422 | validation_failed | A parameter or body field is missing or malformed. | Read details.fields. Fix the request before sending it again. |
| 400 | idempotency_key_required | A money endpoint was called without an Idempotency-Key header. Refused before the body is read. | Send one. Generate it once per payout and keep it with your record of the payout. |
| 402 | insufficient_funds | Your balance cannot cover the payout plus our fee without going below your floor. | Top up your balance, then send the payout again — with the same key or a new one. Nothing was sent. |
| 409 | request_in_progress | The same Idempotency-Key and the same request are still being processed. | Wait, then send the identical request again to get its answer. Do not change the key. |
| 409 | funds_not_cleared | Your balance covers the payout, but part of it is money still clearing, and only cleared money can fund a payout. | Wait at least retry_after_seconds, then send the identical request again. details says what has cleared and when the next part clears. |
| 422 | idempotency_key_reused | An Idempotency-Key you already used was sent with a different request. | Use a new key for a new payout. Never change a request and keep its key. |
| 429 | rate_limited | Your account has exceeded its requests-per-minute limit. | Wait retry_after_seconds, then retry. |
| 503 | product_unavailable | On a payout: we could not start it, and nothing was sent. On a purchase: either nothing was bought, or it reached the biller and we could not confirm it — the response does not say which. | Payout: retry later. Purchase: do not retry, do not refund your customer, and contact us with the correlation_id. |
| 500 | internal_error | Something failed on our side. The response deliberately says nothing about what. | Retry a read with backoff. Quote the correlation_id if it persists. |
The exact 401 and 403 messages, and what each one means, are in Authentication. Endpoints that move money will add codes of their own — for an insufficient balance, for example — and each is documented with the endpoint that returns it.
One status, two codes
A status alone does not say what happened. On POST /v1/partner/payouts, 409 has two meanings, and they need opposite handling:
request_in_progress— the same idempotency key is still being processed. Send the identical request again in a moment.funds_not_cleared— your balance covers the payout, but part of it is money still clearing, and only cleared money can fund a payout. Topping up does not help; waiting does.
{
"code": "funds_not_cleared",
"message": "₦50,000.00 needed but only ₦0.00 has cleared; ₦50,000.00 is still clearing until 06 Nov 14:22 UTC",
"correlation_id": "5e8a1c3f0b7d4e29a6c2f1d0e9b8a7c6",
"details": {
"cleared": { "amount_minor": "0", "currency": "NGN" },
"required": { "amount_minor": "5000000", "currency": "NGN" },
"next_clears_at": "2026-11-06T14:22:00+00:00"
},
"retry_after_seconds": 83921
}details.cleared is what has cleared, details.required is what the payout needs including our fee, and details.next_clears_at is when the next part clears. retry_after_seconds (and the Retry-After header) count down to that moment.
Each top-up clears on its own schedule. When the first part clears, it may still not be enough, and the same request can be refused again with a later time. Keep waiting the new retry_after_seconds each time; do not give up after the first wait.
Validation errors
A 422 validation_failed lists every field that failed in details.fields, each with the field's name and the problem:
{
"code": "validation_failed",
"message": "the request was not valid",
"correlation_id": "b2d4f6a8c0e24f6a8b0c2d4e6f8a0b2c",
"details": {
"fields": [
{ "field": "limit", "problem": "Input should be a valid integer, unable to parse string as an integer" }
]
},
"retry_after_seconds": null
}The response names the field, not the value you sent, so that identity numbers and account details stay out of error responses — log what you sent on your own side. The problem text is descriptive, not a code; do not parse it.
Authentication is checked first. A request with no valid key gets 401, even if it is also malformed.
Correlation ids
Every response has a correlation id. It is in the X-Correlation-ID response header and, for errors, in the correlation_id field — and it is the fastest way for us to find your request.
A 500 internal_error response does not carry the X-Correlation-ID header. The correlation_id field is always present in an error body, so that is the one to log.
You can send your own, to tie our records to yours. If it is between 8 and 64 characters long and contains only letters, digits and hyphens, we use it; otherwise we ignore it and issue one of ours. Either way, the response tells you which id we used.
curl $VERTEXBRIDGE_API_BASE/v1/partner/me \
-H "X-API-Key: $VERTEXBRIDGE_API_KEY" \
-H "X-Correlation-ID: order-7f3a9c21"It is a label, not a security control: it never affects what a request is allowed to do, and it must not contain anything secret or personal.
Retrying
| Status | Retry? |
|---|---|
401, 403, 404, 422 | No. The same request will fail the same way until something is changed. |
429 | Yes, after retry_after_seconds. |
500 | A read: yes, with exponential backoff and a cap. Anything that moves money: only with the same Idempotency-Key. If it persists, contact us with the correlation id. |
503 | It depends on the endpoint — see below. Never let a library retry a 503 automatically on an endpoint that moves money. |
A read is always safe to retry. Anything that moves money — a payout, a purchase — is safe to retry only with the same Idempotency-Key: the same key can never pay twice, and a new key always can. See Idempotency. Webhook replay is the one read-like call with a side effect: a replay that succeeded puts the delivery back in the queue, so retrying it answers 404 rather than queuing it twice.
Your request timed out
A timeout is not an error from us — it is the absence of one, and you cannot tell from it whether we acted. For anything that moves money, do one of two things, never a third:
- Send the identical request again with the same
Idempotency-Key. If we acted, you get the original answer; if we did not, it happens now — once. - Or look it up by your own reference, if you sent one:
GET /v1/partner/transactions?your_reference=…. No transaction means nothing happened; otherwise it gives you ourreference, and you follow its state.
The third thing — sending it again under a new key — is how a payment happens twice. See Idempotency.
A 503 does not mean the same thing everywhere
- On a payout,
503 product_unavailablemeans we could not start it: nothing was sent. Retry later. - On a purchase, the same
503 product_unavailablecan mean either that nothing was bought, or that the purchase reached the biller and we could not confirm what happened. The response does not tell you which. Do not retry it and do not refund your customer. If you sent ayour_reference, look it up withGET /v1/partner/transactions?your_reference=…: no transaction means nothing was bought; otherwise read itsstateand follow it to the end. If you did not send one, contact us with thecorrelation_id.
Responses without a code
A path that does not exist answers 404, and a method a path does not support answers 405, each with a body of the form {"detail": "Not Found"} and no code field. Those are not API errors: check the URL, the /v1 prefix and the method.