# Troubleshoot your integration

Source: https://kirora.io/docs/api/troubleshooting

Go from a symptom to its first checks and the evidence to collect, using the request id, the request log and the error codes.

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](#cannot-connect) |
| 401 | [401 Unauthorized](#401-unauthorized) |
| 403 | [403 Forbidden](#403-forbidden) |
| 404 for something that exists | [404 for a resource you know exists](#404-for-a-resource-you-know-exists) |
| 202, but the email never arrived | [Accepted, but not received](#accepted-but-not-received) |
| A batch returned 2xx, but some data is missing | [Part of a batch is missing](#part-of-a-batch-is-missing) |
| Duplicate contacts, or 409 from identify | [Duplicates and identity conflicts](#duplicates-and-identity-conflicts) |
| A retry did the work twice, or returned 409 | [Retries](#retries) |
| A push did not arrive | [Push not received](#push-not-received) |
| Webhooks do not arrive, or fail verification | [Webhooks](#webhooks) |
| A campaign or journey does not send | [Campaigns and journeys](#campaigns-and-journeys) |
| An embedded builder cannot send a test | [Embedded builders](#embedded-builders) |
| 413, 415 or 429 | [Size, format and rate](#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 `/v1` credential.
- **Test the key** with `GET /v1/me`, which any valid key may call: it returns the
  workspace and the key.
- **Evidence:** the `X-Request-Id` and 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](https://kirora.io/docs/api/reference/authentication#key-kinds) |
| `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 in `Kirora-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.

1. **Read the message.** Keep the `id` from the send's response and call
   `GET /v1/messages/{id}`, or find it by your reference with
   `GET /v1/messages?correlationId=…`.
2. **Read `status`, and `skip` or `failure` for why.**
   - `queued` or `sending`: not handed over yet. A timed send waits for `sendAt`.
   - `skipped` with `test_email`: the address is a reserved test address
     (`example.com` and the like). It is never delivered.
   - `skipped` with another reason, such as `suppressed` or `recipient_email_missing`:
     a check stopped it. Every reason is in
     [Transactional messages](https://kirora.io/docs/api/transactional#what-happened).
   - `failed`: the provider refused it, or a problem on Kirora's side stopped it
     (`not_sent`, never caused by your side); `failure.reason` says which. It is not
     sent again.
   - `bounced` or `complained`: the address is now suppressed. `bounce.type` and
     `bounce.detail` say why a bounce bounced.
   - `sent` or `delivered`: it left Kirora. `delivered` means the recipient's mail
     server accepted it, which does not show whether it reached the inbox or the spam
     folder.
3. **Check `simulated`.** `true` means nothing was handed to a provider.

- **Evidence:** the message `id`, its `status`, `skip`, `failure` or `bounce`, and the `message.*`
  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 with `error` was 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-Id` of the batch. See [Batches](https://kirora.io/docs/api/batches).

## Duplicates and identity conflicts

- **409 `CONFLICT` from identify:** the email or phone matched a contact that has a
  different `externalId`. Your two systems disagree about who this is.
- **409 `ALREADY_EXISTS` with `details.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 by
  `externalId` and merge the duplicate with `mergeContacts`.
- **Evidence:** the identifiers you sent, `details.field`, and both contacts' ids. See
  [IDs and identity](https://kirora.io/docs/api/identifiers).

## 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: true`** is, 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](https://kirora.io/docs/api/reference/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](https://kirora.io/docs/api/reference/idempotency).

## Push not received

- **First checks:** the contact's `consent.push` is `true` (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}/installations` lists the contact's devices
  with `permission`, `active`, `lastSeenAt` and `invalidatedAt`. A device not seen for
  60 days is treated as gone.
- **The send:** 409 `PUSH_DESTINATION_MISSING` means no active installation; 409
  `PUSH_APP_NOT_READY` means no app can send; a `skipped` message with
  `push_no_eligible_installations` means no device could take it.
- **Each device:** `GET /v1/messages/{id}/deliveries` shows what each device's push
  service returned. `accepted` is not proof the device displayed it; `invalid` means the
  device is gone; `rejected` often 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](https://kirora.io/docs/api/push).

## Webhooks

**Deliveries do not arrive:**

- `GET /v1/webhooks/deliveries` (`listWebhookDeliveries`) lists every attempt with its
  result; `?status=failed` lists 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
  `nextAttemptAt` says when it is tried next.
- Your endpoint must respond with a 2xx within 5 seconds. Redirects are not followed.
- `POST /v1/webhooks/{id}/test` sends a `webhook.test` event; `redeliverWebhookDelivery`
  sends 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 `createWebhook` or 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-Id` and `X-Webhook-Attempt` headers, your endpoint's
  response, and the delivery's entry in `listWebhookDeliveries`. See
  [Webhooks](https://kirora.io/docs/api/webhooks).

## Campaigns and journeys

**A campaign does not send:**

- `GET /v1/campaigns/{id}/eligibility` lists 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
  `sending` until every message is queued, then `sent`. An A/B test reads `testing` once
  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_INCOMPLETE` lists each missing field; 400 `EMPTY_AUDIENCE` means 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 `status` is `active`, not `draft` or `paused`.
- The event name matches the trigger's `name` exactly, including case, and its properties
  meet the trigger's `where` conditions.
- The event was tracked within 72 hours of its `occurredAt`. Older events are recorded
  but start nothing.
- The trigger's `reentry` allows the contact in again (`waitAfterEntry` has passed since
  their last entry started), and `maxEntries` is not reached.
- `simulateJourney` says whether the contact would enter, and why not;
  `GET /v1/contacts/{id}/journeys` lists 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](https://kirora.io/docs/api/campaigns-and-journeys).

## Embedded builders

- **First checks:** the session was created with `testSendTo` naming 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's `allowedOrigins`.
- **503 `EMBED_BUILDER_UNAVAILABLE`** means 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](https://kirora.io/docs/api/limits).
- **415 `UNSUPPORTED_MEDIA_TYPE`:** send `Content-Type: application/json`, or
  `multipart/form-data` to an upload route.
- **429 `RATE_LIMITED`:** wait `Retry-After` seconds, then retry. A batch counts once per
  item. See [Rate limits](https://kirora.io/docs/api/reference/rate-limits).

## Asking for help

Send these, and nothing secret:

- the `X-Request-Id` of the failing request, and of the original if it was a retry
- the time, with its timezone
- the operation (`operationId` or 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.

## Next steps

- [Errors](https://kirora.io/docs/api/reference/errors) — every code and which to retry
- [Message log and exports](https://kirora.io/docs/api/messages) — what happened to each message
- [Webhooks](https://kirora.io/docs/api/webhooks) — delivery, retries and signatures