> ## Documentation Index
> Fetch the complete documentation index at: https://docs.userkit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Campaigns and templates

> An audience that is always a segment, a trigger, a sequence and two caps — plus templates that are refused at the save rather than at three in the morning.

A campaign is four things: **an audience**, **a trigger**, **a sequence** of one or
more messages, and **a cap** on how often it may reach the same person. The words
live in a template, and the template is what decides the category the recipient can
switch off.

## The words are a template

```bash theme={null}
curl -X POST "https://api.userkit.dev/v1/organization/message-templates?environment=live" \
  -H "Authorization: Bearer uk_st_…" \
  -H "X-Organization-Id: org_4b1e…" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Win-back — first note",
    "channel": "email",
    "category": "marketing",
    "subject": "{{if .Contact.Name}}{{.Contact.Name}}, {{end}}your account is still here",
    "body": "We kept everything on {{.Plan.Name}} exactly as you left it."
  }'
```

**The subject and the body are compiled when you save, and a template that cannot
render is refused rather than stored.** `{{.Contact.Nickname}}` answers
`400 template_refused` naming the field and listing what there is instead — never
a row that renders `<no value>` into ten thousand inboxes at three in the morning. A
broken template has to fail where somebody is looking.

### The context is closed

That check is only possible because what a template may say is a finite list:

| Reference                                                | Is                                                                                                                    |
| -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `.Contact.Name`, `.Contact.Email`, `.Contact.ExternalID` | The recipient. `Name` is empty when nobody ever told us; `ExternalID` is empty for a contact your backend never named |
| `.Contact.Attr "key"`                                    | Anything `identify` was called with                                                                                   |
| `.Customer.Name`                                         | The team they were acting in, empty when there is none                                                                |
| `.Plan.Name`                                             | The plan, as entitlements resolve it: a plan key, `none`, or `unknown` when resolution failed                         |
| `.Entitlements.Has "feature_key"`                        | Whether that plan includes a feature                                                                                  |
| `.Billing.InvoiceURL`, `.Billing.GraceUntil`             | What this team owes right now, empty for everybody with no outstanding bill                                           |
| `.Data.variable_name`                                    | A value **the sender** supplies, and only if the template declared that variable                                      |

Nothing else. No environment, no organization, no request, no clock, no query — and
that is a security property before it is an ergonomic one: whatever a template can
reach is reachable by anybody who can write one.

An **attribute** and a **feature key** are reached through a call rather than an
index, and the difference is the failure mode. Both are data, so the valid keys
are not knowable when you save; a call answers `""` or `false` for a key nobody
set, where an index would render `<no value>` into somebody's inbox.

### Variables are declared

`.Data` is the one open thing in a context whose entire design is being closed,
and it is open in one direction only: the template **declares** which keys it
reads.

```json theme={null}
{
  "name": "Invoice due",
  "channel": "in_app",
  "category": "transactional",
  "key": "invoice_due",
  "subject": "Invoice {{.Data.invoice_id}} is due {{.Data.due}}",
  "body": "It is {{.Data.amount}}.",
  "variables": ["invoice_id", "due", "amount"]
}
```

`{{.Data.invoice_id}}` compiles only when `invoice_id` is in `variables` — that is
what keeps the save-time check possible with an open value. The same list is the
send's contract: a caller of
[`/v1/notifications`](/en/guides/notifications) missing one of the declared values
gets `400 missing_template_data` naming it.

The `key` is how a program addresses the template — it survives the day somebody
renames it, which a name does not. The same key exists once per channel, and that
is what lets one call reach `in_app`, `email` and `whatsapp`.

### A list, when the message has rows

A weekly report needs N rows, and a plain variable cannot carry them. Declare a
**list with its columns** and the body walks it:

```json theme={null}
{
  "name": "Weekly report",
  "channel": "email",
  "category": "transactional",
  "key": "weekly_report",
  "subject": "Your week {{.Data.week}} report",
  "body": "Most engaged:\n\n{{range .Data.members}}• {{.name}} — {{.points}} points\n{{end}}",
  "variables": ["week", { "name": "members", "fields": ["name", "points"] }]
}
```

```json theme={null}
{
  "template": "weekly_report",
  "data": {
    "week": "12",
    "members": [
      { "name": "Ana", "points": "92" },
      { "name": "João", "points": "41" }
    ]
  }
}
```

