Appearance
Errors
Every failure — including unknown routes — returns the same envelope.
json
{
"error": {
"code": "recipient_validation_failed",
"message": "Recipient validation failed",
"details": { "issues": [ /* ... */ ] }
}
}| Field | Contract |
|---|---|
code | Stable enum. Branch on this. |
message | Human-readable. May be reworded; where a message is guaranteed the reference page says so |
details | Present only where documented |
ts
const body = await res.json();
if (!res.ok) {
switch (body.error.code) {
case "rate_limited": return backOffAndRetry();
case "recipient_validation_failed": return reportRows(body.error.details.issues);
case "unauthorized":
case "forbidden": return alertOperator(body.error.message);
default: throw new Error(body.error.message);
}
}The catalog
| Status | code | Cause | Retry? |
|---|---|---|---|
| 400 | validation_failed | Bad request shape or field values | No — fix the request |
| 401 | unauthorized | Key missing, invalid, expired, disabled, exhausted | No — fix the key |
| 403 | forbidden | Key lacks a permission, or the endpoint refuses keys | No |
| 404 | not_found | Id not in your account, or unknown route | No |
| 409 | conflict | State conflict | Sometimes — read the message |
| 413 | payload_too_large | Body or file over limit | No — split it |
| 415 | unsupported_media_type | Wrong Content-Type on campaign create | No |
| 422 | invalid_mapping | Column mapping does not fit the list schema | No — fix the mapping |
| 422 | recipient_validation_failed | Recipient rows failed validation | No — fix the rows |
| 422 | list_empty | The audience resolved to zero recipients | No |
| 422 | unprocessable | Other semantic failure (reserved) | No |
| 402 | insufficient_credits | Too few prepaid letters for this campaign | Yes, after topping up |
| 429 | rate_limited | Over a rate limit — per key, per account or per IP | Yes, honour Retry-After |
| 500 | internal_error | Unexpected server error | Cautiously — see below |
| 503 | service_unavailable | Shutting down or unavailable | Yes, after backoff |
Only 429 and 503 are safe to retry unchanged. 402 is retryable too, but only after the balance has been topped up — retrying the same request against the same balance fails identically.
Everything else in the 4xx range is a bug in the request.
400 validation_failed
Two shapes. Schema failures carry flattened field errors:
json
{
"error": {
"code": "validation_failed",
"message": "Request validation failed",
"details": {
"formErrors": [],
"fieldErrors": { "name": ["Campaign name is required"] }
}
}
}fieldErrors is keyed by field name; formErrors holds cross-field problems — where the "exactly one content source" and "exactly one recipient source" violations appear.
Other 400s are message-only, with no details:
| Message | Cause |
|---|---|
Invalid JSON body | Body was not parseable JSON |
Idempotency-Key must be between 1 and 255 characters | Bad header |
Could not detect the recipients file format — pass fileFormat: "csv" or "excel" | Ambiguous file |
File must be a PDF / PDF must be under 20 MB / File is not a valid PDF | Bad PDF part |
Logo must be a PNG or JPG image / Logo file must be under 5 MB | Bad logo part |
Image must be a PNG or JPG image / Image file must be under 5 MB | Bad image part |
At most 5 images are allowed | Too many images |
Invalid imagesManifest JSON | Manifest not parseable |
CSV file is required / Excel file is required | Missing import file part |
Invalid file mime or extension | Import file is the wrong type |
Import configuration is required / Invalid import configuration JSON | Missing or malformed config |
Template has no content to inherit | The template was saved empty |
Check for details before reading it.
ts
const fieldErrors = body.error.details?.fieldErrors ?? {};404 versus 403
403 is only ever about permissions
A resource owned by another account is 404, never 403 — the API never reveals it exists. So a 404 means one of:
- the id does not exist,
- it belongs to another account,
- it is internal (a campaign's snapshot
templateId).
If a 404 surprises you, confirm the key belongs to the owning account.
Guaranteed messages: List not found · Template not found · Campaign not found · QR code not found · Route not found. The id is never echoed back.
409 conflict
| Message | Meaning | What to do |
|---|---|---|
Campaign already has a QR code | One code per campaign | Update the existing code |
List no longer exists | The list was deleted mid-request | Recreate the audience and retry |
The 422 family
All three mean the request was well-formed but the data is unusable, and in all three nothing was written.
recipient_validation_failed
json
{
"error": {
"code": "recipient_validation_failed",
"message": "Recipient validation failed",
"details": {
"issues": [
{ "row": 2, "field": "vip", "issue": "missing_field", "message": "Missing custom field \"vip\" (required by list schema)" }
]
}
}
}The message differs by path: Schema validation failed from import and single-add, Recipient validation failed from campaign create. Branch on code.
issue values:
issue | Meaning |
|---|---|
unexpected_field | A custom field not in the list's locked schema |
missing_field | A schema field the row omits, with no default and policy fail |
missing_required | A required value is absent (also used for mapping problems) |
duplicate_key | The same custom-field key mapped from two columns |
row is a 1-based data row index, header excluded. For inline recipients it is the position in your array.
invalid_mapping
Invalid column mapping, with details.issues in the same shape. row: 0 marks a mapping-level rather than a data-level problem. See Recipient files.
list_empty
The campaign has no one to mail:
| Message | Cause |
|---|---|
List has no recipients | The referenced list is empty |
The recipients file produced no importable rows — a campaign needs at least one recipient | Every row was skipped |
Note the second: the same file sent to the import endpoint returns 200 with imported: 0. A campaign cannot exist without a recipient, so here it is a hard error — usually a wrong columnIndex.
5xx
500 internal_error — unexpected. Retry reads with backoff. For POST /api/v1/campaigns, do not blindly retry: the campaign may have been created. Retry with the same Idempotency-Key, which returns the original if one exists.
503 service_unavailable — the instance is draining. Nothing was processed; retry with backoff.
Errors are always JSON
A typo'd path returns the envelope, not an HTML page:
json
{ "error": { "code": "not_found", "message": "Route not found" } }On guarded paths auth is checked before routing, so an unauthenticated request to a wrong URL is 401, not 404. Verify the key before the URL.
A complete handler
ts
type ApiError = {
error: { code: string; message: string; details?: unknown };
};
export async function call<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE}/api/v1${path}`, {
...init,
headers: { ...init.headers, "x-api-key": KEY },
});
if (res.ok) return (await res.json()) as T;
const body = (await res.json()) as ApiError;
const { code, message, details } = body.error;
if (code === "rate_limited" || code === "service_unavailable") {
// Retry-After is present on every 429; prefer it over guessing.
const retryAfter = Number(res.headers.get("Retry-After")) || undefined;
throw Object.assign(new Error(message), { code, retryable: true, retryAfter });
}
throw Object.assign(new Error(message), { code, details, retryable: false });
}Next
- Rate limits — backoff strategy
- Polling & failures — handling a
failedcampaign - Conventions — the envelope