# Objects and rules

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

Build segment rules, journey definitions and template documents, and read the webhook envelope, from minimal and complete examples that pass validation.

Four documents in the API are trees rather than flat records: segment rules, journey
definitions, template documents and the webhook envelope. Two objects carry rules you
need before you rely on them: a schedule and a message. The API reference lists their
fields. This page explains how the pieces fit together, with a minimal and a complete
example of each. Every example here passes the API's validation as written; ids are
placeholders for your own.

## Segment rules

Segment rules describe an audience. They are evaluated when they are used, so the
audience changes as contacts change. The same document is a segment's `rules`, a
campaign's `audienceRules`, the `rules` of a journey's `filter` and `split` steps, and a
template block's `visibleIf`.

A send evaluates a segment's rules live. A saved segment is also evaluated in the
background about every 15 minutes into a stored membership, which `memberCount`,
`GET /v1/contacts?segmentId=`, `listContactSegments` and the `contact.entered_segment`
and `contact.exited_segment` events read: those can be up to about 15 minutes behind.
`listSegmentContacts` evaluates the rules now.

### Grammar

- The top level is a group: `{ "all": [ … ] }` (every item must match) or
  `{ "any": [ … ] }` (at least one must). A group holds 1 to 20 items.
- An item is a rule, or a nested group. A nested group holds rules only, so two levels
  express "A and (B or C)".
- A rule is chosen by its `type`:

| `type` | Matches | Main fields |
| --- | --- | --- |
| `contact` | A field of the contact | `field` (such as `email`, `country`, `consent.email`, `createdAt`, `totalSpent`), `operator`, `value` |
| `attribute` | One of your custom attributes | `field` as `attributes.<key>`, `operator`, `value` |
| `event` | Did an event, how often, with which properties | `name`, `withinDays`, `count`, `where` (up to 5), `notFollowedBy` |
| `activity` | What the contact did with your messages | `activity` (`sent`, `notSent`, `opened`, `clicked`, `notOpened`, `notClicked`, `bounced`), `channel`, `campaignId`, `tagId`, `link`, `withinDays`, `count` |
| `installation` | Whether push can reach the contact | `has` (`active` or `none`), `pushProjectId`, `platform` |
| `list` | Membership of one list, or several | `listIds` (1 to 50), `member` |
| `segment` | Membership of another segment | `segmentId`, `member` |
| `topic` | Whether a topic reaches the contact | `topicId`, `channel` (`email` or `push`, optional), `subscribed` |

`operator` for `contact` and `attribute` rules is one of `equals`, `notEquals`,
`contains`, `notContains`, `startsWith`, `in` (up to 100 values), `exists`, `notExists`,
`gt`, `gte`, `lt`, `lte`, `before`, `after` (an ISO date), `withinLastDays`,
`moreThanDaysAgo`, `inNextDays` (up to 3,650 days), `anniversaryInNextDays` (0 to 365)
and `anniversaryThisMonth`. An `event` rule's `where` takes `equals`, `notEquals`, `gt`,
`gte`, `lt` and `lte`, and the date operators `before`, `after`, `withinLastDays`,
`moreThanDaysAgo` and `inNextDays`, which read a property such as an appointment's
`startTime` as a date relative to today, so "an appointment in the next 7 days" stays
true as time passes; `count` counts only the events that meet every `where`. They read
the property's own date, while `withinDays` limits when the event happened. Membership
moves with the calendar: a contact enters when the date comes into range, with no new
event, and a journey triggered by entering the segment then starts for them. `value` takes the type its operator needs, and none for
`exists`, `notExists` and `anniversaryThisMonth`. Each contact field accepts only the
operators its type supports, and is refused with any other:

| Contact field | Operators |
| --- | --- |
| `email`, `firstName`, `lastName`, `phone`, `timezone`, `externalId` | `equals`, `notEquals`, `contains`, `startsWith`, `exists`, `notExists`, `in` |
| `consent.email`, `consent.push` | `equals`, with `"true"` or `"false"` |
| `country`, `rfmGroup` | `equals`, `notEquals`, `in`, `exists`, `notExists` |
| `rfmRecency`, `rfmFrequency`, `rfmMonetary` | `equals`, `notEquals`, `in`, `exists`, `notExists`, `gt`, `gte`, `lt`, `lte`, with a whole number from 1 to 5 |
| `locale` | `equals`, `notEquals`, `startsWith`, `in`, `exists`, `notExists` |
| `purchaseCount`, `totalSpent` | `equals`, `gt`, `gte`, `lt`, `lte`, with a number |
| `lastPurchasedAt` | `before`, `after`, `withinLastDays`, `moreThanDaysAgo`, `exists`, `notExists` |
| `createdAt` | `before`, `after`, `withinLastDays`, `moreThanDaysAgo` |

