# Errors and idempotency

import { ApiExample, ApiResponse } from "../../../src/ApiExample";

## Error format

Every error has the same shape, whatever the endpoint:

<ApiResponse
  status={422}
  body={{
    error: {
      code: "RESELLER_CREDIT_CAP_EXCEEDED",
      message: "Montant supérieur au plafond par opération",
    },
  }}
/>

| 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[] }`. |

<Callout type="note" title="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.
</Callout>

A failed validation details every offending field, and every reason for each:

<ApiResponse
  status={422}
  body={{
    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:

```javascript title="JavaScript"
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);
  }
}
```

```python title="Python"
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

| 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.

<ApiExample
  method="POST"
  path="/v1/papi/companies/4832/wallet/credit"
  idempotency
  body={{
    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:

<ApiResponse
  status={202}
  title="First call — 202"
  body={{
    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.

<ApiResponse
  status={202}
  title="Replay, same body — 202, stored response"
  body={{
    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:

<ApiResponse
  status={409}
  title="Replay, different body — 409"
  body={{
    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:

<ApiResponse
  status={400}
  body={{
    error: {
      code: "IDEMPOTENCY_KEY_MISSING",
      message: "En-tête X-Idempotency-Key obligatoire sur cette opération",
    },
  }}
/>

Keys are kept for **24 h**.

<Callout type="tip" title="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.
</Callout>

```javascript title="JavaScript"
// 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);
}
```

```php title="PHP"
<?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

| 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 |

<Callout type="caution" title="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.
</Callout>

## `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](/en/guides/perimetre).

## 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.

<ApiResponse
  status={404}
  body={{
    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.