**`range` walks a declared list and nothing else**, and the columns are why. The
loop rebinds the dot, and the save-time check is worth exactly what it knows
about what the dot is at every point — the columns are where that knowledge
comes from. A `{{.email}}` inside a list that declared only `name` and `points`
is refused at the save, naming the column that is not there.

What it buys is where the formatting lives. The alternative is building the
table in your own deploy and sending it as one string — that works, and it puts
the column order, the heading and "cut it to three rows" back inside your code.
With a list, the template draws the row and you send the cells.

Three things worth knowing:

* **A row missing a declared column is a `400`**, naming it once
  (`members[0].points`) rather than once per row.
* **`{{else}}`** covers the empty list: `{{range .Data.members}}…{{else}}Nobody
  this week.{{end}}`, with no second template.
* Values are **strings**, inside rows too. A number formatted by us is a number
  formatted by our rules in somebody else's currency.

### An edit becomes a version

Editing the words writes a **new version**; the previous one stays, and
`PATCH { "version": 2 }` brings it back without deleting the newer one.
`GET /v1/organization/message-templates/{id}/versions` lists the history with who
wrote each one.

Renaming or archiving writes no version — only words do. That is what makes
editing this text safe from the panel, from the API and from
[MCP](/en/guides/mcp): the worst outcome of an unfortunate edit is a restore.

Text, field chains, `{{if}}`/`{{else}}` and the standard template functions work.
**`range`, `with`, `template` and variables are refused** — not because they are
dangerous but because they rebind `.`, and the check that refuses an unknown field
depends on knowing what `.` is at every point. Nothing in the context is a
collection, so there is nothing to walk.

### The category lives here

A category is a statement about what a message **says**, and the template is the
only object that knows what it says. A campaign inherits it rather than choosing
one, which is what stops the same win-back copy going out as `product_news` on
Tuesday. See [messages and consent](/en/guides/messages) for what each one means.

`transactional` is the exception and has a rule of its own: it is the receipt
nobody unsubscribes from, so it **needs a `key`** and **cannot be a campaign
step** — a sequence built on one would reach an audience with no way to stop it.
Write it for [`/v1/notifications`](/en/guides/notifications), not for a campaign.

An `email` template needs a `subject`; on an `in_app` one it is the **title** of
the row in the bell, and optional (a campaign uses its own name); a `whatsapp` one
must not have one — a paired phone has no envelope and no title, and a subject
there would be a field nothing renders. The **channel cannot change** afterwards
for the same reason: moving a row between them would have to invent or discard a
column in the same write.

### Preview against a fiction

```http theme={null}
POST /v1/organization/message-templates/{id}/preview
```

The sample contact is fixed, fictional and **completely filled in**. A preview
against a real contact would make a text box a way to read one person's
attributes, and a preview whose fields were empty would take the `{{else}}` branch
of every `{{if}}` — a working preview of a broken template. What a missing value
looks like is whatever your own `{{if}}` says it looks like.

`DELETE` on a template **archives** it. It went to eleven thousand people and the
delivery log names it; a row that vanished would leave that history pointing at
nothing. `PATCH {"archived": false}` puts it back.

## The audience is always a segment

```bash theme={null}
curl -X POST "https://api.userkit.dev/v1/organization/campaigns?environment=live" \
  -H "Authorization: Bearer uk_st_…" \
  -H "X-Organization-Id: org_4b1e…" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Win-back",
    "trigger_kind": "segment_entered",
    "segment_id": "…",
    "frequency_cap_days": 30,
    "steps": [
      { "template_id": "…", "offset_hours": 0 },
      { "template_id": "…", "offset_hours": 72 }
    ]
  }'
```

`segment_id` is required and there is no way to express "everybody" other than a
[segment](/en/guides/segments) that matches everybody. A campaign has no filter of
its own on purpose: an audience defined twice is an audience that can be previewed
one way and mailed another, with nothing saying so.

A campaign is always created as a **draft**, and writing one is free on every plan.

## Four triggers

