Error handling
Error response shape, common codes, and retry strategy.
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
{
"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
A robust client distinguishes three cases:
- Terminal client error (
4xxexcept429) — your payload is wrong. Do NOT retry. Log, fix the call site. - Transient server error (
5xx) — RefCampaign upstream is unhappy. Retry with exponential backoff (1s, 2s, 4s, 8s) up to ~5 attempts. - Rate limited (
429) — back off according to theRetry-Afterheader. The server tells you when it's safe to come back.
Pseudocode:
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 handles all of this for you.
Validation errors
VALIDATION_FAILED returns error.details as an array of Zod issues:
{
"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/track on sessionId within five minutes — see postback). If your retry logic fires a duplicate, you'll get the same successful response, not a 409. 409 ALREADY_EXISTS is reserved for 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
5xxrate above ~1% over 5 min — RefCampaign side issue. - Any
429 RATE_LIMIT_EXCEEDEDyou didn't expect — your traffic shape changed; review. 403 PERMISSION_DENIEDoutside 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.