Every compared `value` is text, as Customer.io takes it: a number, a day count or a
boolean is written out (`"3"`, `"30"`, `"true"`), and `in` takes `values`, a list of them.
Equality compares the stored value's text, so a stored `5` matches `"5"`; `gt`, `gte`,
`lt` and `lte` read it as a number. Text comparisons ignore case and accents.

`rfmGroup` and the three scores come from a daily pass over the workspace's purchase
event. A score of 5 is the most recent, most frequent or highest spending. A contact who
never purchased has no group or scores, so on those fields matches only `notExists` and
`notEquals`: `rfmRecency` `notEquals` 5 includes them, and an `exists` rule on the same
field leaves them out.

An `event` rule with `count: { "operator": "equals", "value": 0 }` means "has not done".
An `activity` rule may also take `journeyStepId`, the id of a step of the journey it is
evaluated in, which only matches inside a journey.

A `list` rule must name lists the workspace has when the rules are saved; an unknown id is
refused with 400 `VALIDATION_ERROR` at `rules`. A list a segment names cannot be deleted
while the segment names it. If a list is missing anyway, the rule matches nobody, with
`member: false` too, so a send never widens by mistake.

A `topic` rule matches the contacts a topic reaches (`subscribed: true`) or does not
(`false`), as a send on it reads it: an `opt_out` topic reaches everyone who has not left
it, an `opt_in` topic only those who joined. With `channel`, the contact's choice for that
channel wins, so it matches exactly who a campaign on the topic reaches there; without it,
it reads the topic-wide choice, which a contact's `subscribed` shows. Like a list, the topic must
exist when the rules are saved, cannot be deleted while a segment names it, and if it is
missing anyway the rule matches nobody either way.

```json
{ "type": "topic", "topicId": "0192f0c8-0000-7000-8000-000000000001", "channel": "email", "subscribed": false }
```

A `segment` rule reads that segment's rules as they are when evaluated. Segments may
refer to segments up to 3 levels deep, never to themselves, and the whole tree may come
to at most 1,000 rules once every reference is written out. Each fault is refused with
400 `VALIDATION_ERROR` at `rules`.

### Minimal

Contacts who have consented to email:

```json
{ "all": [{ "type": "contact", "field": "consent.email", "operator": "equals", "value": "true" }] }
```

### Complete: repeat buyers

Contacts in the UK or Ireland who completed two or more orders over 50 in the last 90
days, who are on the `pro` plan or clicked an email in the last 30 days, and who are not
in a given list:

```json
{
  "all": [
    { "type": "contact", "field": "country", "operator": "in", "values": ["GB", "IE"] },
    {
      "type": "event",
      "name": "Order Completed",
      "withinDays": 90,
      "count": { "operator": "gte", "value": 2 },
      "where": [{ "property": "total", "operator": "gt", "value": "50" }]
    },
    {
      "any": [
        { "type": "attribute", "field": "attributes.plan", "operator": "equals", "value": "premium" },
        { "type": "activity", "activity": "clicked", "channel": "email", "withinDays": 30 }
      ]
    },
    { "type": "list", "listIds": ["0192f3a2-1b3c-7e5d-8a21-4f6a7b8c9d0e"], "member": false }
  ]
}
```

### Complete: lapsed customers you can reach

Contacts reachable by web push who last purchased more than 60 days ago, or contacts with
email consent and no order in 60 days, or members of another segment:

```json
{
  "any": [
    {
      "all": [
        { "type": "installation", "has": "active", "platform": "web" },
        { "type": "contact", "field": "lastPurchasedAt", "operator": "moreThanDaysAgo", "value": "60" }
      ]
    },
    {
      "all": [
        { "type": "contact", "field": "consent.email", "operator": "equals", "value": "true" },
        {
          "type": "event",
          "name": "Order Completed",
          "withinDays": 60,
          "count": { "operator": "equals", "value": 0 }
        }
      ]
    },
    { "type": "segment", "segmentId": "0192f3c1-2d3e-7f40-8a51-6b7c8d9e0f1a", "member": true }
  ]
}
```

### Operations

`createSegment`, `updateSegment` (`rules` replaces the whole tree), `previewSegment` to
count a tree before saving it, `getSegment` for a saved segment's `memberCount`,
`listSegmentContacts`, and `createCampaign` with `audienceRules`.

## Journey definitions

A journey is a trigger, optional exit conditions, and a list of steps that each contact
runs through.

### Grammar

