InstafuelInstafuel
FREN
  • Guides
  • Référence
  • Référence API
Guides
  • Démarrage rapide
  • Clés d'API
  • Référence API
Référence
  • Codes d'erreur
  • Glossaire
  • English
Environnement
  • Staging — instafuel-backend-staging.up.railway.app

© Instafuel — Abidjan, Côte d'Ivoire

HomeQuickstartAuthenticationAPI keysBusiness introducerWallet creditErrors and idempotencyLimits and quotasScopeWebhooks
powered by Zudoku
Guides

Errors and idempotency

Error format

Every error has the same shape, whatever the endpoint:

{ "error": { "code": "RESELLER_CREDIT_CAP_EXCEEDED", "message": "Montant supérieur au plafond par opération" } }
FieldContract
codestable. Branch your logic on it.
messageFrench, meant for display. May change without notice — never parse it.
detailspresent 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:

{ "error": { "code": "VALIDATION_ERROR", "message": "Validation échouée", "details": [ { "field": "phoneNumber", "messages": [ "Numéro ivoirien attendu, format +225XXXXXXXXXX" ] }, { "field": "amountFcfa", "messages": [ "Doit être un entier", "Doit être strictement positif" ] } ] } }

Handling it properly, client-side:

Code
const response = await fetch(url, options); if (!response.ok) { const { error } = await response.json(); switch (error.code) { case "RESELLER_DAILY_CAP_EXCEEDED": return rejectWithBusinessMessage(error.code); case "RATE_LIMIT_EXCEEDED": return reschedule(Number(response.headers.get("X-RateLimit-Reset"))); default: // An unknown code is handled by its HTTP status, never silently throw new InstafuelError(response.status, error.code, error.message); } }
Code
response = requests.get(url, headers=headers, timeout=30) if not response.ok: error = response.json()["error"] if error["code"] == "RESELLER_DAILY_CAP_EXCEEDED": reject_with_business_message(error["code"]) elif error["code"] == "RATE_LIMIT_EXCEEDED": reschedule(int(response.headers["X-RateLimit-Reset"])) else: raise InstafuelError(response.status_code, error["code"], error["message"])

HTTP codes

CodeMeaning
400malformed request or missing required header
401key absent, invalid, expired or revoked
403authenticated, but out of scope, out of rights, or IP not allowed
404resource does not exist, or feature not enabled for you
409conflict — uniqueness violated, or replay with a different body
422business rule violated: cap exceeded, role not allowed, validation failed
429rate limit exceeded
5xxserver 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.

curl -X POST "https://instafuel-backend-staging.up.railway.app/v1/papi/companies/4832/wallet/credit" \ -H "Authorization: Bearer $INSTAFUEL_API_KEY" \ -H "X-Idempotency-Key: 8f14e45f-ea0c-4f7e-9a1b-3d2c1e0b9a87" \ -H "Content-Type: application/json" \ -d '{ "amountFcfa": 500000, "source": "BANK_TRANSFER", "clientReference": "VIR-2026-0814", "proofIds": [ "11111111-1111-4111-8111-111111111111" ] }'

What a second call does

The first call records the request:

{ "status": "APPROVAL_PENDING", "approvalId": "0193a1f2-7c44-7c1e-9b0a-5f2d1c8e4b60", "amountFcfa": 500000, "requestedAt": "2026-08-31T09:04:12.000Z", "expiresAt": "2026-09-07T09:04:12.000Z" }

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.

{ "status": "APPROVAL_PENDING", "approvalId": "0193a1f2-7c44-7c1e-9b0a-5f2d1c8e4b60", "amountFcfa": 500000, "requestedAt": "2026-08-31T09:04:12.000Z", "expiresAt": "2026-09-07T09:04:12.000Z" }

The same key with a different body, on the other hand, is a caller-side bug, and the API refuses to guess for you:

{ "error": { "code": "IDEMPOTENCY_CONFLICT", "message": "Cette clé d'idempotence a déjà été utilisée avec un corps différent", "firstSeenAt": "2026-08-31T09:04:12.000Z" } }

And with no header at all:

{ "error": { "code": "IDEMPOTENCY_KEY_MISSING", "message": "En-tête X-Idempotency-Key obligatoire sur cette opération" } }

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
// The key belongs to the operation, not to the HTTP request const operation = { key: crypto.randomUUID(), body: { amountFcfa: 500000 } }; for (let attempt = 1; attempt <= 3; attempt++) { const response = await send(operation); // same key every time if (response.ok || !isRetryable(response.status)) return response; await wait(2 ** attempt * 1000); }
Code
<?php // The key belongs to the operation: generated once, reused on every attempt $idempotencyKey = bin2hex(random_bytes(16)); for ($attempt = 1; $attempt <= 3; $attempt++) { [$status, $response] = send($idempotencyKey, $body); if ($status < 400 || !isRetryable($status)) { return $response; } sleep(2 ** $attempt); }

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

CodeRetry?
429, 5xxyes — exponential backoff, same idempotency key
408, connection drop, timeoutyes — same key: this is exactly the case it covers
401no, unless you have just rotated the key
400, 403, 404, 409, 422no — 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.

{ "error": { "code": "FEATURE_DISABLED", "message": "Fonctionnalité non activée pour ce compte" } }

If an operation documented here returns that code, it needs to be enabled on the Instafuel side for your account.

Last modified on September 2, 2026
Wallet creditLimits and quotas
On this page
  • Error format
  • HTTP codes
  • Idempotency
    • What a second call does
    • Second layer, wallet-side
  • Retry, or not
  • 403 or 404?
  • Features not enabled
JSON
JSON
Javascript
Javascript
PHP
JSON
JSON
JSON
JSON
Javascript
JSON