| Kind              | Fires                                                                                                                         | How                                      |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
| `one_shot`        | Once, to whoever is in the audience at the moment you arm it                                                                  | Activation writes the audience down      |
| `event`           | The first time a named fact is true of somebody in the audience                                                               | A consumer off the event bus, in seconds |
| `segment_entered` | When somebody enters **this campaign's own audience**                                                                         | The same consumer                        |
| `date_offset`     | N days after a timestamp on the contact — `created_at`, `first_seen_at`, `identified_at`, `email_verified_at`, `last_seen_at` | A daily sweep                            |

The first three are announced by something. The fourth is not: nothing is
published when a clock crosses a boundary, so it is swept rather than consumed.
All five attributes are in the past, which is why the offset may not be negative —
"three days before" is a sentence about a future date and there is none here to
write it against.

**A person is reached once per occasion, ever.** What counts as an occasion is
built out of what the occasion *is*, never out of a row id:

* an `event` fact about a **person** is an occasion once — so five sign-ins are
  one occasion;
* an `event` fact about an **object** is an occasion per object — five gateway
  retries on one invoice are one chase, and three invoices over a year are three;
* entering a segment is one occasion: leaving and coming back is churn noise;
* a date offset happens once, and the **number is not part of it** — editing a
  campaign from "day 7" to "day 14" must not mail everybody it already mailed;
* arming a one-shot again after a pause **is** a new occasion, and it will reach
  whoever is in the audience now.

## A sequence

`steps` is the sequence in order, up to ten. `offset_hours` is measured from the
**previous step's delivery** — and from the claim on the first one, which is how
"two hours after they sign up" is written without a fifth trigger kind. A one-shot
is a sequence of one.

Steps are replaced **as a set** on every write. A sequence is the campaign's
definition of what it says and when, so editing it a row at a time would leave a
window in which a live campaign sends half of one sequence and half of another.

## Two limits, two different promises

`frequency_cap_days` (default 30) is how long **this** campaign waits before it may
reach the same person again. Behind it sits a **24-hour cooldown across every
campaign of the environment**, and that is the one that makes the real promise:
three campaigns each obeying their own cap perfectly is three messages in an hour,
and the recipient experiences the sum rather than the three policies. The cooldown
is not a setting — a knob there would be turned down by exactly the tenant whose
mail is the problem.

**Neither applies to the later steps of a sequence already begun.** A sequence is
one conversation; the cooldown is a statement about how many conversations may
start, not about whether one that started may finish. Applied to step two it would
break every sequence in half in any environment running more than one campaign —
and the setup would work perfectly in test.

The cap and the cooldown mean *not now* and record nothing. An **opt-out** means
*no*, and it records a `suppressed` send with the reason.

## What a sequence guarantees

* **Nothing is sent that was not written down first.** Every message is a claimed
  row before it leaves, which is what makes a blast resumable: a worker that dies
  at recipient 4,312 continues at 4,313, because 4,313 is already on disk.
* **Once per person per occasion**, whatever the bus does. Facts are delivered at
  least once and a redelivery writes nothing.
* **Pause stops what is claimed and not yet sent**, not only what would have been
  claimed next. A blast of ten thousand rows that could not be stopped halfway
  would make the button a lie.
* **Consent and audience are re-checked at every step**, never inherited from
  step one. Somebody who left the audience between two messages has stopped being
  who the campaign is for — a win-back's second note to somebody who came back is
  the worst message this module can send.
* **Anonymous visitors are never reached**, and an `email` step needs an address.

## What it does not guarantee

* **It is not a clock.** Delivery and the next step of a sequence are swept every
  minute; date offsets are swept once a day. Offsets are hours, so a minute is
  well inside the promise — but a message due at 09:00 goes out at 09:00-something.
* **A date offset more than seven days old is not mailed.** A "welcome to your
  second week" that arrives in the second month is worse than nothing, and it is
  what keeps a run missed for a month from mailing a year of anniversaries at once.
* **A sequence can end early.** Unsubscribing, leaving the audience, or the
  occasion itself being resolved stops the remaining steps — deliberately.
* **A failed send is terminal, unless nothing could have left.** A retry cannot
  tell one message from two, so a failure is recorded rather than repeated. The
  one exception is a send that could not be ATTEMPTED — a read that was
  unavailable, a queue that refused the publish — which goes back to being
  outstanding work and is due again five minutes later. Nothing left, so there is
  no second message to fear.
