Message log and exportsMenu
On this page

Message log and exports

Updated

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). Each row is an OutboundMessage: the same object the message.* 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
Code
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": "[email protected]" },
  "recipientEmail": "[email protected]",
  "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.

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.

Code
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","[email protected]","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