# Errors

The envelope, every public code, and which are worth retrying

Every error — validation, authentication, conflict, server fault — comes back in one
shape, with the HTTP status set appropriately:

```json
{
  "error": {
    "code": "CONFLICT",
    "message": "Only draft campaigns can be changed or sent",
    "details": { },
    "requestId": "0192f1c4-…"
  }
}
```

`requestId` is also the `X-Request-Id` header, on every response, not only errors. Quote it
when you contact support: it finds the request in our logs.

`code` is a stable machine-readable identifier — **branch on this**. `message` is
human-readable English intended for logs and developers, not for end users; its wording
may change at any time. `details` is optional and its shape varies by code.

## The catalogue

These are the codes a `/v1` client can receive.

The API defines more codes than these, but the rest are reachable only from
cookie-session surfaces — the dashboard, the AI console, account management — and a
server-to-server client will never see one. They are deliberately left out of this list
so no SDK carries a dead exception type.

Scopes, agent keys and IP
restrictions are explained in [Authentication](/docs/api/authentication), and
`IDEMPOTENCY_KEY_REUSED` in [Idempotency](/docs/api/idempotency).


| Code | Status | |
| --- | --- | --- |
| `BAD_REQUEST` | 400 / 413 / 415 | A malformed request that is not a schema violation: unreadable JSON, a body too large, an unsupported content type. |
| `ALREADY_EXISTS` | 409 | Another resource in this workspace already holds a value that must be unique. `details.field` names it: a contact's `email`, `phone` or `externalId`, a group's or segment's `name`, a sending domain's `domain`, a template's `alias`, a partner workspace's `externalId`. |
| `CONFLICT` | 409 | The resource is not in a state that allows this: editing a campaign that is no longer a draft, unscheduling one that is not scheduled, editing an ended journey, tracking an event name being deleted. `message` says what to do instead. |
| `IN_USE` | 409 | Something still uses the resource, so it cannot be deleted: a campaign's audience group or segment, a placed shared block, an event name a journey, segment, campaign or goal reads. `details` say where. |
| `FORBIDDEN` | 403 | Valid key, wrong scope, or an agent key on a route agents may not use. |
| `INTERNAL_ERROR` | 500 | Unexpected fault. Retry with backoff. |
| `NOT_FOUND` | 404 | No such resource in this workspace. Another workspace's resource answers the same. |
| `RATE_LIMITED` | 429 | Too many requests. Wait `Retry-After` seconds; `details.retryAfterSeconds` carries the same. |
| `REVISION_CONFLICT` | 409 | The template, campaign or journey changed since you read it (`expectedRevision` is stale). `details` carry the current `revision`, `updatedAt` and who changed it. |
| `SERVICE_UNAVAILABLE` | 503 | A dependency is temporarily down. Retry with backoff. |
| `UNAUTHORIZED` | 401 | Key missing, malformed, revoked or expired. |
| `VALIDATION_ERROR` | 400 | Body, query or path failed validation. `details` carries the field errors. |
| `API_KEY_IP_NOT_ALLOWED` | 403 | The key is restricted to other IP addresses. |
| `AGENT_CONFIRMATION_REQUIRED` | 409 | An agent wrote or edited this campaign or journey; a person confirms it in the dashboard before it sends, schedules or activates. |
| `EMBED_ORIGIN_NOT_ALLOWED` | 403 / 409 | 403: an embed session used from a page its partner key does not allow. 409: starting an embed session on a partner key with no `allowedOrigins`. |
| `WORKSPACE_SUSPENDED` | 403 | The workspace is suspended; nothing sends until it is resumed. |
| `WORKSPACE_DELETION_PENDING` | 403 | The workspace is being deleted. |
| `CONTACT_SEND_IN_PROGRESS` | 409 | The contact is part of a send that is running; try again once it finishes. |
| `EXPORT_UNAVAILABLE` | 409 / 410 | 409: the export is not ready yet. 410: it has expired; start a new one. |
| `NOTHING_TO_EXPORT` | 400 | The export would hold no rows. |
| `EVENT_NAME_LIMIT_REACHED` | 409 | The workspace already tracks the most distinct event names it can. Delete unused names, and put what varies (ids, prices) in `properties`. |
| `PLAN_LIMIT_REACHED` | 403 | A limit of the workspace owner's plan stops this: emails this month or today, contacts, image storage, workspaces, members, sending domains, active journeys or the Kiro allowance. `details.entitlement` names the limit, `details.limit` and `details.used` say how far it goes, and `details.upgradeTo` names the plan that lifts it. |
| `PLAN_FEATURE_UNAVAILABLE` | 403 | The workspace owner's plan does not include this feature. `details.entitlement` names it and `details.upgradeTo` the plan that does. |
| `CAMPAIGN_AUDIENCE_CONFLICT` | 409 | Messages already exist for part of this audience from an interrupted send; send again to reconcile. |
| `EMPTY_AUDIENCE` | 400 | The audience resolves to nobody. |
| `INVALID_CAMPAIGN_CONTENT` | 400 | The content snapshot is not valid. |
| `DRAFT_INCOMPLETE` | 409 | The draft is not finished; `details.issues` names each missing or invalid field. |
| `INVALID_SCHEDULE` | 400 | `sendAt` is not in the future. |
| `UNSUPPORTED_CHANNEL` | 400 | The channel is not available on this deployment. |
| `POSTAL_ADDRESS_MISSING` | 409 | Marketing email needs the workspace's postal address; set it first. |
| `CONSENT_MISSING` | 403 | The contact has not consented on that channel. |
| `SUPPRESSED` | 403 | The address is suppressed after a bounce or complaint. |
| `SIGNUP_BLOCKED` | 400 | The address matches the workspace's signup blocklist, so no contact is created. |
| `INVALID_SENDER_ADDRESS` | 400 | The from-address is not a valid sender. |
| `SENDING_DOMAIN_DMARC_MISSING` | 409 | Marketing email needs a DMARC record on the sending domain (`p=none` at least). |
| `SENDING_DOMAIN_IDENTITY_CONFLICT` | 409 | The domain conflicts with an existing provider identity. |
| `SENDING_DOMAIN_UNVERIFIED` | 403 | The from-address is not on a verified sending domain; nothing sends from it until DNS verification succeeds. |
| `IDEMPOTENCY_KEY_REUSED` | 409 | Same `Idempotency-Key`, different payload. |
| `CONTENT_NOT_RETAINED` | 404 | The message exists, but what it sent was never kept or has expired. |
| `RECIPIENT_EMAIL_MISSING` | 400 | The contact has no email address. |
| `TEMPLATE_NOT_PUBLISHED` | 409 | `templateId` names a template with no published version. Publish it first. |
| `TEMPLATE_ENVELOPE_INCOMPLETE` | 409 | The template and the send together give no `subject` or no `from`; `details` name what is missing. |
| `PUSH_APP_NOT_READY` | 409 | No configured push application. |
| `PUSH_DESTINATION_MISSING` | 409 | The contact has no active push installation. |


