Campaigns and journeysMenu
On this page

Campaigns and journeys

Updated

Build and send a campaign and activate a journey through /v1: the calls in order, what each needs and the errors you will meet.

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:write and journeys:write, which include reading them, their eligibility and their metrics. Sending or scheduling a campaign needs campaigns:send. Activating a journey needs journeys:activate. Listing a campaign’s messages needs messages:read, and creating a conversion goal needs conversion-goals:write. See Authentication.
  • Marketing consent. Marketing goes only to contacts who have consented on the channel. Granting consent needs a consent.statement saying how the contact agreed (see Recording consent). Contacts without consent are left out of the audience, and the eligibility check counts them in excluded.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 from address must be on a verified sending domain, and the domain needs a DMARC record (p=none at 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.

Terminal
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:

Terminal
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:

JSON
{
  "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:

Terminal
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:

Terminal
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:

Terminal
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:

JSON
{
  "delivery": {
    "rateLimit": { "recipients": 5000, "per": "hour" },
    "expiresAfter": { "value": 48, "unit": "hours" }
  }
}
  • rateLimit sends to at most recipients contacts per minute, hour or day, 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 reason expired: 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 400 VALIDATION_ERROR at delivery.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:

Terminal
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

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

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

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

JSON
{
  "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:

  1. Read the resource again (getCampaign or getJourney) for its current fields and revision.
  2. Apply your change again to what you read, not to your old copy. content and steps replace the whole value, so start from the current one.
  3. Send the PATCH again with the new revision as expectedRevision.

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.

Next steps