Every error — validation, authentication, conflict, server fault — comes back in one shape, with the HTTP status set appropriately:
{
"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:
errorslists each field the request got wrong, onVALIDATION_ERRORandDRAFT_INCOMPLETE.detailscarries what else a code reports, such as the limit aPLAN_LIMIT_REACHEDhit or the field anALREADY_EXISTSnames. 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:
{
"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, and
IDEMPOTENCY_KEY_REUSED in 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).
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.
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 |
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. 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.) 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 before building retry logic into a write path.
Mapping errors in an SDK
Model code on the catalogue above. Then:
- Expose
codeas a typed value, not a bare string. - Keep the unparsed
detailsreachable — clients need the field errors inside aVALIDATION_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.