Every error response returned by the API follows the same shape. Codes are stable and machine-readable; messages are human-readable and may change wording between releases.

## Response shape

```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"
  }
}
```

| Field            | Type    | Notes                                                               |
| ---------------- | ------- | ------------------------------------------------------------------- |
| `success`        | `false` | Always `false` on errors.                                           |
| `error.code`     | string  | Stable enum — switch on this in your client code.                   |
| `error.message`  | string  | Human-readable. Suitable for logs, not for end-users.               |
| `error.details`  | unknown | Free-form context. For `VALIDATION_FAILED`, an array of Zod issues. |
| `meta.timestamp` | string  | ISO-8601 UTC. Useful for support tickets.                           |

The HTTP status code mirrors the broad failure category (4xx for client errors, 5xx for server errors). The `error.code` further narrows it down.

## Common codes

| HTTP | `error.code`          | When you'll see it                                                                                          |
| ---- | --------------------- | ----------------------------------------------------------------------------------------------------------- |
| 400  | `VALIDATION_FAILED`   | Request body or query params don't match the schema. `details` lists the field-level issues.                |
| 400  | `INVALID_REQUEST`     | Body is unparsable JSON, or the request shape is fundamentally wrong (missing required content-type, etc.). |
| 401  | `UNAUTHORIZED`        | Missing, malformed, or revoked API key.                                                                     |
| 403  | `PERMISSION_DENIED`   | Authenticated but not allowed (wrong account, locked merchant, expired subscription).                       |
| 404  | `NOT_FOUND`           | The resource id doesn't exist or doesn't belong to your account.                                            |
| 409  | `ALREADY_EXISTS`      | Duplicate creation (e.g. an affiliate with the same email already exists in this campaign).                 |
| 429  | `RATE_LIMIT_EXCEEDED` | Per-IP or per-account rate limit hit. Honour `Retry-After`.                                                 |
| 429  | `LIMIT_EXCEEDED`      | Subscription-tier quota hit (e.g. monthly conversion cap). Upgrade or wait for reset.                       |
| 500  | `SERVER_ERROR`        | Generic upstream failure. Retry with backoff.                                                               |
| 503  | `SERVICE_UNAVAILABLE` | A downstream service (payment provider, queue) is degraded. Retry.                                          |

The full list ships in the OpenAPI spec under `components.schemas.ApiErrorResponse.properties.error.code.enum`.

## Retry strategy

<Callout type="info" title="Don't retry 4xx">
  4xx responses (except 429) mean the request itself is wrong. Retrying without changing the payload will fail again and burns rate-limit budget. Fix the call, not the retry loop.
</Callout>

A robust client distinguishes three cases:

1. **Terminal client error (`4xx` except `429`)** — your payload is wrong. Do NOT retry. Log, fix the call site.
2. **Transient server error (`5xx`)** — RefCampaign upstream is unhappy. Retry with exponential backoff (1s, 2s, 4s, 8s) up to \~5 attempts.
3. **Rate limited (`429`)** — back off according to the `Retry-After` header. The server tells you when it's safe to come back.

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')
}
```

The official [`@refcampaign/sdk`](/docs/api/integration/sdk) handles all of this for you.

## Validation errors

`VALIDATION_FAILED` returns `error.details` as an array of Zod issues:

```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" }
    ]
  }
}
```

The `path` array is the dotted path into your request body or query params. Surface these in your UI / logs to point users at the specific field.

## Idempotency and duplicate-error handling

Some endpoints are idempotent on a key (`POST /api/v1/conversions/postback` on `externalId` — see [postback](/docs/api/integration/postback)). A repeat call with the same `externalId` returns the same conversion without creating a duplicate. `409 ALREADY_EXISTS` is reserved for cross-account conflicts (the same `externalId` submitted by a different merchant) or other genuine duplicate creation attempts (e.g. signing up the same email to the same campaign twice).

## When to alert

Consider treating these as paging-worthy:

* Any sustained `5xx` rate above \~1% over 5 min — RefCampaign side issue.
* Any `429 RATE_LIMIT_EXCEEDED` you didn't expect — your traffic shape changed; review.
* `403 PERMISSION_DENIED` outside of expected role-restricted paths — your subscription may have changed status.

`401 UNAUTHORIZED` after a deploy almost always means the API key wasn't propagated to the new environment.