## Common problems

**`VALIDATION_ERROR`** — the body, query or path failed validation (HTTP 400).
**Solution:** read `details` for the field errors, fix those fields, and send again.

**`FORBIDDEN`** — the key lacks a scope the route needs (HTTP 403, message "API key lacks
the `<scope>` scope"). **Solution:** create a key with that scope, or add it where your
key kind allows; an agent key is also refused on send routes whatever its scopes
(see [Authentication](/docs/api/authentication#key-kinds)).

**`IDEMPOTENCY_KEY_REUSED`** — the same `Idempotency-Key` arrived with a different
payload (HTTP 409). **Solution:** repeat the original request exactly to retry it, or use
a new key for a genuinely different send.

**`TEMPLATE_NOT_PUBLISHED`** — `templateId` names a template with no
published version (HTTP 409). **Solution:** publish the template, then retry with the
same `Idempotency-Key`.

**`RATE_LIMITED`** — too many requests for the budget (HTTP 429). **Solution:** wait
`Retry-After` seconds and retry with backoff; see [Rate limits](/docs/api/rate-limits).

**`SENDING_DOMAIN_UNVERIFIED`** — the from-address is not on a verified sending domain
(HTTP 403). **Solution:** verify the domain under **Settings → Email → Domains**, or send
from an address on a domain that is already verified.

## What to retry

| | |
| --- | --- |
| **Retry with backoff** | `RATE_LIMITED` (honour `Retry-After`), `SERVICE_UNAVAILABLE`, `INTERNAL_ERROR`, and transport-level failures |
| **Never retry unchanged** | Every 4xx other than 429 — the request is wrong, and repeating it will fail identically |

Retrying is only safe when the operation is idempotent. Read [Idempotency](/docs/api/idempotency)
before building retry logic into a write path.

## Mapping errors in an SDK

Model `code` on the catalogue above. Then:

- Expose `code` as a typed value, not a bare string.
- Keep the unparsed `details` reachable — clients need the field errors inside a
  `VALIDATION_ERROR`, and no typed wrapper will anticipate every shape.
- Include the HTTP status and, where available, a request identifier on the exception.
- **Handle an unrecognised code without crashing.** New codes are additive and can appear
  before your SDK is updated; fall back to a generic error carrying the raw code rather
  than throwing a parse failure.

## Next steps

- [Idempotency](/docs/api/idempotency) — retry writes without repeating them
- [Rate limits](/docs/api/rate-limits) — back off on 429
- [Authentication](/docs/api/authentication) — scopes and key kinds
- [Webhooks](/docs/api/webhooks) — failures that arrive after the request