A campaign is one marketing send to an audience, now or at a set time. A journey is an automation: contacts enter when an event happens, and the journey runs its steps for each of them. This guide gives the calls in order for both, and what each error means.
Set KIRORA_API_KEY and KIRORA_API_URL as in the Quickstart.
Before you start
- Scopes. Building needs
campaigns:writeandjourneys:write, which include reading them, their eligibility and their metrics. Sending or scheduling a campaign needscampaigns:send. Activating a journey needsjourneys:activate. Listing a campaign’s messages needsmessages:read, and creating a conversion goal needsconversion-goals:write. See Authentication. - Marketing consent. Marketing goes only to contacts who have consented on the
channel. Granting consent needs a
consent.statementsaying how the contact agreed (see Recording consent). Contacts without consent are left out of the audience, and the eligibility check counts them inexcluded.consentMissing. - A postal address. Marketing email carries your postal address in its footer. Without
one, sending a campaign or activating a journey with an email step is refused with 409
POSTAL_ADDRESS_MISSING. Set it in the workspace settings. - A verified sending domain with DMARC. The
fromaddress must be on a verified sending domain, and the domain needs a DMARC record (p=noneat least) for marketing email. - A push project, for push: the app set up as in Push notifications.
Send a campaign
1 Create the draft
POST /v1/campaigns saves a draft. Only name is required, so you can save early and
finish later. To send, it needs one audience and content for each channel in channels.
The audience is all contacts (allContacts: true), one or more lists (listIds; a
member of several receives it once), a segment (segmentId) or inline rules
(audienceRules). Beside any but rules, exclusions leaves out the members of other
lists and segments. Consent and suppressions apply to every audience.
curl "$KIRORA_API_URL/v1/campaigns" \
-H "Authorization: Bearer $KIRORA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "October newsletter",
"channels": ["email"],
"listIds": ["0192f3a2-1b3c-7e5d-8a21-4f6a7b8c9d0e"],
"content": {
"email": {
"subject": "New this month at Juniper Kitchen",
"from": "Juniper Kitchen <[email protected]>",
"templateSnapshot": {
"schemaVersion": 1,
"blocks": [
{ "id": "title", "type": "heading", "text": "Autumn arrivals" },
{ "id": "intro", "type": "text", "text": "Hi {{ contact.firstName | default: \"there\" }}, the new range is in." },
{ "id": "footer", "type": "footer", "text": "{{ org.name }}\n{{ org.address }}", "locked": true }
],
"theme": {}
}
}
}
}'For push, set "channels": ["push"] and
"content": { "push": { "pushProjectId": "<push project id>", "title": "Autumn arrivals", "body": "The new range is in." } }.
A campaign can select both channels. Content for a channel you did not select is refused.
The response is 201 with the campaign in status: "draft". Keep data.id and
data.revision. The email body is built from its design (templateSnapshot), the same
document Templates as code describes, not from HTML you send.
2 Check that it can send
GET /v1/campaigns/{id}/eligibility lists every blocker without sending anything:
curl "$KIRORA_API_URL/v1/campaigns/$CAMPAIGN_ID/eligibility" \
-H "Authorization: Bearer $KIRORA_API_KEY"The fields to read in data:
| Field | Meaning |
|---|---|
canSend |
true when nothing blocks it anywhere |
audienceCount |
Contacts in the audience |
blockers |
Campaign-wide: campaign_not_draft, unsupported_channels, draft_incomplete, empty_audience |
channels[].blockers |
Per channel: draft_incomplete for its own content, sender_unverified, dmarc_missing, postal_address_missing, plan_limit_reached, new_account_daily_limit (a new account’s daily email limit: a campaign over it is refused whole, and upgrading does not lift it. It is not the new-workspace marketing limit, which holds email for later days and never appears here), push_app_not_ready, no_eligible_recipients |
channels[].eligibleRecipientCount |
Who the channel reaches after the exclusions: the number a send is sized by |
channels[].excluded |
Contacts left out and why: consentMissing, suppressed, topicUnsubscribed, missingEmail or missingInstallation |
Every blocker is { code, message, field? }, the shape of an error: branch on code,
show message, and for draft_incomplete point at field, the part still missing, as
DRAFT_INCOMPLETE would list it in errors:
{
"canSend": false,
"blockers": [{ "code": "draft_incomplete", "field": "subject", "message": "..." }],
"channels": [
{
"channel": "email",
"eligibleRecipientCount": 2440,
"blockers": [{ "code": "sender_unverified", "message": "..." }]
}
]
}The check is an estimate; it reserves no quota. Sending refuses what the check reports as blocked: an unfinished draft, a missing postal address, a sender that is not on a verified domain or lacks DMARC, and a push project that is not ready.
3 Send it, or schedule it
Send now:
curl -X POST "$KIRORA_API_URL/v1/campaigns/$CAMPAIGN_ID/send" \
-H "Authorization: Bearer $KIRORA_API_KEY"The response is 202 with the campaign, sent once its messages are queued. An audience
over 5,000, or an A/B test, is queued in the background: the campaign answers sending
and turns sent when every message is queued (an A/B test’s turns testing once its
versions are out). A campaign sends at most once, so sending
one that has gone returns it again.
Or schedule it with a schedule object, type fixed or local_time. This schedules
it for 08:00 UTC on 2 November 2027; use a time in the future:
curl "$KIRORA_API_URL/v1/campaigns/$CAMPAIGN_ID/schedule" \
-H "Authorization: Bearer $KIRORA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "type": "fixed", "sendAt": "2027-11-02T08:00:00Z" }'fixed sends to everyone at sendAt, an ISO 8601 date-time with its offset, which must
be in the future. local_time sends to each contact at localDateTime, a wall-clock time
with no offset (YYYY-MM-DDTHH:MM), on their own clock. This schedules it for 08:00 where
each contact is on 3 November 2027; pick a day far enough ahead that the time has not
passed in any zone:
curl "$KIRORA_API_URL/v1/campaigns/$CAMPAIGN_ID/schedule" \
-H "Authorization: Bearer $KIRORA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "local_time",
"localDateTime": "2027-11-03T08:00",
"timezoneIfUnknown": "Europe/London",
"ifTimeHasPassed": "skip"
}'timezoneIfUnknown is used for contacts without a time zone; without it, the
workspace’s time zone is. When the time has already passed in some zones,
ifTimeHasPassed decides what those contacts get: send_now (the default), next_day,
or skip (their messages are skipped with local_time_passed). A local time that has
passed in every zone is refused with 400 INVALID_SCHEDULE, as is a fixed time in the
past. The campaign answers with its schedule, and for a local time the computed
startsAt, when the earliest zone reaches it.
Scheduling a campaign that is already scheduled, and has not begun sending, moves it to
the new time. POST /v1/campaigns/{id}/unschedule takes a scheduled campaign back to a
draft.
4 Slow it down, or let it expire
A campaign’s delivery, set on the draft, says how fast it goes out and until when:
{
"delivery": {
"rateLimit": { "recipients": 5000, "per": "hour" },
"expiresAfter": { "value": 48, "unit": "hours" }
}
}rateLimitsends to at mostrecipientscontacts perminute,hourorday, spread evenly across it. It counts contacts, so one contact’s email and push go together. Your plan’s send rate still applies, so a campaign can only slow a send down. A flash sale to 200,000 contacts then does not bring everyone to the shop at once.expiresAfter(1 to 720 hours, from when sending starts) skips everyone not yet sent when it passes, with reasonexpired: an “ends tonight” email does not land tomorrow. A rate-limited send always expires, after 720 hours unless you set it.- With an A/B test, the expiry must outlast
abTest.decideAfter, or the save is 400VALIDATION_ERRORatdelivery.expiresAfter.
The campaign turns sent once every message is queued. With a rate limit it stays
sending while its turns run, and turns sent once its last message has gone or
expired (checked every few minutes); its metrics show messages leaving queued.
5 Read the results
| Call | What it answers |
|---|---|
getCampaignMetrics (GET /v1/campaigns/{id}/metrics) |
How it did over its whole life: contacts reached, conversions and credited revenue at the top; message counters under email and push, never added together (null for a channel it does not send); byStatus counts each message once by its current status and sums to total, while sent and delivered overlap the other counters; push records what the app and the push services report (sent, failed, skipped, opened, clicked; no delivery, bounce or unsubscribe), with device outcomes under push.devices; deliverabilityHealth judges the email’s bounce and complaint ratios on the same lines as the overview report’s (null without email) |
getCampaignSeries (GET /v1/campaigns/{id}/series?interval=hour) |
The same counters per hour or day, each point named by startsAt in the workspace’s timezone |
getCampaignBreakdown (GET /v1/campaigns/{id}/breakdown?groupBy=domain) |
Email results by domain, device or country; the 50 largest rows, the rest summed in other |
listCampaignLinks (GET /v1/campaigns/{id}/links) |
Every link in the email with its clicks, never-clicked ones included |
listMessages (GET /v1/messages?campaignId=) |
Each message, with opened and clicked filters |
An email’s opened includes machine opens (Apple Mail Privacy Protection fetches every
message’s images, Gmail fetches them on delivery while its app is open, and mail
security scanners check messages), counted apart in openedByMachine. A machine’s click (a mail security
scanner checking links on delivery, a link preview) counts in neither clicked nor
opened, and on links only in machineClicks. Revenue is credited revenue: the value of
goal conversions a message drove, gross, before refunds and cancellations.
Run a journey
1 Create the draft
POST /v1/journeys needs a name, a trigger and steps. Each step has an id you
choose; a step’s nextStep is the id of the step that follows it, null to end, or left
out to go on to the next step in the list. This journey waits an hour after a Signed Up
event, then sends one email:
curl "$KIRORA_API_URL/v1/journeys" \
-H "Authorization: Bearer $KIRORA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Welcome series",
"trigger": { "type": "event", "name": "Signed Up" },
"steps": [
{ "id": "wait-an-hour", "type": "wait", "duration": { "value": 1, "unit": "hours" } },
{
"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": {}
}
},
"nextStep": null
}
]
}'The response is 201 with the journey in status: "draft". A draft does not run. Change it
with PATCH /v1/journeys/{id}. Objects and rules lists every trigger and step.
2 Activate it
curl "$KIRORA_API_URL/v1/journeys/$JOURNEY_ID/activate" \
-H "Authorization: Bearer $KIRORA_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'The response is 200 with the journey, active; contacts who match the trigger from
then on enter it.
To see every problem at once before activating, call
GET /v1/journeys/{id}/activation-check (getJourneyActivationCheck, scope
journeys:read). It changes nothing and returns issues, each with the field to fix,
the error code activation would answer with and, on a step, its stepId. Empty
issues means those checks pass; activation can still refuse for the plan’s
active-journey limit or a pending agent confirmation.
For a journey that starts when a contact joins a list or enters a segment, send
{ "enrollCurrentMembers": true } to enroll the contacts already in it too. They are
enrolled in the background over the next minutes; anyone the journey has ever enrolled is
skipped, even when re-entry is repeatable, and so is any entry past maxEntries.
Without it only new members enter, so activating never emails a whole list by surprise.
Activation is also how a paused journey resumes, so the same flag catches up the contacts
who joined while it was paused. Any other trigger type returns 400 VALIDATION_ERROR at
enrollCurrentMembers: a date journey always includes everyone whose date comes round,
and an event journey has no current members.
3 Read how it is doing
GET /v1/journeys/{id}/metrics counts by when each thing happened over a window (the
last 30 days by default): enrollments started, completed and exited (with those that
left by reaching a conversion goal, conversionGoalReached), the messages sent, failed or skipped, and each conversion
goal’s conversions and gross revenue. GET /v1/journeys/{id}/step-metrics counts the
contacts who entered in the window (all time by default), wherever they are now, step
by step: how many reached each step, went on, are there now, and what each send step’s
messages did. Steps are counted by their id across every version of the journey, so a
step keeps its numbers when others are added or moved.
Conversions need a conversion goal: POST /v1/conversion-goals with the journeyId (or
a campaignId), the eventName that counts (Order Completed), and optionally where
and occurrence to narrow which of those events count (an order over $50, a contact’s
first order), and the valueProperty summed as revenue. A new goal also counts the
events already in the retained history, matched to the sends before them, from its
countedFrom. A journey whose exitConditions include { "type": "conversion_goal" }
also ends each enrollment when one of its conversion goals is reached, with exit reason
conversion_goal_reached. Conversion goals need a paid plan, and one that still decides
something (an A/B test not yet decided, a live or paused journey’s exit) cannot be
deleted: 409 IN_USE.
States
Each call below moves a campaign or a journey from one status to another. A call made
in the wrong state changes nothing and returns an error; the tables say which.
Campaigns
A campaign’s status is draft, scheduled, sending, testing (an A/B test’s
versions went out and the rest wait for the winner), sent or cancelled. sent means
every message is queued, not that every message has been delivered. Archiving is not a
status: it sets archivedAt and leaves status as it was.
draft ──send──────────────▶ sent
draft ──send (over 5,000 contacts, or an A/B test)──▶ sending ─▶ sent
draft ──schedule──▶ scheduled ──at its time──▶ sending ─▶ sent
scheduled ──schedule (a new time)──▶ scheduled
scheduled ──unschedule──▶ draft
sending, testing ──cancel──▶ cancelled
testing ──winner chosen──▶ sending ─▶ sent| From | Call | To | A repeat, or the wrong state |
|---|---|---|---|
draft |
sendCampaign (POST /v1/campaigns/{id}/send) |
sent, or sending while a worker queues a large audience or an A/B test |
A campaign that has already gone (sending, testing or sent) is returned as it stands, with 202, and nothing is sent twice. A scheduled or cancelled campaign returns 409 CONFLICT. An archived one returns 409 CONFLICT |
draft or scheduled |
scheduleCampaign (POST /v1/campaigns/{id}/schedule) |
scheduled |
On a scheduled campaign, a different time moves it there. The same time again returns the campaign as it stands, with 200, even once the send has begun; a different time once it has begun returns 409 CONFLICT. A time that has passed returns 400 INVALID_SCHEDULE. An archived campaign returns 409 CONFLICT |
scheduled |
unscheduleCampaign (POST /v1/campaigns/{id}/unschedule) |
draft |
A campaign that is not scheduled, or whose send has begun, returns 409 CONFLICT. Stop one that is sending with cancel |
sending or testing, or sent with messages still queued |
cancelCampaign (POST /v1/campaigns/{id}/cancel) |
cancelled |
Nothing left to stop, a repeat included, returns 409 CONFLICT. Messages already being sent finish; the rest are skipped with campaign_cancelled. The response is { campaign, skippedMessageCount }, the count of those skipped |
testing |
chooseAbTestWinner (POST /v1/campaigns/{id}/ab-test/winner), or the automatic decision |
sending, then sent |
Choosing the same winner again returns the campaign. A campaign that is not waiting on its test returns 409 CONFLICT |
draft |
updateCampaign (PATCH /v1/campaigns/{id}) |
draft |
Any other status returns 409 CONFLICT. A stale expectedRevision returns 409 REVISION_CONFLICT (below) |
draft |
deleteCampaign (DELETE /v1/campaigns/{id}) |
Deleted (204) | Any other status returns 409 CONFLICT: archive it instead |
Any but scheduled, sending or testing |
archiveCampaign (POST /v1/campaigns/{id}/archive) |
Unchanged, archived | A repeat returns the campaign (200). scheduled, sending or testing returns 409 CONFLICT |
| Archived | unarchiveCampaign (POST /v1/campaigns/{id}/unarchive) |
Unchanged, listed again | A repeat returns the campaign (200) |
A cancelled or sent campaign never sends again, and a trigger fires a campaign once.
To send the same content again, duplicate it (duplicateCampaign, with an optional
{ "name" } for the copy) and send or trigger the copy: each send has its own status and
its own metrics.
Journeys
A journey’s status is draft, active, paused or ended.
draft ──activate──▶ active ──pause──▶ paused ──activate──▶ active
draft, active, paused ──end──▶ ended| From | Call | To | A repeat, or the wrong state |
|---|---|---|---|
draft or paused |
activateJourney (POST /v1/journeys/{id}/activate) |
active |
Activating an active journey checks it again and returns it, with 200. An ended journey returns 409 CONFLICT. An unfinished one returns 409 DRAFT_INCOMPLETE |
active |
pauseJourney (POST /v1/journeys/{id}/pause) |
paused |
Pausing a paused journey returns it, with 200. A draft or an ended journey returns 409 CONFLICT |
draft, active or paused |
endJourney (POST /v1/journeys/{id}/end) |
ended |
Ending an ended journey returns 409 CONFLICT. Everyone still partway through exits with reason journey_ended |
draft, active or paused |
updateJourney (PATCH /v1/journeys/{id}) |
Unchanged | An ended journey returns 409 CONFLICT. On an active journey the edit must leave it whole (409 DRAFT_INCOMPLETE). A stale expectedRevision returns 409 REVISION_CONFLICT (below) |
ended, or one nobody has entered |
deleteJourney (DELETE /v1/journeys/{id}) |
Deleted (204) | An active or paused journey that contacts have entered returns 409 CONFLICT: end it first |
While a journey is paused, nobody new enters it, and contacts already partway through
wait where they are until it is activated again. An ended journey never runs again: to
run it again, duplicate it (duplicateJourney, with an optional { "name", "tags" } for
the copy) and activate the copy.
Recovering from REVISION_CONFLICT
Every campaign and journey carries a revision, raised by each edit. Send the revision
you read as expectedRevision on PATCH, and an edit someone else made since you read
it is refused instead of overwritten:
{
"error": {
"code": "REVISION_CONFLICT",
"message": "This has changed since you loaded it. Reload to see the latest version before trying again.",
"details": {
"revision": 5,
"updatedAt": "2026-10-21T14:02:11.000Z",
"updatedBy": { "type": "member", "name": "Amaka Obi" }
}
}
}To recover:
- Read the resource again (
getCampaignorgetJourney) for its current fields andrevision. - Apply your change again to what you read, not to your old copy.
contentandstepsreplace the whole value, so start from the current one. - Send the
PATCHagain with the newrevisionasexpectedRevision.
Repeat if it conflicts again. If details.updatedBy names your own key, an earlier
attempt of yours, such as one that timed out, already landed: read the resource and
check whether your change is there before sending it again. Omitting expectedRevision
overwrites whatever is there.
Errors you will meet
| Status and code | Where | What to do |
|---|---|---|
400 VALIDATION_ERROR |
Any write | errors names each field |
409 DRAFT_INCOMPLETE |
Send, schedule, activate | errors lists each missing field, including a list, segment, form, topic or webhook the journey names that no longer exists |
400 EMPTY_AUDIENCE |
Send | The audience has no contacts |
409 POSTAL_ADDRESS_MISSING |
Send, schedule, activate | Set the workspace postal address |
409 SENDING_DOMAIN_UNVERIFIED |
Send, schedule, activate | The email’s from is not on a verified sending domain |
409 SENDING_DOMAIN_DMARC_MISSING |
Send, schedule, activate | Publish a DMARC record (p=none at least) on the sending domain |
403 PLAN_LIMIT_REACHED |
Activate | The plan’s limit on active journeys, counted across all the owner’s workspaces, is reached: Free allows 3. details.entitlement is active_journeys, and details.limit, details.used and details.upgradeTo say how far it goes |
400 INVALID_SCHEDULE |
Schedule | The time has passed |
409 CONFLICT |
Send, schedule | The campaign is archived: unarchive it first |
409 CONFLICT |
Any state change | The campaign or journey is in the wrong state; see States |
409 REVISION_CONFLICT |
Update | It changed since you read it; see Recovering from REVISION_CONFLICT |
403 SCOPE_MISSING |
Send, schedule, activate | The key lacks campaigns:send or journeys:activate |
403 AGENT_KEY_REFUSED |
Send, schedule, activate | It is an agent key |
409 AGENT_CONFIRMATION_REQUIRED |
Send, schedule, activate | An agent wrote or edited this draft; see below |
Agent keys
An agent key can create and edit campaigns and journeys, but it cannot send, schedule or
activate them. Those calls return 403, and the key cannot hold the scopes.
A campaign or journey an agent wrote or edited also returns 409
AGENT_CONFIRMATION_REQUIRED when another key tries to send, schedule or activate it. A
person confirms it in the dashboard first. An API key cannot give that confirmation. See
Agents.
Retrying
Send an Idempotency-Key with every create: a repeat without one creates a second
campaign or journey, while a retry with the same key within 24 hours returns the first
(see Idempotency). Every other operation
here states its retry behaviour on its reference page, for example that sending a
campaign is safe to repeat.