The words are a template
{{.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:
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.
{{.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 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: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, andPATCH { "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: 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 asproduct_news on
Tuesday. See messages and consent 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, 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
{{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
segment_id is required and there is no way to express “everybody” other than a
segment 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
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
eventfact about a person is an occasion once — so five sign-ins are one occasion; - an
eventfact 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
emailstep 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 becomesfailedwith that sentence on it rather than being re-rendered forever with nobody told. While it is still trying, the campaign’s metrics count it undersends.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
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 — “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
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.