Objects and rulesMenu
On this page

Objects and rules

Updated

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

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 <[email protected]>"
  },
  "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).

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": "[email protected]"
      },
      "recipientEmail": "[email protected]",
      "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 <[email protected]>",
    "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 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, 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 gives the meaning of each status:

Code
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.

Minimal

The smallest transactional send, one email message:

JSON
{
  "to": { "email": "[email protected]" },
  "email": {
    "subject": "Your receipt",
    "from": "Juniper Kitchen <[email protected]>",
    "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