Skip to content

Errors ​

Every failure — including unknown routes — returns the same envelope.

json
{
  "error": {
    "code": "recipient_validation_failed",
    "message": "Recipient validation failed",
    "details": { "issues": [ /* ... */ ] }
  }
}
FieldContract
codeStable enum. Branch on this.
messageHuman-readable. May be reworded; where a message is guaranteed the reference page says so
detailsPresent 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 ​

StatuscodeCauseRetry?
400validation_failedBad request shape or field valuesNo — fix the request
401unauthorizedKey missing, invalid, expired, disabled, exhaustedNo — fix the key
403forbiddenKey lacks a permission, or the endpoint refuses keysNo
404not_foundId not in your account, or unknown routeNo
409conflictState conflictSometimes — read the message
413payload_too_largeBody or file over limitNo — split it
415unsupported_media_typeWrong Content-Type on campaign createNo
422invalid_mappingColumn mapping does not fit the list schemaNo — fix the mapping
422recipient_validation_failedRecipient rows failed validationNo — fix the rows
422list_emptyThe audience resolved to zero recipientsNo
422unprocessableOther semantic failure (reserved)No
402insufficient_creditsToo few prepaid letters for this campaignYes, after topping up
429rate_limitedOver a rate limit — per key, per account or per IPYes, honour Retry-After
500internal_errorUnexpected server errorCautiously — see below
503service_unavailableShutting down or unavailableYes, 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:

MessageCause
Invalid JSON bodyBody was not parseable JSON
Idempotency-Key must be between 1 and 255 charactersBad 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 PDFBad PDF part
Logo must be a PNG or JPG image / Logo file must be under 5 MBBad logo part
Image must be a PNG or JPG image / Image file must be under 5 MBBad image part
At most 5 images are allowedToo many images
Invalid imagesManifest JSONManifest not parseable
CSV file is required / Excel file is requiredMissing import file part
Invalid file mime or extensionImport file is the wrong type
Import configuration is required / Invalid import configuration JSONMissing or malformed config
Template has no content to inheritThe 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 ​

MessageMeaningWhat to do
Campaign already has a QR codeOne code per campaignUpdate the existing code
List no longer existsThe list was deleted mid-requestRecreate 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:

issueMeaning
unexpected_fieldA custom field not in the list's locked schema
missing_fieldA schema field the row omits, with no default and policy fail
missing_requiredA required value is absent (also used for mapping problems)
duplicate_keyThe 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:

MessageCause
List has no recipientsThe referenced list is empty
The recipients file produced no importable rows — a campaign needs at least one recipientEvery 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 ​