Bulk Messaging / Campaigns
Overview
The campaigns feature sends a single plaintext message to many recipients while protecting the SMSC from overload. You upload a named recipient list once, then run one or more campaigns against it. Each campaign drips messages into the normal message queue at a configurable rate, and reports live progress and delivery statistics.
Two reusable objects:
| Object | What it is |
|---|---|
| Campaign list | A named, reusable set of recipient MSISDNs, each with up to four optional variables. Upload once (JSON array or CSV), reuse across campaigns. |
| Campaign | A send run: a list (or all active subscribers) + a message template, dripped into the queue. |
Everything is exposed over the HTTP API. All state is stored in Mnesia alongside the rest of the SMSC.
Targeting: lists and the active-subscriber default
A campaign's recipients come from one of two sources:
- A recipient list (
list_id) — the MSISDNs you uploaded. - No list at all — if you create a campaign without a
list_id, it targets all currently active (registered) subscribers — every MSISDN with an unexpired registration in the location store at the moment the campaign starts. This is the easiest way to reach "everyone on the network right now".
Per-recipient variables and templating
Each list recipient can carry up to four variables — var1, var2, var3,
var4 — for things like first name, last name, or plan name. The campaign
message is a template: {{ var1 }}…{{ var4 }} (and {{ msisdn }}) are
spliced in for each recipient when the message is sent. Whitespace inside the
braces is optional, and any placeholder with no value renders as an empty
string.
The templating is deliberately minimal — variable substitution only (no conditionals or filters), so there is no extra dependency and no surprises in what subscribers receive.
Uploading variables via CSV — the MSISDN column is the one named
msisdn/destination_msisdn/number/phone (or the first column if none is
named); every other column becomes var1…var4 in column order:
msisdn,first,last,plan
12025550101,Alice,Smith,Gold
12025550102,Bob,Jones,Silver
Uploading variables via JSON — recipient objects may set the vars directly:
{ "name": "VIPs", "recipients": [
{ "msisdn": "12025550101", "var1": "Alice", "var2": "Gold" }
] }
Example template:
Hi {{ var1 }}, your {{ var2 }} plan renews soon. Reply STOP to opt out.
For recipient 12025550101 above (var1=Alice, var2=Gold) this sends:
Hi Alice, your Gold plan renews soon. Reply STOP to opt out.
Audience filter
When a campaign uses a list, the audience flag decides who actually gets a
message:
| Audience | Behaviour |
|---|---|
active_only (default) | Only list members that are registered (an unexpired location) at the moment the campaign starts. Everyone else is recorded as skipped_inactive and never sent to. |
all | Every member of the list, regardless of registration. |
The registration check is a snapshot taken at start time — subscribers who
register later are not picked up. When a campaign has no list, the recipient
set is already the active-subscriber base, so audience is implicitly
active_only and any supplied value is ignored.
Lifecycle
- draft — created, no targets yet.
- start — the campaign snapshots its targets (from its list or the active subscriber base), then moves to running. If there is nothing to send it goes straight to completed.
- running — the drip worker submits messages into the queue.
- pause / resume — temporarily stop / continue dripping.
- completed — every target has been dripped into the queue (delivery then continues asynchronously, and delivery stats keep updating).
- cancelled — stopped permanently.
Deleting a campaign
Deleting a campaign stops it but keeps the record — it is cancelled rather
than purged, so its history and final statistics stay visible. Stopped and
completed campaigns are removed automatically once they age past the retention
TTL (campaign_retention_hours, default 7 days).
Drip rate (overload protection)
Each campaign has a drip_tps (messages submitted into the queue per second,
default from config). A background worker wakes on a fixed interval and submits
at most drip_tps × interval messages per campaign per tick — so a one-million
recipient campaign never floods the queue. Rate is per campaign; multiple
running campaigns each drip at their own rate.
Progress and delivery statistics
Every campaign object includes a live stats block (poll GET /api/campaigns/:id):
| Field | Meaning |
|---|---|
total | Targets snapshotted at start |
pending | Not yet dripped into the queue |
queued | Submitted, awaiting a delivery report |
delivered | Confirmed delivered |
failed | Delivery failed / dead-lettered |
skipped_inactive | Skipped because not registered at start |
dispatched | queued + delivered + failed |
progress_percent | Percent of targets no longer pending |
delivery_percent | Percent of dispatched messages delivered |
Delivery and failure counts update automatically as delivery reports flow back
through the normal SMSC delivery path. Per-recipient detail (and filtering by
state, e.g. everyone that failed) is available via
GET /api/campaign_targets/:id?state=failed.
Configuration
config :sms_c, :campaigns,
# Start the drip worker (set false to pause ALL campaign delivery)
enabled: true,
# Interval between drip ticks in milliseconds
drip_tick_ms: 1000,
# Per-campaign messages/second when a campaign does not set its own drip_tps
default_drip_tps: 50,
# Default message validity window when a campaign sets no validity_hours
message_validity_hours: 72,
# How long a stopped/completed campaign persists before it is purged (hours)
campaign_retention_hours: 168
Validity and scheduled delivery
A campaign can set, at creation time:
validity_hours— the message validity period. Each submitted message is stamped with anexpiresso undelivered messages lapse instead of lingering in the queue. Defaults tomessage_validity_hoursfrom config.deliver_after— an optional ISO8601 time (must be in the future) for a scheduled send.
How scheduled delivery interacts with the drip. The campaign still starts
dripping immediately at drip_tps, but each message's clock is shifted forward
by a fixed offset delta = deliver_after − started_at. So a message dripped at
real time t is stamped send_time/deliver_after = t + delta and
expires = t + delta + validity_hours. Because messages are inserted at
drip_tps in real time, their per-message deliver_after values come out
staggered at drip_tps starting at deliver_after — delivery is rate-limited
rather than a thundering herd at the scheduled instant. With no deliver_after,
delta = 0 and messages are dripped and delivered immediately.
Typical workflow
# 1. Upload a named recipient list with per-recipient variables (CSV or JSON)
curl -k -X POST https://smsc:8443/api/campaign_lists \
-H 'Content-Type: application/json' \
-d '{"name":"VIP customers",
"csv":"msisdn,first,plan\n12025550101,Alice,Gold\n12025550102,Bob,Silver\n"}'
# => {"id": 1, "recipient_count": 2, ...}
# 2. Create a campaign against that list, templating in the variables
curl -k -X POST https://smsc:8443/api/campaigns \
-H 'Content-Type: application/json' \
-d '{"name":"Renewal notice","message":"Hi {{ var1 }}, your {{ var2 }} plan renews soon.",
"source_msisdn":"12345","source_smsc":"IMS_SMSC",
"list_id":1,"audience":"active_only","drip_tps":50}'
# => {"id": 7, "status": "draft", ...}
# 3. Start it
curl -k -X POST https://smsc:8443/api/campaigns/control \
-H 'Content-Type: application/json' \
-d '{"campaign_id":7,"action":"start"}'
# 4. Poll progress / delivery stats
curl -k https://smsc:8443/api/campaigns/7
# => {... "stats": {"total":..,"delivered":..,"failed":..,"progress_percent":..}}
To message all active subscribers, omit list_id in step 2 (and skip
step 1 entirely).
See also
- Campaigns API reference
- SMS Routing Guide — how dripped messages are routed/delivered
- Configuration Reference