Errors and idempotency
Error format
Every error has the same shape, whatever the endpoint:
| Field | Contract |
|---|---|
code | stable. Branch your logic on it. |
message | French, meant for display. May change without notice — never parse it. |
details | present on validation errors: a list of { field, messages[] }. |
Messages are in French
error.message is produced by the API for end users in Côte d'Ivoire, and is not translated. If your interface is in English, map error.code to your own wording — which is what you should do anyway.
A failed validation details every offending field, and every reason for each:
Handling it properly, client-side:
Code
Code
HTTP codes
| Code | Meaning |
|---|---|
400 | malformed request or missing required header |
401 | key absent, invalid, expired or revoked |
403 | authenticated, but out of scope, out of rights, or IP not allowed |
404 | resource does not exist, or feature not enabled for you |
409 | conflict — uniqueness violated, or replay with a different body |
422 | business rule violated: cap exceeded, role not allowed, validation failed |
429 | rate limit exceeded |
5xx | server error |
The 400 / 422 split is useful: 400 means "your request is malformed", 422 means "your request is well-formed but the operation is refused". An exceeded cap is a 422 — retrying it as-is is pointless.
Idempotency
Every mutation requires the X-Idempotency-Key header: a UUID you generate.
What a second call does
The first call records the request:
The same call replayed with the same key and the same body does not create a second request: it returns the stored response, identical — same approvalId, same requestedAt — along with the X-Idempotent-Replay: true header.
The same key with a different body, on the other hand, is a caller-side bug, and the API refuses to guess for you:
And with no header at all:
Keys are kept for 24 h.
One key per business operation, not per attempt
Generate the key when the operation is decided — not when it is sent — and reuse it for every network attempt of that operation.
Generating a new key on each retry defeats the whole protection: each attempt becomes a distinct operation, and a 500,000 FCFA payment declared three times yields three requests.
Code
Code
Second layer, wallet-side
Independently of the HTTP header, every ledger entry carries a key derived from the wallet and the payment reference, under a uniqueness constraint in the database. The same clientReference on the same wallet therefore cannot produce two entries, even if the header was mishandled.
That reference is prefixed server-side with your introducer identifier: two partners using the same reference on the same wallet do not collide.
Retry, or not
| Code | Retry? |
|---|---|
429, 5xx | yes — exponential backoff, same idempotency key |
408, connection drop, timeout | yes — same key: this is exactly the case it covers |
401 | no, unless you have just rotated the key |
400, 403, 404, 409, 422 | no — an identical replay produces the same result |
Silence is not a failure
If the connection drops before the response, the operation may well have gone through. Treat it as neither succeeded nor failed: replay it with the same idempotency key. You will get the stored response if it had gone through, and the operation will be executed otherwise.
403 or 404?
Both exist and do not mean the same thing:
403— the resource is identified and sits outside your scope.404— the resource does not exist, or the scope is applied inside the query, which makes the two cases indistinguishable.
A 404 on an identifier you believe valid therefore usually means it belongs to someone else. See Scope.
Features not enabled
A module not opened for your account answers 404 FEATURE_DISABLED, not 403. Until a feature is enabled, it is invisible: you cannot infer its existence by probing the API.
If an operation documented here returns that code, it needs to be enabled on the Instafuel side for your account.
