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.
{ "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:
{ "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:
{
"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:
{
"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.
nextStepomitted runs the next step insteps; after the last step, the journey ends. So the array’s order is the default path.nullends the journey at that step. The last step of a branch should setnull, 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:
{
"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:
{
"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.
{ "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/renderwith"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’shrefto place the link yourself. - A brand kit or shared block does not apply to HTML.
Draft, published and alias
- The template is the draft.
createTemplatesaves it and publishes it as version 1, except from an agent key, whose templates stay drafts.updateTemplatechanges 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.publishTemplatemakes the draft the next version, with the brand kit and shared blocks as they are then, andhasUnpublishedChangessays whether there is anything to publish, a changed shared block or brand included. A template never published returns 409TEMPLATE_NOT_PUBLISHEDwhen a send names it. - An alias names the template in a send.
aliasis lowercase letters and numbers joined by-or_, up to 64 characters, unique in the workspace, and never shaped like an id, soemail.templatetakes either. - Campaigns and journeys copy the design. Their
templateSnapshotholdsschemaVersion,blocks,theme,languageandplainTextOverride, or for an HTML template{ "kind": "html", "html", "plainTextOverride" }, and is not changed by later edits to the template. A design snapshot may not containhtmlblocks.
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
{
"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:
{
"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:
{
"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:
{
"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:
{ "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:
{
"type": "local_time",
"localDateTime": "2026-11-17T08:00",
"timezoneIfUnknown": "Europe/London",
"ifTimeHasPassed": "skip"
}A timed transactional message, the body of sendTransactionalMessage:
{
"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:
{
"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:
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:
{
"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:
{
"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.