Campaigns
- Written for
- + Written for
- Deprecated
- + Deprecated
- Applies to
- + Applies to
Campaigns
Send to a segmented audience over email, SMS or WhatsApp — with consent gating, click tracking, and per-recipient outcomes.
A campaign sends a message to an audience, on a schedule, with tracking. It's the proactive side of Chatly: not "the customer messaged us, route them," but "we have something to say to a specific segment — send it."
Campaigns go out on email, SMS or WhatsApp. There is no in-chat campaign channel; for proactive in-conversation messaging, use a trigger or a workflow with a send_message step.
Warning — Marketing-channel compliance is on you
Email DMARC, SMS A2P 10DLC, WhatsApp template approval, GDPR/PECR/ TCPA opt-in — Chatly records consent per contact per channel and fails closed on SMS and WhatsApp, but you own the legal posture. Don't enable campaigns in production until your compliance is in order.
Shapes of campaigns
Campaign types
Name | Type | Description |
|---|---|---|
|
| Pick an audience, write a message, send now or at a date. The recipient list is materialised once, at send time, from the audience as it stands then. |
|
| A standing rule that enrols visitors as they match a page trigger — a URL pattern, optionally with a dwell time. No audience needed and no fixed schedule. |
Those two are the whole vocabulary. There is no recurring, triggered or drip type — for a timed sequence, build a workflow with wait steps between send_message nodes.
Define a campaign
Dashboard
Campaigns → New → pick a channel → pick an audience → write content → send or schedule.
Email: subject plus body. Sent through the platform email queue.
SMS: body only, sent from a channel you nominate.
WhatsApp: body only, sent from a channel you nominate. Meta's 24-hour session and template rules still apply at the provider.
API
Creating a campaign and sending it are two calls. POST /v1/campaigns always produces a draft.
POST /v1/campaigns — Bearer token
{
"name": "May reactivation",
"type": "one_off",
"channel": "email",
"audienceId": "0190f4a2-8c31-7a55-9f0e-2d1b6c4e77aa",
"subject": "We miss you, {{name}}",
"body": "It's been a while, {{name}}. Come back and see what's new.",
"trackClicks": true
}Parameters
Name | Type | Description |
|---|---|---|
|
| Required, 1–160 characters. |
|
| Required, 1–100,000 characters. A single body field — there is no |
|
| Optional, max 160 characters. The cap is not cosmetic: the email queue rejects longer subjects downstream, after recipients are already marked sent. |
|
|
|
|
| Which configured channel row to send from. Required for SMS and WhatsApp — a workspace may hold two numbers, and "over SMS" doesn't say which. Email has none; it uses the platform queue. |
|
|
|
|
| Required before a |
|
| Required for |
|
| Seconds on the page before enrolling, 0–86,400. Null or 0 means on arrival. |
|
| Rewrite links through the click redirect. Off unless asked for. Default: |
PATCH /v1/campaigns/{id} — Bearer token
Same fields, all optional. Edits are for drafts.
POST /v1/campaigns/{id}/send — Bearer token
{ "scheduledAt": "2026-06-01T15:00:00Z" }Omit the body's scheduledAt to send at the next dispatch tick, which is at most a minute away. The campaign moves to scheduled. Only a draft or paused campaign can be sent; anything else returns a conflict. The call also validates that a one_off has an audience, that an ongoing has a URL pattern, and that a non-email campaign has a channelId.
POST /v1/campaigns/{id}/pause — Bearer token
GET /v1/campaigns — Bearer token
GET /v1/campaigns/{id} — Bearer token
GET /v1/campaigns/{id}/recipients — Bearer token
Pause stops the remaining batches. The list is capped at 200 and is not cursor-paginated; the recipients list is capped at 1,000.
Warning — These endpoints need a signed-in user
Workspace API keys (
ck_…) are accepted only on routes that opt in, and no campaign route does. Ack_key gets a 403 here.
Audiences
One-off campaigns target audiences. At send time the dispatcher re-evaluates the audience and writes one campaign_recipients row per contact, so you hit the current cohort and every send has a row to be idempotent against.
Info — Membership is fixed once the list is materialised
An audience is a live query, and a large send takes several ticks. Fixing the list at materialisation is what makes the run reproducible — somebody who joins the audience mid-send is not swept in, and somebody who leaves still receives it.
Content + templating
The body supports exactly two substitutions:
Hi {{name}},
We haven't seen you in a while. Come back and check out what's new.
— Acme TeamParameters
Name | Type | Description |
|---|---|---|
|
|
|
|
|
|
There are no helpers — no format_date, currency, pluralize or case transforms — no dotted paths into attributes, no deepLink, and no auto-inserted unsubscribe footer. If you need per-recipient logic richer than a name, drive the send from a workflow, where node params get the full templating engine.
Delivery pipeline
campaign_dispatch_tick (every 60s, per workspace)
│
├── MATERIALISE one_off campaigns whose scheduled_at has come
│ → re-evaluate audience → one campaign_recipients row per contact
│
├── ENROL ongoing campaigns gain whoever matched the page
│ trigger since the last tick
│
├── SEND one bounded batch per campaign per tick
│ ├── email → the platform email queue (Brevo)
│ └── sms / → marked here, handed to the campaign send queue,
│ whatsapp which paces per (workspace, channel) in Redis
│
└── ATTRIBUTE recipients who started a conversation inside the
7-day window are marked convertedCampaign status moves through:
draft → scheduled → sending → sent
↘ paused ↗
cancelledEach recipient row lands on one of four outcomes:
queued → sent
→ failed (with a reason)
→ skipped (unsubscribed | no_email | no_phone | no_consent | merged)Info — The recipient row is marked before the provider is called
On every channel. If the process dies between the two, that recipient is recorded sent and never goes out — one missed message. The other order loses the mark and re-sends the whole batch next tick. Under-sending is recoverable by a human; over-sending is not, and on SMS it also costs real money.
Pacing
Rate is a fixed property of the channel, not a per-campaign setting. There is no rateLimit, rampPlan, maxRecipients or send-window field on a campaign.
Per-channel ceilings
Name | Type | Description |
|---|---|---|
|
| 500 per tick, no minimum interval. Handing jobs to the queue is cheap; the email worker paces the provider. |
|
| 20 per tick, minimum 1,000 ms between sends. A Twilio long code accepts roughly one message a second — exceed it and Twilio queues rather than failing, delaying for hours. |
|
| 60 per tick, minimum 250 ms between sends. A new WhatsApp number sits in a 250-conversation/24h tier until Meta raises it. |
Ticks are every 60 seconds, so email tops out around 500/minute per campaign and SMS around 20/minute. To cap the size of a send, narrow the audience.
Tracking
Clicks — set trackClicks: true and outbound links are rewritten through a signed redirect. The redirect is a public route (/v1/campaign-clicks/{token}) that verifies the token and forwards to the original destination, incrementing clickedCount.
Info — Click tracking degrades rather than breaks
If the signing secret or the public origin is not configured, the message goes out with its original links. Losing the attribution beats shipping a link that points at an internal hostname and is dead on a recipient's phone.
Conversions — a recipient who starts a conversation within 7 days of their send is marked converted and counted in convertedCount.
There is no open tracking: no pixel is inserted and no open count is recorded on a campaign. Delivery and read receipts on SMS and WhatsApp are whatever the provider's own webhooks report on the channel, not a campaign metric.
Consent + unsubscribe
Before a recipient is queued, three checks run:
contact.unsubscribedAt— outbound opt-out. Set, and the recipient is skipped with reasonunsubscribed.Per-channel consent — SMS and WhatsApp fail closed. No consent row means refused, not "assume yes", and the recipient is skipped with reason
no_consent.A deliverable address for the channel — otherwise
no_emailorno_phone. Merged contacts are skipped asmerged.
POST /v1/contacts/{contactId}/consent — Bearer token
GET /v1/contacts/{contactId}/consent — Bearer token
{
"channel": "sms",
"state": "granted",
"source": "widget_form",
"evidence": "Signup form v3, checkbox 'SMS updates', 2026-05-11"
}Consent is written deliberately, on its own route, because it is the record you produce when a regulator or Meta asks where an opt-in came from — not something an integration should be able to flip as a side effect of syncing a name.
POST /v1/contacts/{id}/unsubscribe — Bearer token
{ "unsubscribed": true, "reason": "clicked unsubscribe" }This route accepts a workspace API key, so your own unsubscribe page can call it server-side.
Warning — Wire the unsubscribe path yourself
Chatly does not append an unsubscribe footer, does not mint a signed unsubscribe link, and does not add
List-Unsubscribe/List-Unsubscribe-Postheaders. Gmail and Yahoo require one-click unsubscribe from bulk senders, so put the link in your body and point it at a page of yours that calls the endpoint above.
Transactional mail — password resets, order updates, support replies — does not go through campaigns and is unaffected by unsubscribedAt.
Reports
Counters on the campaign itself:
Parameters
Name | Type | Description |
|---|---|---|
|
| Rows materialised, including the ones that were skipped. |
|
| Outcome tallies. |
|
| Only meaningful with |
|
| Conversations started inside the 7-day attribution window. |
GET /v1/campaigns/{id}/recipients is the per-recipient breakdown — status, and for a skip, the reason. That endpoint is the answer to "why did this send to fewer people than the audience holds": every excluded contact still has a row saying why.
There is no per-variant reporting, because there are no variants: the API has no A/B split, no variants array and no winner promotion.
Troubleshooting
Warning — Campaign 'sent' count is lower than the audience size
Expected. Check
GET /v1/campaigns/{id}/recipients— the usual culprits areunsubscribed,no_consent(the fail-closed default on SMS and WhatsApp) andno_email.
Warning — An SMS or WhatsApp campaign won't save or send
Both need
channelId. Create returns a validation error naming the field, and send re-checks it — a campaign that reacheddraftwithout one will be refused at send time rather than half-delivered.
Info — A scheduled campaign hasn't moved
Sending is behind a per-workspace feature gate that an operator can close during a deliverability incident. A refused send leaves the campaign in
draftorpausedso it can be retried, rather than stranding it inscheduledwith nothing willing to dispatch it.