| Field | Holds |
| --- | --- |
| `trigger` | What starts an enrollment, one shape per `type` (below) |
| `exitConditions` | Up to 10: `{ "type": "event", "name": "…", "where": [ … ] }`, or `{ "type": "conversion_goal" }` to exit on any of the journey's conversion goals. An exit event must differ from the trigger's |
| `steps` | Up to 50. Their order is the default path. May be empty while a draft |
| `messageLimits` | `{ "apply": true, "retryFor": { "value": 24, "unit": "hours" } }`: whether the journey's messages count toward the workspace's message limits, and how long one over a limit is retried (whole hours, at most 48) |
| `maxEntries` | The most enrollments the journey starts over its life; unset has no cap |

The `trigger` types:

| `type` | Starts an enrollment when | Fields |
| --- | --- | --- |
| `event` | The contact does an event | `name`, optional `where` |
| `list` | The contact joins a list | `listId` (unset: any list) |
| `segment` | The contact starts or stops matching a segment, found within 15 minutes | `segmentId`, `on` (`enter`, the default, or `exit`) |
| `form` | The contact submits a form | `formId` (unset: any form), optional `where` (`outcome` `equals` `created` for new contacts only) |
| `attribute` | An attribute of the contact changes | `key`, optional `where` on `value` and `previous_value` |
| `date` | A date on the contact comes round | `field`, `recurrence` (`once` or `annual`), `offset` (whole days, -365 to 365), `time` (`HH:MM` in the contact's timezone) and `leapDay` |

Every type but `date` takes `reentry`: `{ "mode": "once" }` (the default) or
`{ "mode": "repeatable", "waitAfterEntry": { "value": 7, "unit": "days" } }`, which lets a
contact enter again once that long has passed since their last entry **started** (zero
lets entries overlap). A contact enters one repeatable journey at most 10 times an hour,
whatever the wait. A `date` trigger repeats by its `recurrence` instead.

Conditions are `where: [{ "property", "operator", "value" }]`, up to 5, all of which must
hold, in a trigger, a branch and an event exit. The operators are `equals` and
`notEquals`, which ignore case and accents in text (`notEquals` also matches an event
without the property), and `gt`, `gte`, `lt` and `lte`, which compare numbers; the date
operators are for segment rules, which are evaluated when read. A
dotted property reads a nested one (`order.total`).

Every duration is `{ "value", "unit" }`, with `unit` `seconds`, `minutes`, `hours` or
`days`; each field states its range.

Each step has an `id` you choose, unique within the journey (`^[a-z0-9][a-z0-9_-]{0,63}$`),
an optional `name`, and a `type`. It moves the contact on by naming the id of another step:

| `type` | Does | Moves to |
| --- | --- | --- |
| `wait` | Waits a `duration` (1 minute to 365 days), `businessDays`, or `until` a date (with an optional signed `offset`), optionally ending within a `window` of hours | `nextStep` |
| `wait_until` | Waits up to `timeout` (1 minute to 90 days) for the contact to do `event.name`, optionally meeting `event.where` | `onMatchStep`, or `onTimeoutStep` |
| `digest` | Gathers further trigger events for `gatherFor` (1 minute to 7 days) into one run | `nextStep` |
| `send` | Sends an email (`channel: "email"`) or a push (`channel: "push"`) | `nextStep`; `onUnreachableStep` when the contact cannot receive the channel; `onFailureStep` when the plan's allowance is used up |
| `branch` | Checks the trigger event's properties against `where` | `onMatchStep` or `otherwiseStep` |
| `filter` | Checks segment `rules`, once or before every later step (`mode`) | `onMatchStep`, or `otherwiseStep` (`null`, the default, ends) |
| `split` | Up to 5 `paths`, each with segment `rules`, checked in order | The first matching path's `nextStep`, else `otherwiseStep` |
| `random_split` | 2 to 5 `paths` by `percent`, adding up to 100 | The chosen path's `nextStep`; `null` is a holdout |
| `action` | Sets or changes an attribute, adds to or removes from a list, or sends the `journey.action_reached` webhook | `nextStep` |

Transitions follow these rules:

- A target must be the id of a step in the journey, or the save is a 400 at that field.
- Following transitions must never come back to a step: a loop is a 400 at the transition
  that closes it. A jump back to an earlier step that then ends is allowed.
- `nextStep` omitted runs the next step in `steps`; after the last step, the journey ends.
  So the array's order is the default path.
- `null` ends the journey at that step. The last step of a branch should set `null`, or
  the contact continues into the steps that follow it in the array.
- Inserting or moving a step changes nothing else, since transitions name ids.
- An id names one step for the journey's life: a step's metrics follow its id across
  edits, so reusing a deleted step's id merges their metrics.

Contacts partway through a journey keep the path they entered on; an edit applies to new
entries. A send step's content is the exception: a fix to its subject, design, sender or
push message reaches everyone who has not reached that step yet, matched by its `id`.

An email `send` step's `content` holds `subject`, `from`, optional `previewText` and
`replyTo`, and `templateSnapshot`: a design in the template document format (below),
or an HTML email (`{ "kind": "html", "html": ... }`), copied into the step. A push step's `content` holds `pushProjectId` and `push`. A
`send` step may also set `topicId`, or `transactional: true` for a service message that
needs no marketing consent.

### What activation needs

A draft can be saved unfinished. `activateJourney` (scope `journeys:activate`) refuses
until the journey is whole:

| Response | Cause |
| --- | --- |
| 409 `DRAFT_INCOMPLETE` | No steps; a send step missing its subject, sender, design or push content; a button or video with no working link (none, or not an `https:`, `http:`, `mailto:` or `tel:` address or a merge tag) or a link to a placeholder address; or a list, segment, form, topic or webhook the journey names that no longer exists. `errors` lists each one with its `path` |
| 409 `SENDING_DOMAIN_UNVERIFIED` | An email step's `from` is not on a verified sending domain |
| 409 `SENDING_DOMAIN_DMARC_MISSING`, 409 `POSTAL_ADDRESS_MISSING` | An email step needs a DMARC record on its sender's domain, and the workspace needs a postal address |
| 409 `PUSH_APP_NOT_READY` | A push step's project has no app that can send |
| 409 `AGENT_CONFIRMATION_REQUIRED` | An agent wrote or edited the journey; a person confirms it first |
| 403 `PLAN_LIMIT_REACHED` | The plan's limit on active journeys is reached |

`getJourneyActivationCheck` (`GET /v1/journeys/{id}/activation-check`, scope
`journeys:read`) lists every one of these problems at once without activating, except
the plan limit and the agent confirmation.

### Minimal

A journey that sends one email when a contact signs up:

```json
{
  "name": "Welcome",
  "trigger": { "type": "event", "name": "Signed Up" },
  "steps": [
    {
      "id": "welcome-email",
      "type": "send",
      "channel": "email",
      "content": {
        "subject": "Welcome to Juniper Kitchen",
        "from": "Juniper Kitchen <hello@mail.juniperkitchen.com>",
        "templateSnapshot": {
          "schemaVersion": 1,
          "blocks": [
            { "id": "title", "type": "heading", "text": "Welcome to Juniper Kitchen" },
            { "id": "footer", "type": "footer", "text": "{{ org.name }}\n{{ org.address }}", "locked": true }
          ],
          "theme": {}
        }
      }
    }
  ]
}
```

### Complete

A cart reminder: wait an hour, email, then record that the contact opened it, or send a
push if they did not open it or could not get the email. It runs for carts over 50 in
pounds, lets a contact enter again 7 days after their last entry started, and stops for
anyone who completes an order:

```json
{
  "name": "Cart reminder",
  "trigger": {
    "type": "event",
    "name": "Cart Abandoned",
    "where": [
      { "property": "currency", "operator": "equals", "value": "GBP" },
      { "property": "total", "operator": "gt", "value": "50" }
    ],
    "reentry": { "mode": "repeatable", "waitAfterEntry": { "value": 7, "unit": "days" } }
  },
  "exitConditions": [{ "type": "event", "name": "Order Completed" }],
  "steps": [
    { "id": "wait-an-hour", "type": "wait", "duration": { "value": 1, "unit": "hours" } },
    {
      "id": "reminder-email",
      "name": "Reminder email",
      "type": "send",
      "channel": "email",
      "content": {
        "subject": "You left something behind",
        "from": "Juniper Kitchen <hello@mail.juniperkitchen.com>",
        "templateSnapshot": {
          "schemaVersion": 1,
          "blocks": [
            { "id": "title", "type": "heading", "text": "Still thinking it over?" },
            { "id": "cta", "type": "button", "text": "Back to your cart", "href": "https://juniperkitchen.com/cart" },
            { "id": "footer", "type": "footer", "text": "{{ org.name }}\n{{ org.address }}", "locked": true }
          ],
          "theme": {}
        }
      },
      "onUnreachableStep": "reminder-push"
    },
    {
      "id": "opened-it",
      "type": "filter",
      "mode": "once",
      "rules": { "all": [{ "type": "activity", "activity": "opened", "journeyStepId": "reminder-email" }] },
      "onMatchStep": "record-open",
      "otherwiseStep": "reminder-push"
    },
    {
      "id": "record-open",
      "type": "action",
      "action": { "kind": "set_attribute", "key": "cart_reminder", "value": "opened" },
      "nextStep": null
    },
    {
      "id": "reminder-push",
      "type": "send",
      "channel": "push",
      "content": {
        "pushProjectId": "0192f3e4-5a6b-7c8d-9e0f-1a2b3c4d5e6f",
        "push": { "title": "Your cart is waiting", "body": "Pick up where you left off." }
      },
      "nextStep": null
    }
  ]
}
```

### Operations

`createJourney`, `updateJourney` (a live journey's edit must leave it whole; contacts
already partway keep the path they entered on), `simulateJourney` to preview an
enrollment without sending, `getJourneyActivationCheck` to list what activation would
refuse, `activateJourney` (with `enrollCurrentMembers` to also enroll the contacts
already in a list or segment trigger), `pauseJourney`, `endJourney`,
`duplicateJourney`, and `getJourneyMetrics` and `getJourneyStepMetrics` for results.
[Campaigns and journeys](https://kirora.io/docs/api/campaigns-and-journeys) gives the calls in order.

## Template documents

A template is an email design, a tree of blocks and a theme, or an email written as
HTML, plus `transactionalDefaults`, the subject, preview text, sender and reply-to its
transactional sends use.

### Designs and HTML

`kind` says which a template is, and never changes. A `design` (the default when
`kind` is left out) has `schemaVersion`, `blocks`, `theme` and `language`. An `html`
template has `html`: the whole email, kept as code.

```json
{ "kind": "html", "name": "Monthly newsletter",
  "html": "<!DOCTYPE html><html lang=\"en\"><body><p>Hi {{ contact.firstName | default: \"there\" }}</p><a href=\"{{unsubscribe_url}}\">Unsubscribe</a></body></html>" }
```

- Saving removes scripts, event handlers, frames, forms, SVG, `javascript:` links and
  stylesheet links other than Google Fonts, which email apps drop anyway, and keeps the
  rest as written, Outlook conditional comments included. CSS is not inlined.
  `POST /v1/templates/render` with `"kind": "html"` returns the HTML as saving keeps
  it, and its warnings say what was removed.
- Merge tags work as in a design. Marketing sends get an unsubscribe link and the
  workspace postal address below the email unless it already shows them; put
  `{{unsubscribe_url}}` in a link's `href` to place the link yourself.
- A brand kit or shared block does not apply to HTML.

### Draft, published and alias

- **The template is the draft.** `createTemplate` saves it and publishes it as version
  1, except from an agent key, whose templates stay drafts. `updateTemplate` changes the
  draft only.
- **Sends use the published version.** A transactional send that names the template
  renders `publishedVersion`, so editing the draft cannot change mail in flight.
  `publishTemplate` makes the draft the next version, with the brand kit and shared
  blocks as they are then, and `hasUnpublishedChanges` says whether there is anything
  to publish, a changed shared block or brand included. A template never published
  returns 409 `TEMPLATE_NOT_PUBLISHED` when a send names it.
- **An alias names the template in a send.** `alias` is lowercase letters and numbers
  joined by `-` or `_`, up to 64 characters, unique in the workspace, and never shaped
  like an id, so `email.template` takes either.
- **Campaigns and journeys copy the design.** Their `templateSnapshot` holds
  `schemaVersion`, `blocks`, `theme`, `language` and `plainTextOverride`, or for an
  HTML template `{ "kind": "html", "html", "plainTextOverride" }`, and is not changed by
  later edits to the template. A design snapshot may not contain `html` blocks.

`listTemplateVersions`, `getTemplateVersion` and `restoreTemplateVersion` read and
restore earlier versions; a version carries the `sharedBlocks` and `brandKit` it was
published with. A write with `expectedRevision` that someone else changed
first returns 409 `REVISION_CONFLICT` (see [Templates as code](https://kirora.io/docs/api/templates-as-code)).

### Blocks

`blocks` is the email from top to bottom, at most 200 blocks across the whole tree and
five levels deep. Every block has:

| Field | Holds |
| --- | --- |
| `id` | Unique among the template's blocks, up to 128 characters |
| `type` | `header`, `heading`, `text`, `image`, `button`, `divider`, `spacer`, `columns`, `columns3`, `imagetext`, `imagegroup`, `boxed`, `quote`, `list`, `product`, `video`, `social`, `menu`, `html`, `section`, `footer`, `repeat` or `shared`, each its own schema in the reference. Any other type is refused with 400 |
| `style` | Overrides such as `backgroundColor`, `fontSize`, `padding` and `fontFamily`; `mobileFontSize` and `mobilePadding` apply on phones (screens up to 620px), and mail apps that ignore mobile styles use `fontSize` and `padding` |
| `visibleIf`, `visibleIfPresent`, `showOn` | Show the block only to contacts matching segment rules, only when a value is present, or only on phones or wider screens |
| `columns`, `blocks` | Children: two or three columns for `columns` and `columns3`; the inside of a `section`; what a `repeat` writes out once per item |

`style.fontFamily` is a font id, not a font name: `arial`, `verdana`, `georgia`,
`tahoma`, `times-new-roman` and `courier-new`, which every mail client has, or a web
font such as `open-sans` or `playfair-display`, which falls back to a safe font where it
does not load. The reference lists every id under `EmailFont`. The theme's
`bodyFontFamily` and `headingFontFamily` are CSS font stacks instead.

A `footer` stays at the top level and takes no conditions. A `repeat` cannot hold another
`repeat`, and a `section` needs at least one block. Keep fields you do not recognise
inside a block when you write a document back.

### Minimal

```json
{
  "name": "Plain note",
  "blocks": [
    { "id": "body", "type": "text", "text": "Hi {{ contact.firstName | default: \"there\" }}, thanks for your order." }
  ]
}
```

`theme` defaults to `{}`, which follows the workspace's brand kit (`getBrandKit`).

### Complete

A shipping notice a transactional send can name by its alias, with its own subject and
sender, a web font on the heading, and no unsubscribe link in its footer:

```json
{
  "name": "Order shipped",
  "alias": "order-shipped",
  "transactionalDefaults": {
    "subject": "Order {{ data.order_id }} is on its way",
    "from": "Juniper Kitchen <orders@mail.juniperkitchen.com>"
  },
  "language": "en",
  "theme": {},
  "blocks": [
    { "id": "logo", "type": "header", "src": "https://juniperkitchen.com/logo.png", "alt": "Juniper Kitchen", "width": "120px" },
    { "id": "title", "type": "heading", "text": "Your order is on its way", "style": { "fontFamily": "open-sans" } },
    { "id": "intro", "type": "text", "text": "Order {{ data.order_id }} left our warehouse today." },
    { "id": "track", "type": "button", "text": "Track your parcel", "href": "https://juniperkitchen.com/track" },
    { "id": "footer", "type": "footer", "text": "{{ org.name }}\n{{ org.address }}", "locked": true, "unsubscribeLink": false }
  ]
}
```

### Operations

`createTemplate`, `getTemplate`, `updateTemplate`, `renderTemplate` to draw a saved
template as a send would, `renderTemplateContent` to draw a document without saving it,
`publishTemplate`, the version operations above, and `sendTransactionalMessage` with
`email.template`.

## Webhook envelope

Every webhook delivery is a `POST` whose body has the same four fields:

| Field | Holds |
| --- | --- |
| `eventId` | The event's id, the same on every retry. Deduplicate on it |
| `eventType` | The event, such as `message.delivered` |
| `occurredAt` | When Kirora queued the event, which can be later than the times inside `data` |
| `data` | The event's payload: a snapshot of the resource when the event happened |

The headers repeat the event and carry the signature: `X-Webhook-Event`,
`X-Webhook-Event-Id`, `X-Webhook-Attempt`, `X-Webhook-Timestamp` and
`X-Webhook-Signature`. Verify the signature over the raw body before you read it (see
[Webhooks](https://kirora.io/docs/api/webhooks#verifying-the-signature)).

### Minimal

The test event, sent only to the endpoint you test:

```json
{
  "eventId": "01a0f2cf-4444-7555-8666-777788889999",
  "eventType": "webhook.test",
  "occurredAt": "2026-09-30T09:00:00.000Z",
  "data": { "webhookId": "01a0f2ce-eeee-7fff-8000-111122223333" }
}
```

### Complete

A delivered email. `data.message` is the message as `getMessage` returns it, never with
its body:

```json
{
  "eventId": "01a0f2cf-4444-7555-8666-777788889999",
  "eventType": "message.delivered",
  "occurredAt": "2026-10-01T09:31:04.000Z",
  "data": {
    "message": {
      "id": "0192f3b0-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
      "messageType": "marketing",
      "channel": "email",
      "status": "delivered",
      "campaign": { "id": "0192f3d3-5e6f-7071-8c83-9e0f1a2b3c4d", "name": "October newsletter" },
      "journey": null,
      "template": null,
      "contact": {
        "id": "0192f3a1-7c2e-7d4b-9a10-3e5f6a7b8c9d",
        "externalId": "customer-4821",
        "email": "amaka@example.com"
      },
      "recipientEmail": "amaka@example.com",
      "simulated": false,
      "correlationId": null,
      "sendAt": null,
      "sentAt": "2026-10-01T09:31:00.000Z",
      "deliveredAt": "2026-10-01T09:31:04.000Z",
      "openedAt": null,
      "openedByMachine": false,
      "clickedAt": null,
      "clickedByMachine": false,
      "complainedAt": null,
      "bounce": null,
      "failure": null,
      "skip": null,
      "createdAt": "2026-10-01T09:30:58.000Z"
    },
    "providerMessageId": "0100019290c1a2b3-example-000000"
  }
}
```

Delivery is at least once and in no fixed order: order events by the timestamps inside
`data`, not by arrival.

### Operations

`createWebhook` to subscribe, `listWebhookEvents` for every event with an example body,
`testWebhook`, `listWebhookDeliveries`, `getWebhookDelivery` and
`redeliverWebhookDelivery`.

## Schedule

A campaign and a transactional message can both be sent later. A campaign takes a
`schedule` object on `scheduleCampaign`, and reads it back as its `schedule`; a
transactional message takes `sendAt` in the send itself.

### Grammar

A campaign's `schedule` is split by `type`:

| Field | Holds |
| --- | --- |
| `type` | `fixed`: everyone at one instant. `local_time`: each contact at a time on their own clock |
| `sendAt` | With `fixed`: an ISO 8601 date-time with its offset, such as `2026-11-17T09:00:00+00:00` |
| `localDateTime` | With `local_time`: a wall-clock time with no offset, `YYYY-MM-DDTHH:MM`, that each contact receives in their own time zone |
| `timezoneIfUnknown` | With `local_time`: the IANA time zone for contacts without one, such as `Europe/London`. Without it, the workspace's time zone |
| `ifTimeHasPassed` | With `local_time`: what contacts get where the time has already passed when you schedule. `send_now` (the default) sends to them at once, `next_day` sends at the same time the next day where they are, and `skip` leaves them out (their messages are skipped with `local_time_passed`) |
| `startsAt` | Read only, with `local_time`: when sending starts, the earliest time zone reaching `localDateTime` |

A field of the other `type` is 400 `VALIDATION_ERROR`.

| Rule | Campaign `fixed` | Campaign `local_time` | Transactional `sendAt` |
| --- | --- | --- | --- |
| Must be | In the future | Still to come in at least one time zone | In the future, and at most 90 days ahead |
| Otherwise | 400 `INVALID_SCHEDULE` | 400 `INVALID_SCHEDULE` once it has passed everywhere | 400 `VALIDATION_ERROR` at `sendAt` |
| Move it | `scheduleCampaign` again, before the send begins | The same | Cancel it and send again |
| Undo it | `unscheduleCampaign`: the campaign returns to `draft` and nothing goes out | The same | `cancelScheduledTransactionalMessage`: the message becomes `skipped` |

A local-time campaign starts sending at its `startsAt`, and each contact's message waits
for their own time. Its `schedule` stays set once sending starts, since each contact's
time still decides when theirs goes; a fixed schedule reads `null` once the campaign is
no longer `scheduled`.

Unscheduling works only while the campaign is `scheduled` and its send has not begun;
after that it returns 409 `CONFLICT`, and `cancelCampaign` stops what is still queued.
Cancelling a transactional message makes it `skipped` with `skip.reason` `cancelled`,
gives its email or push back to the plan's allowance, and publishes `message.skipped`;
cancelling it again returns it as it is, with 200. Once a worker has started sending it,
or for a message sent without `sendAt`, the cancel returns 409 `CONFLICT`.

A transactional retry with the same `Idempotency-Key` and body is recognised before
`sendAt` is checked, so a retry that arrives after the time has passed returns the
first send instead of a validation error.

### Minimal

A campaign at one instant, the body of `scheduleCampaign`:

```json
{ "type": "fixed", "sendAt": "2026-11-17T09:00:00+00:00" }
```

### Complete

A campaign at 08:00 in each contact's time zone, London time for contacts without one,
leaving out contacts where 08:00 has already passed:

```json
{
  "type": "local_time",
  "localDateTime": "2026-11-17T08:00",
  "timezoneIfUnknown": "Europe/London",
  "ifTimeHasPassed": "skip"
}
```

A timed transactional message, the body of `sendTransactionalMessage`:

```json
{
  "to": { "externalId": "customer-4821" },
  "correlationId": "class-3315-reminder",
  "email": {
    "subject": "Your class starts in an hour",
    "from": "Juniper Kitchen <hello@mail.juniperkitchen.com>",
    "html": "<p>See you at 10:00 in the Shoreditch studio.</p>"
  },
  "sendAt": "2026-11-17T09:00:00+00:00"
}
```

Use dates still to come when you run these; the ones here are examples.
[Transactional messages](https://kirora.io/docs/api/transactional#sending-later) computes one with `date`.

### Operations

`scheduleCampaign`, `unscheduleCampaign`, `cancelCampaign`, `sendTransactionalMessage`
and `sendTransactionalMessagesBatch` with `sendAt`, and
`cancelScheduledTransactionalMessage`.

## Message

A message is one send to one contact on one channel. Campaigns, journeys and
transactional sends all create messages, and every one is a row in the
[message log](https://kirora.io/docs/api/messages), whether it was sent, is still waiting, or was stopped.

### One request, one message per channel

A transactional request makes one message for each channel it sends on:

| Request | Messages |
| --- | --- |
| `email` only | One email message |
| `push` only | One push message |
| `email` and `push`, no `fallbackOrder` (the default) | One for each channel the contact can receive: two when they can receive both |
| `email` and `push`, `fallbackOrder: ["push", "email"]` | One for each channel the contact can receive: the first goes, the next waits (`fallbackFor`) and goes only if the first fails, is skipped or bounces |

A channel the contact cannot receive, or the plan has no room for, is passed over and
listed in `skippedChannels`. When none is left, the request fails with that channel's
reason, such as 409 `SUPPRESSED` or 409 `PUSH_DESTINATION_MISSING`.

### Accepted is not sent

A `202` from `sendTransactionalMessage` means the request passed validation and the
checks made on acceptance, and each message was stored as `queued`. Nothing has been
handed to a provider yet. The response lists the messages:

```json
{
  "data": {
    "messages": [
      {
        "id": "0192f3b0-2b3c-7d4e-9f50-6b7c8d9e0f1a",
        "channel": "push",
        "status": "queued",
        "correlationId": "delivery-7731",
        "createdAt": "2026-11-17T08:30:00.000Z",
        "sendAt": null,
        "fallbackFor": null
      },
      {
        "id": "0192f3b0-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
        "channel": "email",
        "status": "queued",
        "correlationId": "delivery-7731",
        "createdAt": "2026-11-17T08:30:00.000Z",
        "sendAt": null,
        "fallbackFor": null
      }
    ],
    "skippedChannels": []
  }
}
```

Keep each `id`. What happens next is the message's `status`, which you read with
`getMessage`, or receive as `message.*` webhooks. [Message log and
exports](https://kirora.io/docs/api/messages#statuses) gives the meaning of each status:

```
queued ─▶ sending ─▶ sent ─▶ delivered        (email)
                          ├─▶ bounced          (email)
                          └─▶ complained       (email)
queued ─▶ skipped                              (a check stopped it, or it was cancelled)
sending ─▶ failed                              (refused, or a problem on Kirora's side)
```

Opens and clicks do not change `status`; they set `openedAt` and `clickedAt`. Checks run
again when a message goes, so a message accepted with a 202 can still end `skipped`, for
example when the address was suppressed in the meantime. Every `skip.reason` and
`failure.reason` is in [Transactional messages](https://kirora.io/docs/api/transactional#what-happened).

### Minimal

The smallest transactional send, one email message:

```json
{
  "to": { "email": "delivered@example.com" },
  "email": {
    "subject": "Your receipt",
    "from": "Juniper Kitchen <hello@mail.juniperkitchen.com>",
    "html": "<p>Thanks for your order.</p>"
  }
}
```

### Complete

A send on both channels, matched to your order by `correlationId`, which every message
and its webhooks carry:

```json
{
  "to": { "externalId": "customer-4821" },
  "correlationId": "delivery-7731",
  "data": { "order_id": "7731", "eta": "Thursday" },
  "push": {
    "pushProjectId": "0192f3e4-5a6b-7c8d-9e0f-1a2b3c4d5e6f",
    "title": "Order {{ data.order_id }} is on its way",
    "body": "Arriving {{ data.eta }}."
  },
  "email": { "template": "order-shipped" }
}
```

### Operations

`sendTransactionalMessage`, `sendTransactionalMessagesBatch` and
`cancelScheduledTransactionalMessage` write; `listMessages` (with `correlationId`),
`getMessage`, `getMessageContent`, and `listMessageDeliveries` for each device a push
went to, read.

## Next steps

- [Templates as code](https://kirora.io/docs/api/templates-as-code) — read, edit and write back a template document
- [Campaigns and journeys](https://kirora.io/docs/api/campaigns-and-journeys) — the calls that use these documents, in order
- [Webhooks](https://kirora.io/docs/api/webhooks) — every event, signatures and retries
- [Limits](https://kirora.io/docs/api/limits) — every size and count in one table