# Message log and exports

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

Read the message log with its filters and statuses, and export it as a CSV file.

Every message Kirora sends, or decides not to send, is a row in the workspace's message
log: one row per contact per channel, from campaigns, journeys and transactional sends
alike. This page covers reading the log, what each status means, and taking it as a
file. Every call here needs the `messages:read` scope.

## Reading the log

`GET /v1/messages` (`listMessages`) lists the log newest first, a page at a time (see
[Pagination](https://kirora.io/docs/api/reference/pagination)). Each row is an `OutboundMessage`: the same object the
`message.*` [webhooks](https://kirora.io/docs/api/webhooks) carry. It never includes the message body.

| Filter | Returns |
| --- | --- |
| `status` | Messages in one status (below) |
| `channel` | `email` or `push` |
| `messageType` | `marketing` or `transactional` |
| `campaignId`, `journeyId` | Messages from one campaign or one journey |
| `stepId` | With `journeyId`, messages from the journey's step with this id, in every version of its steps |
| `templateId` | Transactional sends from one stored template. Campaign and journey messages record no template |
| `correlationId` | Transactional sends that carried this reference of yours, such as an order number |
| `opened`, `clicked` | `true` or `false`: opened (a click counts) or clicked at least once, or never |
| `recipient` | The exact address a message went to, and email messages not yet given one whose contact's current address is this one |
| `query` | Part of the recipient's address or name, matched against the contact, up to 100 characters |
| `createdAfter`, `createdBefore` | Messages accepted at or after, and before, these times |

```
GET /v1/messages?campaignId=0192f3d3-5e6f-7071-8c83-9e0f1a2b3c4d&status=bounced&limit=100
```

`GET /v1/messages/{id}` (`getMessage`) returns one row, whatever its source. Narrower
reads return the same rows:

| Operation | Returns |
| --- | --- |
| `listContactMessages` | One contact's messages, by Kirora's id or, with `idType=externalId`, yours; `createdAfter` and `createdBefore` bound them, as on the log |
| `getMessageContent` | What a transactional send contained, while it is kept. A campaign or journey message answers 404 `CONTENT_NOT_RETAINED`: its content is the campaign's, or the journey version's |
| `listMessageDeliveries` | For any push message, from a campaign, a journey or a transactional send: what each of the contact's devices returned |

A campaign's messages are `GET /v1/messages?campaignId=…`, and a transactional send's
are `GET /v1/messages?correlationId=…`: one log, with every filter.

Lists return no total. To count outcomes, use a campaign's stats or the reports.

### Fields to read

```json
{
  "id": "0192f3c0-…",
  "messageType": "marketing",
  "channel": "email",
  "status": "bounced",
  "campaign": null,
  "journey": { "id": "0192f3a4-…", "name": "Post-purchase thank you", "enrollmentId": "0192f3b0-…", "stepId": "thank-you-email" },
  "template": null,
  "contact": { "id": "0192f3a1-…", "externalId": "customer-881", "email": "ada@example.com" },
  "recipientEmail": "ada@example.com",
  "simulated": false,
  "correlationId": null,
  "sendAt": null,
  "sentAt": "2026-10-24T09:30:58.000Z",
  "deliveredAt": null,
  "openedAt": null,
  "openedByMachine": false,
  "clickedAt": null,
  "clickedByMachine": false,
  "complainedAt": null,
  "bounce": { "occurredAt": "2026-10-24T09:31:00.000Z", "type": "mailbox_not_found", "detail": "smtp; 550 5.1.1 user unknown" },
  "failure": null,
  "skip": null,
  "createdAt": "2026-10-24T09:30:57.000Z"
}
```

| Field | Holds |
| --- | --- |
| `status` | Where the message is now (below) |
| `campaign`, `journey` | Where it came from: `{ id, name }` for a campaign, `{ id, name, enrollmentId, stepId }` for a journey; `name` is the current one. Both are `null` for a transactional send |
| `contact` | Who it was for: `{ id, externalId, email }`, the contact as it is now |
| `recipientEmail` | The address it went to, fixed at the first attempt; `contact.email` may since differ |
| `correlationId` | The reference you sent with a transactional message |
| `simulated` | `true` for a reserved test address: nothing was handed to a provider |
| `sentAt`, `deliveredAt`, `openedAt`, `clickedAt`, `complainedAt` | When each of those happened |
| `bounce` | Set on a `bounced` message: `occurredAt`, `type` (`mailbox_not_found`, `mailbox_full`, `suppressed`, or `general` for any other reason) and `detail`, the receiving server's reply |
| `failure` | Set on a `failed` message: `occurredAt` and `reason`, such as `provider_rejected` or `transient_bounce` |
| `skip` | Set on a `skipped` message: `occurredAt`, `reason` (such as `suppressed`, `consent_missing`, `test_email` or `cancelled`) and, with `suppressed`, `suppressionReason`: why the address is blocked (`unsubscribed`, `bounced`, `complained`, `complained_transactional`, `manual` or `imported`) |
| `openedByMachine`, `clickedByMachine` | The open or click came from software, not a person |

Each of `bounce`, `failure` and `skip` is `null` unless `status` is its outcome, so a
complaint or a cancel never reads as a send failure. `contact` becomes `null` once the
contact is deleted or the message passes the workspace's retention period: the row stays,
but no longer names anyone, and it no longer matches a `recipient` filter.

## Statuses

| `status` | Meaning |
| --- | --- |
| `queued` | Accepted and stored. Nothing is sent yet, or it waits for `sendAt` |
| `sending` | Being handed to the email provider or push service |
| `sent` | Handed over. For push, the push service accepted it, which does not show the device displayed it |
| `delivered` | Email only. The recipient's mail server accepted it, which does not show whether it reached the inbox |
| `bounced` | Email only. A permanent bounce; the address is now suppressed |
| `complained` | Email only. The recipient marked it as spam |
| `failed` | The provider refused it, the email soft-bounced, or a problem on Kirora's side stopped it (`not_sent`); nothing in your content, audience or setup caused that, and Kirora is alerted. In rare cases the provider may have delivered it before the problem. It is not sent again |
| `skipped` | Not sent: a check stopped it or it was cancelled. `skip.reason` says why |

Opens and clicks do not change `status`: they set `openedAt` and `clickedAt`. Every
`skip.reason` and `failure.reason` is listed in
[Transactional messages](https://kirora.io/docs/api/transactional#what-happened).

To follow outcomes as they happen, subscribe to the `message.*` webhooks rather than
polling the log.

## Exporting

To take the whole send log, or a large part of it, ask for a file instead of paging
`GET /v1/messages`. The export takes the same filters as the list and applies them the
same way, including `query`, part of the recipient's address or name, matched against the
contact, `campaignId`, `journeyId` or `templateId` for one source (`templateId`
matches transactional sends only: campaign and journey messages record no template), and
`correlationId`, so a file of receipts joins to your orders. `opened` and `clicked` are
the list's only.

```
POST /v1/messages/exports            { "status": "failed", "createdAfter": "2026-10-01T00:00:00Z" }
GET  /v1/messages/exports/{id}       → status: queued, generating, then ready, failed or expired
GET  /v1/messages/exports/{id}/download
```

Send `{}` to export every message. A filter that matches no message is refused with
400 `NOTHING_TO_EXPORT`. The file is built in the background, so poll the export until
`status` is `ready`. Downloading earlier returns 409 `EXPORT_UNAVAILABLE` ("not ready
yet"). A failed export returns the same 409 with `details.status: "failed"` and the
message "The export failed; create a new one".

The file is CSV, one row per message, newest first, with the message's fields as columns
and its objects flattened, every cell quoted and `""` where a message has no value:

```csv
id,messageType,channel,status,campaignId,campaignName,journeyId,journeyName,journeyEnrollmentId,stepId,templateId,templateVersion,copyKind,copyOf,contactId,recipientEmail,simulated,correlationId,sendAt,sentAt,deliveredAt,openedAt,clickedAt,complainedAt,bounceOccurredAt,bounceType,bounceDetail,failureOccurredAt,failureReason,skipOccurredAt,skipReason,createdAt
"0192f3b0-4d5e-7f60-8b72-8d9e0f1a2b3c","transactional","email","bounced","","","","","","","0192f3f2-3c4d-7e5f-8061-7c8d9e0f1a2b","3","","","0192f3a1-7c2e-7d4b-9a10-3e5f6a7b8c9d","amaka@example.com","false","order-1001","","2026-10-01T09:31:00.000Z","","","","","2026-10-01T09:31:05.000Z","mailbox_not_found","smtp; 550 5.1.1 user unknown","","","","","2026-10-01T09:30:58.000Z"
```

`simulated` lets you leave test sends out, and `correlationId` joins a row to your own
records. The file is kept for 7 days, given in `expiresAt`. After that the export reads `expired` and the
download returns 410 `EXPORT_UNAVAILABLE`. An export too large for one file fails with
`failureCode` `EXPORT_TOO_LARGE`; narrow the date range and ask again.

Any key of the workspace with `messages:read` can read an export, not only the key that
asked for it. The file holds recipient addresses, so grant the scope with care.

## Next steps

- [Webhooks](https://kirora.io/docs/api/webhooks) — receive each outcome as it happens
- [Transactional messages](https://kirora.io/docs/api/transactional#what-happened) — every `skip.reason` and `failure.reason`
- [Troubleshoot your integration](https://kirora.io/docs/api/troubleshooting) — when a message did not arrive