Start from what you see, then follow the evidence in order: did the request reach Kirora, what did it return, was the work accepted, and what happened to it afterwards. Each step below names the first things to check and where the evidence is.
Find the symptom
| You see | Go to |
|---|---|
| No HTTP response at all | Cannot connect |
| 401 | 401 Unauthorized |
| 403 | 403 Forbidden |
| 404 for something that exists | 404 for a resource you know exists |
| 202, but the email never arrived | Accepted, but not received |
| A batch returned 2xx, but some data is missing | Part of a batch is missing |
| Duplicate contacts, or 409 from identify | Duplicates and identity conflicts |
| A retry did the work twice, or returned 409 | Retries |
| A push did not arrive | Push not received |
| Webhooks do not arrive, or fail verification | Webhooks |
| A campaign or journey does not send | Campaigns and journeys |
| An embedded builder cannot send a test | Embedded builders |
| 413, 415 or 429 | Size, format and rate |
The request id and the request log
Every response, success or error, carries an X-Request-Id header, and every error body
repeats it as error.requestId. Log it with each call. It is the one value that finds
the request on Kirora’s side.
In the dashboard, Developer → Request log lists the workspace’s API requests for the last 31 days, with their status and a short reason for each failure, and finds one by its exact request id. It is open to owners and admins, who also see each key’s own calls under Developer → API keys.
Cannot connect
No status code means the request never reached Kirora, so there is no request id.
- First checks: the base URL is
https://api.kirora.io; your server resolves it; your outbound firewall or proxy allows it; your HTTP client’s TLS settings are the defaults. Do not turn off certificate checks to make an error go away. - Evidence: the time with its timezone, the hostname you called, and the error your HTTP client reported.
401 Unauthorized
The key is missing, malformed, unknown, revoked or expired. Errors are 401
UNAUTHORIZED with a WWW-Authenticate: Bearer header.
- First checks: the header is
Authorization: Bearer kirora_…with nothing else in it; the key has not passed its expiry date (new keys expire after 90 days unless you chose otherwise); it was not rotated without an overlap; you are not sending the web SDK’s client key (kirora_pk_…), which is not a/v1credential. - Test the key with
GET /v1/me, which any valid key may call: it returns the workspace and the key. - Evidence: the
X-Request-Idand the key’s name or id. Never share the key itself.
403 Forbidden
A 403 always carries a code. Read it before changing anything:
code |
Cause | Fix |
|---|---|---|
SCOPE_MISSING |
The key lacks the operation’s scope. details.requiredScope names it |
Create a key that holds the scope |
AGENT_KEY_REFUSED |
An agent key called an operation agent keys may not perform, whatever their scopes |
None needed: the refusal is by design, and a person does it in the dashboard. See Authentication |
FORBIDDEN |
This caller may not do this: a key that is not a partner key sent Kirora-Workspace, a partner key was used from a browser, or an embed session called outside its routes |
Call from a server with the right key, or drop the header |
API_KEY_IP_NOT_ALLOWED |
The request came from outside the key’s allowedIps |
Add the address, or call from an allowed server |
PLAN_FEATURE_UNAVAILABLE |
The plan does not include the feature, such as goals on Free. details.entitlement names it |
Change plan |
PLAN_LIMIT_REACHED |
A plan limit is used up. details.entitlement, limit and used say which; for events_per_month, the event is not stored and is listed in the event log |
Top up the prepaid balance, change plan, or wait for a monthly or daily limit to reset |
WORKSPACE_SUSPENDED, WORKSPACE_DELETION_PENDING |
The workspace is suspended, or being deleted | Ask its owner, or the partner that manages it, to resume it or cancel the deletion |
EMBED_ORIGIN_NOT_ALLOWED |
An embed session was used from a page the partner key does not allow | Add the page’s origin to the key’s allowedOrigins |
A sending domain that is not ready is a 409, not a 403: SENDING_DOMAIN_UNVERIFIED (the
from address is not on a verified domain: verify it, or send from one that is) and
SENDING_DOMAIN_DMARC_MISSING (marketing email needs a DMARC record on the domain, p=none at least).
404 for a resource you know exists
A resource in another workspace returns 404, never 403.
- First checks: the key belongs to the workspace that holds the resource (a staging
key cannot read live data); a contact path uses Kirora’s id, or your own with
?idType=externalId; a partner request names the right child inKirora-Workspace. - For a tracked event: the contact must already exist. Identify it first.
Accepted, but not received
A 202 means Kirora accepted and stored the message. It does not mean the message was sent or delivered.
- Read the message. Keep the
idfrom the send’s response and callGET /v1/messages/{id}, or find it by your reference withGET /v1/messages?correlationId=…. - Read
status, andskiporfailurefor why.queuedorsending: not handed over yet. A timed send waits forsendAt.skippedwithtest_email: the address is a reserved test address (example.comand the like). It is never delivered.skippedwith another reason, such assuppressedorrecipient_email_missing: a check stopped it. Every reason is in Transactional messages.failed: the provider refused it, or a problem on Kirora’s side stopped it (not_sent, never caused by your side);failure.reasonsays which. It is not sent again.bouncedorcomplained: the address is now suppressed.bounce.typeandbounce.detailsay why a bounce bounced.sentordelivered: it left Kirora.deliveredmeans the recipient’s mail server accepted it, which does not show whether it reached the inbox or the spam folder.
- Check
simulated.truemeans nothing was handed to a provider.
- Evidence: the message
id, itsstatus,skip,failureorbounce, and themessage.*webhook deliveries for it.
Part of a batch is missing
A batch returns 2xx when it was accepted as a whole. Each item has its own result.
- First checks: read every entry of
results; an entry witherrorwas refused, with the same code a single call would have returned. The entry’s position is the item’s position in your request. - Evidence: the failed indexes and codes, your own ids for those items, and the
X-Request-Idof the batch. See Batches.
Duplicates and identity conflicts
- 409
CONFLICTfrom identify: the email or phone matched a contact that has a differentexternalId. Your two systems disagree about who this is. - 409
ALREADY_EXISTSwithdetails.field: the value you are setting belongs to another contact. - Duplicates: usually a contact identified by a new email address without its
externalId, which finds nobody and creates a second contact. Identify byexternalIdand merge the duplicate withmergeContacts. - Evidence: the identifiers you sent,
details.field, and both contacts’ ids. See IDs and identity.
Retries
- The retry did the work twice: the retry carried no
Idempotency-Key, or a new one. Generate the key once, before the first attempt, and reuse it for every retry. - 409
IDEMPOTENCY_KEY_REUSED: the same key arrived with a different body, path or query. Repeat the original request exactly, or use a new key for a different action. - 409
IDEMPOTENCY_KEY_IN_USE: the first request with the key is still running. Wait a moment and retry with the same key. - 409
IDEMPOTENCY_KEY_OUTCOME_UNKNOWN: the first request with the key created or sent something, and failed or never returned, so it may have happened. Check (list the resources, or the messages), then retry with a new key if it did not. - A response with
Idempotency-Replay: trueis, for most writes, the stored first response, not a fresh read: read the resource for its state now. A transactional replay returns the message as it stands now. See Idempotency for each operation. - Evidence: the request ids of the original and of the retry, and the key with most of its characters hidden. See Idempotency.
Push not received
- First checks: the contact’s
consent.pushistrue(the device’s permission alone does not set it); the push project has a ready app; the contact has an active installation. - Installations:
GET /v1/contacts/{id}/installationslists the contact’s devices withpermission,active,lastSeenAtandinvalidatedAt. A device not seen for 60 days is treated as gone. - The send: 409
PUSH_DESTINATION_MISSINGmeans no active installation; 409PUSH_APP_NOT_READYmeans no app can send; askippedmessage withpush_no_eligible_installationsmeans no device could take it. - Each device:
GET /v1/messages/{id}/deliveriesshows what each device’s push service returned.acceptedis not proof the device displayed it;invalidmeans the device is gone;rejectedoften means the app’s key or the payload was refused. - Evidence: the message id, the installation’s
externalInstallationId, and the delivery results. See Push notifications.
Webhooks
Deliveries do not arrive:
GET /v1/webhooks/deliveries(listWebhookDeliveries) lists every attempt with its result;?status=failedlists only what failed.- A paused or disabled webhook receives no new events. A webhook whose last retry fails, with nothing else accepted meanwhile, is disabled, and a 410 from your endpoint disables it at once.
- While an endpoint keeps failing, deliveries are held; each pending delivery’s
nextAttemptAtsays when it is tried next. - Your endpoint must respond with a 2xx within 5 seconds. Redirects are not followed.
POST /v1/webhooks/{id}/testsends awebhook.testevent;redeliverWebhookDeliverysends a past delivery again.
Signatures do not verify:
-
Compute the HMAC over the raw body exactly as received, not over parsed and re-serialised JSON.
-
Use the secret from the latest
createWebhookor rotation response. After a rotation, the header carries a signature per active secret: accept any that verifies. -
Sign
${X-Webhook-Timestamp}.${raw body}, and allow for your clock: refuse only timestamps more than five minutes away. -
Evidence: the
X-Webhook-Event-IdandX-Webhook-Attemptheaders, your endpoint’s response, and the delivery’s entry inlistWebhookDeliveries. See Webhooks.
Campaigns and journeys
A campaign does not send:
GET /v1/campaigns/{id}/eligibilitylists every blocker and who is left out, without sending.- A large audience or an A/B test is queued in the background: the campaign reads
sendinguntil every message is queued, thensent. An A/B test readstestingonce its versions are out, until a winner is chosen. - 409
AGENT_CONFIRMATION_REQUIRED: an agent wrote or edited it, and a person must confirm it in the dashboard. - 409
DRAFT_INCOMPLETElists each missing field; 400EMPTY_AUDIENCEmeans the audience has nobody in it.
A journey does not activate: GET /v1/journeys/{id}/activation-check lists every
problem activation would refuse, with the field to fix and the error code, without
activating.
A journey does not start for a contact:
-
The journey’s
statusisactive, notdraftorpaused. -
The event name matches the trigger’s
nameexactly, including case, and its properties meet the trigger’swhereconditions. -
The event was tracked within 72 hours of its
occurredAt. Older events are recorded but start nothing. -
The trigger’s
reentryallows the contact in again (waitAfterEntryhas passed since their last entry started), andmaxEntriesis not reached. -
simulateJourneysays whether the contact would enter, and why not;GET /v1/contacts/{id}/journeyslists their enrollments. -
Evidence: the campaign’s or journey’s id and
status, the eligibility or simulation result, and the contact’s id. See Campaigns and journeys.
Embedded builders
- First checks: the session was created with
testSendTonaming the address (up to 5); without any, the builder hides test sending. The session is less than 15 minutes old: an expired one returns 401, and your server should mint a new one. The page’s origin is in the partner key’sallowedOrigins. - 503
EMBED_BUILDER_UNAVAILABLEmeans the hosted builders are not available; retrying will not help. - Do not widen a session’s authority to work around a refusal. Give it the address it
needs in
testSendTo.
Size, format and rate
- 413
PAYLOAD_TOO_LARGE: the body or file is over the route’s limit. See Limits. - 415
UNSUPPORTED_MEDIA_TYPE: sendContent-Type: application/json, ormultipart/form-datato an upload route. - 429
RATE_LIMITED: waitRetry-Afterseconds, then retry. A batch counts once per item. See Rate limits.
Asking for help
Send these, and nothing secret:
- the
X-Request-Idof the failing request, and of the original if it was a retry - the time, with its timezone
- the operation (
operationIdor method and path) - your library and runtime versions, if you use one
- the steps that reproduce it, and a minimal request with personal data removed
Never send an API key, a signing secret or a user token.