Chaque réponse d'erreur retournée par l'API suit la même forme. Les codes sont stables et machine-readable ; les messages sont human-readable et peuvent changer de formulation entre versions.

## Forme de la réponse

```json
{
  "success": false,
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Validation failed.",
    "details": [
      {
        "path": ["amount"],
        "message": "Number must be greater than 0"
      }
    ]
  },
  "meta": {
    "timestamp": "2026-05-03T10:23:11.000Z"
  }
}
```

| Champ            | Type    | Notes                                                              |
| ---------------- | ------- | ------------------------------------------------------------------ |
| `success`        | `false` | Toujours `false` sur les erreurs.                                  |
| `error.code`     | string  | Enum stable — basez votre logique client sur ce champ.             |
| `error.message`  | string  | Human-readable. Adapté aux logs, pas aux end-users.                |
| `error.details`  | unknown | Contexte libre. Pour `VALIDATION_FAILED`, un tableau d'issues Zod. |
| `meta.timestamp` | string  | ISO-8601 UTC. Utile pour les tickets support.                      |

Le code HTTP miroir la catégorie d'échec (4xx pour erreurs client, 5xx pour erreurs serveur). `error.code` affine davantage.

## Codes courants

| HTTP | `error.code`          | Quand vous le verrez                                                                              |
| ---- | --------------------- | ------------------------------------------------------------------------------------------------- |
| 400  | `VALIDATION_FAILED`   | Le body ou les query params ne matchent pas le schema. `details` liste les problèmes par champ.   |
| 400  | `INVALID_REQUEST`     | Body unparseable, ou shape de requête fondamentalement mauvaise (content-type manquant, etc.).    |
| 401  | `UNAUTHORIZED`        | Clé API manquante, mal formée ou révoquée.                                                        |
| 403  | `PERMISSION_DENIED`   | Authentifié mais pas autorisé (mauvais compte, marchand verrouillé, abonnement expiré).           |
| 404  | `NOT_FOUND`           | L'id de ressource n'existe pas ou n'appartient pas à votre compte.                                |
| 409  | `ALREADY_EXISTS`      | Création dupliquée (ex. un affilié avec le même email existe déjà dans cette campagne).           |
| 429  | `RATE_LIMIT_EXCEEDED` | Limite par IP ou par compte atteinte. Respectez `Retry-After`.                                    |
| 429  | `LIMIT_EXCEEDED`      | Quota du tier d'abonnement atteint (ex. cap mensuel de conversions). Upgrade ou attente du reset. |
| 500  | `SERVER_ERROR`        | Échec upstream générique. Retry avec backoff.                                                     |
| 503  | `SERVICE_UNAVAILABLE` | Un service downstream (provider de paiement, queue) est dégradé. Retry.                           |

La liste complète est dans la spec OpenAPI sous `components.schemas.ApiErrorResponse.properties.error.code.enum`.

## Stratégie de retry

<Callout type="info" title="Pas de retry sur les 4xx">
  Les réponses 4xx (sauf 429) signifient que la requête elle-même est mauvaise. Retry sans changer le payload échouera à nouveau et grignote votre quota de rate limit. Corrigez l'appel, pas la boucle de retry.
</Callout>

Un client robuste distingue trois cas :

1. **Erreur client terminale (`4xx` sauf `429`)** — votre payload est mauvais. NE PAS retry. Logguez, corrigez le call site.
2. **Erreur serveur transitoire (`5xx`)** — RefCampaign upstream est en difficulté. Retry avec backoff exponentiel (1s, 2s, 4s, 8s) jusqu'à environ 5 tentatives.
3. **Rate limited (`429`)** — backoff selon le header `Retry-After`. Le serveur vous dit quand revenir.

Pseudocode :

```ts
async function callWithRetry(fetcher, maxAttempts = 5) {
  let attempt = 0
  while (attempt < maxAttempts) {
    const res = await fetcher()
    if (res.ok) return res
    if (res.status === 429) {
      const retryAfter = parseInt(res.headers.get('retry-after') ?? '5', 10)
      await sleep(retryAfter * 1000)
      attempt++
      continue
    }
    if (res.status >= 500) {
      await sleep(Math.min(2 ** attempt * 1000, 30_000))
      attempt++
      continue
    }
    throw await errorFromResponse(res) // 4xx — terminal
  }
  throw new Error('Exhausted retries')
}
```

Le SDK officiel [`@refcampaign/sdk`](/fr/docs/api/integration/sdk) gère tout cela pour vous.

## Erreurs de validation

`VALIDATION_FAILED` renvoie `error.details` comme un tableau d'issues Zod :

```json
{
  "success": false,
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Validation failed.",
    "details": [
      { "path": ["currency"], "code": "invalid_string", "message": "currency must be a 3-letter ISO code" },
      { "path": ["amount"], "code": "too_small", "message": "Number must be greater than 0" }
    ]
  }
}
```

Le tableau `path` est le chemin pointé dans votre body de requête ou query params. Affichez ces données dans votre UI / logs pour pointer l'utilisateur sur le champ exact.

## Idempotence et gestion des erreurs de duplication

Certains endpoints sont idempotents sur une clé (`POST /api/v1/conversions/postback` sur `externalId` — voir [postback](/fr/docs/api/integration/postback)). Un appel répété avec le même `externalId` renvoie la même conversion sans créer de doublon. `409 ALREADY_EXISTS` est réservé aux conflits inter-comptes (le même `externalId` soumis par un autre marchand) ou aux vraies tentatives de création dupliquées (ex. inscrire le même email à la même campagne deux fois).

## Quand alerter

Considérez ces situations comme dignes d'astreinte :

* Tout taux de `5xx` soutenu au-dessus de \~1% sur 5 min — problème côté RefCampaign.
* Tout `429 RATE_LIMIT_EXCEEDED` inattendu — la forme de votre trafic a changé ; à investiguer.
* `403 PERMISSION_DENIED` en dehors des paths attendus — le statut de votre abonnement a peut-être changé.

`401 UNAUTHORIZED` après un déploiement signifie presque toujours que la clé API n'a pas été propagée au nouvel environnement.