* **A send that could not be attempted gets five tries, and then stops.** The
  waits double — 5, 10, 20, 40 minutes — and each attempt records what it hit in
  `last_error`. A condition that is still broken on the fifth is not an outage,
  and the send becomes `failed` with that sentence on it rather than being
  re-rendered forever with nobody told. While it is still trying, the campaign's
  metrics count it under `sends.retrying`, and the contact's message log shows
  the attempt count beside the status.
* **Email, in-app and WhatsApp deliver.** There is no push and no SMS, and there
  will be no method here that has no endpoint behind it. Consent is per category
  and not per channel, so an opt-out closes all three — including the feed row.

## Arming is the act a plan gates

```http theme={null}
POST /v1/organization/campaigns/{id}/activate
POST /v1/organization/campaigns/{id}/pause
```

Writing a campaign, composing its templates, previewing them and reading anything
here are free on every plan. **Arming** answers `402
outbound_campaigns_not_included` when the plan does not include it — a plan
refusal rather than a permission one, because `403` is a dead end and this has a
way out. Everything already written is kept and stays editable.

**No plan is consulted on the way out.** A plan may refuse an act, and stopping is
the one act a refusal must never stand in front of.

On a one-shot, arming also materialises the audience and answers with `claimed` —
how many people this is going to reach. Pausing is not a return to `draft`: draft
would read as "never sent", and the send log says otherwise.

A one-shot that runs out of audience archives itself and publishes
`campaign.completed`, which is deliverable as a
[webhook](/en/guides/webhooks) — "the blast is done, now run the report". The
individual sends stay off the bus: eleven thousand deliveries for one blast would
be a denial of service against your own endpoint, and the record is the delivery
log.

Deleting a campaign is a **real delete**, and its send log goes with it. That is
the honest answer for the one built by mistake; a campaign that actually sent
something should be paused and left where it is.

## What a campaign did

```http theme={null}
GET /v1/organization/campaigns/{id}/metrics?window_days=14
```

The numbers come from three kinds of source and the difference matters when you
read them.

**`sends` is the claim** — written before anything left, so `sent` and
`suppressed` cannot disagree with what went out. `suppressed` is the number that
makes an unsubscribe visible as an outcome instead of as a gap.
`retrying` is the one to watch while a campaign is running: those are pending
sends waiting out a backoff after an attempt that could not be made, and each of
them has at most five before it becomes `failed`. A number that keeps climbing
there is a cause somebody can still fix — the send's `last_error`, on the
contact's message log, says what it is.

**`delivery` is the mail provider's answer, and `tracked` is the field to read
first.** Zero-of-zero-tracked means *we are not measuring*; zero-of-nine-hundred
means *nobody opened it*. Same percentage, different facts — which is why no rate
is computed here.

**`outcomes` is a join, not a record.** Subscriptions and invoices are matched
against the people this campaign reached, inside `window_days` after their message
went out. It measures coincidence in a window and **never causation**: somebody who
was going to pay anyway is counted. `reached` is the denominator and is
deliberately not `sent + suppressed` — a suppressed send reached nobody, and
crediting it with an upgrade would make honouring an unsubscribe look like
marketing that worked.

## The campaign you did not create

Every environment is born with one, called **Cobrança de pagamento**: the three
notices that go out when a payment fails, at one, four and eight days. It is a
campaign like any other in that **the words are yours** — rewrite the subjects and
the bodies in your own voice, and use `.Billing.InvoiceURL` and
`.Billing.GraceUntil` to name the bill and the deadline.

What is not yours is the switch. It cannot be paused, deleted, re-triggered or
re-sequenced, because pausing it silences the only warning that precedes a
suspension and the grace clock does not stop with it. Its templates carry a
category no contact can switch off, and it is exempt from the frequency cap and
the global cooldown — a win-back sent yesterday must not swallow a payment notice.

Paying stops it: the remaining steps are recorded as `suppressed` with the reason
`resolved`, which is also the answer a support agent needs. A second failed
invoice is a second sequence.

## Permission

`messaging:manage` — owner and admin, reads included. It is not a share of
`engagement:manage` and the split is deliberate: that one covers what your product
says to somebody already looking at it, this covers what it **sends** to somebody
who is not. A campaign lands in an inbox belonging to a person who opened nothing,
it cannot be edited after it leaves, and getting it wrong is a spam complaint
against your own sending domain.
