# Campaigns and journeys

Source: https://kirora.io/docs/api/campaigns-and-journeys

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](https://kirora.io/docs/api/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](https://kirora.io/docs/api/reference/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](https://kirora.io/docs/api/identifiers#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](https://kirora.io/docs/api/push).

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

```sh
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 <hello@mail.juniperkitchen.com>",
        "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](https://kirora.io/docs/api/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:

```sh
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:

```sh
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:

```sh
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:

```sh
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:

```sh
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 <hello@mail.juniperkitchen.com>",
          "templateSnapshot": {
            "schemaVersion": 1,
            "blocks": [
              { "id": "title", "type": "heading", "text": "Welcome to Juniper Kitchen" },
              { "id": "footer", "type": "footer", "text": "{{ org.name }}\n{{ org.address }}", "locked": true }
            ],
            "theme": {}
          }
        },
        "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](https://kirora.io/docs/api/models) lists every trigger and step.

### 2. Activate it

```sh
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:

```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](#states) |
| 409 `REVISION_CONFLICT` | Update | It changed since you read it; see [Recovering from `REVISION_CONFLICT`](#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](https://kirora.io/docs/api/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](https://kirora.io/docs/api/reference/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

- [Objects and rules](https://kirora.io/docs/api/models): segment rules and journey definitions in full
- [Webhooks](https://kirora.io/docs/api/webhooks): learn when a campaign has sent
- [Idempotency](https://kirora.io/docs/api/reference/idempotency): retry creates and sends safely
- [Start a journey from an order](https://kirora.io/docs/api/recipe-order-journey): a journey started by `Order Completed`, end to end