Guides

Campaigns

AdminUpdated Sep 19, 2026

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

one_off

type

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.

ongoing

type

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

name

string

Required, 1–160 characters.

body

string

Required, 1–100,000 characters. A single body field — there is no content object and no separate HTML / text parts.

subject

string

Optional, max 160 characters. The cap is not cosmetic: the email queue rejects longer subjects downstream, after recipients are already marked sent.

channel

enum

email, sms or whatsapp. Default: email.

channelId

uuid

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.

type

enum

one_off or ongoing. Default: one_off.

audienceId

uuid

Required before a one_off can be sent.

triggerUrlPattern

string

Required for ongoing. A case-insensitive substring match on the page URL — never a regex.

triggerDwellSeconds

integer

Seconds on the page before enrolling, 0–86,400. Null or 0 means on arrival.

trackClicks

boolean

Rewrite links through the click redirect. Off unless asked for. Default: false.

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. A ck_ 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 Team

Parameters

Name

Type

Description

name

token

{{name}} — the contact's name. Renders as an empty string when unset.

email

token

{{email}} — the contact's email. Renders as an empty string when unset.

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 converted

Campaign status moves through:

draft → scheduled → sending → sent
                  ↘ paused ↗
                    cancelled

Each 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

email

limit

500 per tick, no minimum interval. Handing jobs to the queue is cheap; the email worker paces the provider.

sms

limit

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.

whatsapp

limit

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:

  1. contact.unsubscribedAt — outbound opt-out. Set, and the recipient is skipped with reason unsubscribed.

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

  3. A deliverable address for the channel — otherwise no_email or no_phone. Merged contacts are skipped as merged.

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-Post headers. 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

totalRecipients

integer

Rows materialised, including the ones that were skipped.

sentCount, failedCount, skippedCount

integer

Outcome tallies.

clickedCount

integer

Only meaningful with trackClicks on.

convertedCount

integer

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 are unsubscribed, no_consent (the fail-closed default on SMS and WhatsApp) and no_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 reached draft without 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 draft or paused so it can be retried, rather than stranding it in scheduled with nothing willing to dispatch it.

Was this page helpful?