# Errors

Source: https://kirora.io/docs/api/reference/errors

Read the error envelope, look up every public error code, and learn which errors 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",
    "requestId": "0192f1c4-…"
  }
}
```

`requestId` is also the `X-Request-Id` header, on every response, not only errors. Quote it
when you contact support: it identifies the request in Kirora's 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. Two members are optional:

- `errors` lists each field the request got wrong, on `VALIDATION_ERROR` and
  `DRAFT_INCOMPLETE`.
- `details` carries what else a code reports, such as the limit a `PLAN_LIMIT_REACHED`
  hit or the field an `ALREADY_EXISTS` names. Its shape depends on the code, as the
  catalogue below says.

A `VALIDATION_ERROR` lists the fields it found wrong the same way, whether the request's
schema or a later rule found them. A refusal about the request as a whole, rather than a
field (an expired form token, an address whose domain takes no mail), has only its
`message`:

```json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Some request values are invalid. Check the field errors.",
    "errors": [
      { "in": "body", "field": "subject", "code": "too_big", "message": "Use at most 998 characters.", "maximum": 998 }
    ],
    "requestId": "0192f1c4-…"
  }
}
```

`in` is `body`, `query`, `params` or `headers` when known; `field` is dotted (`blocks.0.text`)
and empty for the part as a whole; `code` is the schema rule that failed, or `custom` for a
rule checked after the schema (a consent statement missing, an expiry in the past). Request
bodies are strict: a field the operation does not take is refused, not ignored.

## 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
the dashboard, the AI console and 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](https://kirora.io/docs/api/reference/authentication), and
`IDEMPOTENCY_KEY_REUSED` in [Idempotency](https://kirora.io/docs/api/reference/idempotency).


| Code | Status | Meaning |
| --- | --- | --- |
| `BAD_REQUEST` | 400 | A malformed request that is not a schema violation, such as unreadable JSON. |
| `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 list's or segment's `name`, a sending domain's `domain`, a template's `alias`, a topic's `name`, a webhook's `url`, a conversion goal's `eventName`, a partner workspace's `externalId`, or a form's `slug` (unique across every workspace). |
| `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 list or segment an unfinished campaign targets, a list, segment, form, topic or webhook endpoint a live or paused journey names, a segment another segment reads, a push project something scheduled or live sends to, a topic a campaign sends on or rules name, a placed shared block, an image an email loads, a sending domain email still sends from, an event name a journey, segment, campaign or goal reads, a tag such a rule names, a conversion goal an A/B test not yet decided or a live or paused journey's exit decides by. `details`, where present, name what uses it, in the shape of that resource's usage: `ListUsage`, `SegmentUsage`, `SharedBlockUsage`, `AssetUsage`, `SendingDomainUsage`; a journey's form or webhook endpoint gives `details.journeys`, a topic `details.campaigns`, `details.journeys` and `details.segments` (up to 50 each, with `details.hasMore`), an event name, tag or conversion goal `details.usedBy`. |
| `FORBIDDEN` | 403 | Valid key, but this caller may not do this: a non-partner key sending `Kirora-Workspace`, a partner key used from a browser, an embed session off its routes, or a role or masked attribute that does not allow it. |
| `SCOPE_MISSING` | 403 | The key lacks the operation's scope. `details.requiredScope` names it; issue a key that holds it. |
| `AGENT_KEY_REFUSED` | 403 | An agent key may not do this whatever its scopes: send, activate, publish, change contacts or consent, or change something live. The system working, not a misconfiguration: a person does it in the dashboard. |
| `INTERNAL_ERROR` | 500 | Unexpected fault. Whether a retry is safe depends on the operation: see its retry note (`x-retry`). |
| `NOT_FOUND` | 404 | No such resource in this workspace. Another workspace's resource returns the same. |
| `METHOD_NOT_ALLOWED` | 405 | The path exists, but not with this method. The `Allow` header lists the methods it takes. |
| `PAYLOAD_TOO_LARGE` | 413 | The body, or an uploaded file, is larger than this route takes. `message` states the limit when the route sets its own. |
| `UNSUPPORTED_MEDIA_TYPE` | 415 | The `Content-Type` is not one this route reads: send `application/json`, or `multipart/form-data` to an upload route. |
| `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, path or a header failed validation. `errors` lists each problem: `in` (`body`, `query`, `params`, `headers`, when known), `field` (dotted, empty for the whole part), `code` (when a schema rule failed), `message` (plain words, meant to be shown beside the field), and `minimum` or `maximum` on a `too_small` or `too_big` rule. |
| `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_BUILDER_UNAVAILABLE` | 503 | This deployment does not serve the hosted builders yet, so no embed session can start. Retrying will not help. |
| `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 | A message to this contact is being handed to a provider; retry in a few seconds. |
| `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. A new account's daily email ramp returns the same way with `upgradeTo` null and `details.increasesAt`, when the ramp next raises the limit: no plan lifts it sooner. |
| `SENDING_REVIEW_REQUIRED` | 403 | The sender's domain is waiting for a person at Kirora, who reviews each new sending domain before it can send, against the workspace's sending profile. Until approved it has no provider identity, so nothing sends from it, not even a test. `details.domain` names it and `details.status` says where its review stands: `not_requested` (the workspace's sending profile is not filled in yet), `in_review` (usually answered within a day) or `declined` (the domain's `review.declineReason` says why). A subdomain of an approved domain needs no review. |
| `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. |
| `DRAFT_INCOMPLETE` | 409 | The draft is not finished; `errors` names each missing or invalid field. |
| `INVALID_SCHEDULE` | 400 | A fixed `sendAt` is not in the future, or a local `localDateTime` has already passed in every timezone. |
| `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` | 409 | The contact has not consented on that channel. |
| `SUPPRESSED` | 409 | The address is suppressed on this channel, for any reason: a bounce, a spam complaint, an unsubscribe, an import or a block added by hand. A transactional send is refused only for a bounce or a complaint about a transactional message. `listSuppressions` shows the block and its `reason`. |
| `TOPIC_UNSUBSCRIBED` | 409 | The contact has unsubscribed from the campaign's or journey step's topic on that channel. |
| `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. |
| `TEST_SEND_OUTCOME_UNKNOWN` | 503 | The provider may or may not have accepted the test; check the inbox before retrying. |
| `TEST_SEND_ROUTE_NOT_READY` | 409 | The workspace's sending route is still being set up; retry the test in a few minutes. |
| `TEST_SEND_UNAVAILABLE` | 503 | The test could not be handed to the provider; retry shortly. |
| `SENDING_DOMAIN_UNVERIFIED` | 409 | 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 request. |
| `IDEMPOTENCY_KEY_IN_USE` | 409 | A request with this `Idempotency-Key` is still running; retry after a short wait to get its response. |
| `IDEMPOTENCY_KEY_OUTCOME_UNKNOWN` | 409 | The first request with this `Idempotency-Key` failed or never returned, and may have taken effect (operations whose retry kind is `unsafe`): check, then retry with a new key. |
| `CONTENT_NOT_RETAINED` | 404 | The message exists, but what it sent was removed once sending finished, or has expired. |
| `RECIPIENT_EMAIL_MISSING` | 409 | The contact has no email address. |
| `RECIPIENT_EMAIL_UNSUPPORTED` | 409 | The contact's email address has non-ASCII characters before the @, which Kirora cannot deliver to yet. |
| `TEMPLATE_NOT_PUBLISHED` | 409 | `email.template` 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. |
| `FORM_CLOSED` | 409 | The form is closed: closed by hand, past its closing time, or at its submission limit. The message is the form's closed message. |


## Common problems

**`VALIDATION_ERROR`** — the body, query or path failed validation (HTTP 400).
**Solution:** read `errors`, fix those fields, and send again. A misspelled or
unsupported field is one of them.

**`SCOPE_MISSING`** — the key lacks a scope the route needs (HTTP 403;
`details.requiredScope` names it). **Solution:** create a key with that scope, where your
key kind allows it.

**`AGENT_KEY_REFUSED`** — an agent key asked for something agents may not do, whatever
its scopes, such as sending (HTTP 403). **Solution:** none in code; a person does it in
the dashboard (see [Authentication](https://kirora.io/docs/api/reference/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`** — `email.template` 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](https://kirora.io/docs/api/reference/rate-limits).

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

## What to retry

| Retry action | When it applies |
| --- | --- |
| **Retry with backoff** | `RATE_LIMITED` (honour `Retry-After`) and `SERVICE_UNAVAILABLE`. `INTERNAL_ERROR` and transport-level failures may have done the work: retry them only as the operation's retry note (`x-retry`) allows |
| **Retry once the state changes** | `CONTACT_SEND_IN_PROGRESS` (in a few seconds), `EXPORT_UNAVAILABLE` with 409 (poll the export until `status` is `ready`), `TEMPLATE_NOT_PUBLISHED` (after you publish the template) and `TEST_SEND_ROUTE_NOT_READY` (the sending route is still being set up; try again in a few minutes) |
| **Read, then send again** | `REVISION_CONFLICT`: read the current `revision`, merge your change and send it with that `expectedRevision`. The request changes, so it is not a retry of the same one |
| **Never retry unchanged** | Every other 4xx, including 405, 413 and 415. The request is wrong, and repeating it will fail identically. A 404 or 409 that comes back from your retry of a delete, cancel or create may mean the first attempt worked: see [What is safe to retry](https://kirora.io/docs/api/reference/idempotency#what-is-safe-to-retry) |

A 405 (`METHOD_NOT_ALLOWED`) lists the methods the path takes in its `Allow` header. A 413
(`PAYLOAD_TOO_LARGE`) means the body is over the route's limit; see
[Request size](https://kirora.io/docs/api/reference/rate-limits#request-size). A 415 (`UNSUPPORTED_MEDIA_TYPE`) means the
`Content-Type` is wrong: send `application/json` (`multipart/form-data` for an upload).

A request rejected before it runs, such as a validation, missing-scope or rate-limit
error, reserves no `Idempotency-Key`. A refusal returned once the operation has started
can be stored and replayed for that key, so after fixing its cause, send it with a new
key, following the operation's retry note. (A send refused for its recipient may still
have created the contact; see [Idempotency](https://kirora.io/docs/api/reference/idempotency#when-a-request-fails).) A 5xx or a timeout may have
done the work: retry only where the operation is safe to repeat. Each operation's retry
note (`x-retry`) says whether it is. Read
[Idempotency](https://kirora.io/docs/api/reference/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](https://kirora.io/docs/api/reference/idempotency) — retry writes without repeating them
- [Rate limits](https://kirora.io/docs/api/reference/rate-limits) — back off on 429
- [Authentication](https://kirora.io/docs/api/reference/authentication) — scopes and key kinds
- [Webhooks](https://kirora.io/docs/api/webhooks) — failures that arrive after the request