Campaigns
A campaign is a bulk send over an inbox connection into inbox threads. It is not a separate delivery
stack: a campaign resolves an address per recipient, renders a message, and hands it to the same
reply() path a human agent uses — same connection, same credentials, same per-connection rate
limiter. What the campaign layer adds is an audience, a consent gate, a recipient-level record of
what happened to each person, and asynchronous receipt reconciliation.
Scope is inbox, not a scope of its own. An integration that can already message these customers one
at a time would otherwise silently lose the ability to see what was sent to them in bulk.
Lifecycle
create ──▶ draft | scheduled
│
├── POST /:id/send ──▶ validation (422 inline)
│ │
│ ▼
│ status: sending (or scheduled, if deferred)
│ │
│ ▼ queue worker, one job per campaign
│ per recipient:
│ eligibility ─▶ skipped (+skipReason)
│ address resolution
│ adapter send ─▶ sent | failed
│ │
▼ ▼
completed ◀─────────── sent > 0
failed ◀─────────── sent == 0 && failed > 0
POST /v1/campaigns never dispatches. A campaign with sendType scheduled or batch is born
scheduled; everything else is born draft, including a "send now" one — the caller must then call
POST /v1/campaigns/:id/send. This is the single most confusing thing about the flow, so create
emits campaign.draft_created for a send-now that stayed a draft, and campaign.no_recipients when
the audience resolved to zero.
POST /:id/send answers 202, not 200. It validates, commits recipient rows and hands the
campaign to a queue worker; none of the messages have gone out when the response is written. Outcome
lives in GET /:id/stats and GET /:id/recipients.
The terminal rule is sent === 0 && failed > 0 ? 'failed' : 'completed'. A blast where 11,900 of
12,198 landed is a completed campaign with 298 failures — calling that "failed" would hide the
11,900 that did.
DELETE /v1/campaigns/:id cancels; it does not remove. The recipient rows carry delivery receipts,
and InboxEntry.campaignId points at the campaign from inside real customer conversations.
Scheduling
A scheduled campaign is dispatched by a sweep (CampaignSchedulerService), not by a job enqueued at
create time, because send()'s billing, daily-cap, sender-conflict and template-approval checks must
run at the moment of sending rather than hours earlier — and because a delayed job is invisible to an
update that reschedules or cancels. The sweep runs every 60 s, takes at most 50 due campaigns, and
ignores anything more than 24 h overdue: blasting a month-old audience unannounced is worse than not
sending it.
Validation is up front
Everything that would fail identically on every recipient is checked synchronously in send() and
answered as a 422 validation_failed, not discovered mid-loop. Answering 202 and then failing 12,198
times is strictly worse — each failure strands a thread, since the thread is upserted before the send
throws.
| Check | Refused when |
|---|---|
| Status | Campaign is terminal, or already sending |
| Connection | connectionId is null, or the connection no longer exists / belongs elsewhere |
| Sender health | assertConnectionSendable throws (e.g. a QR session with no sessionId) |
| Email domain | Channel is email and no verified sender domain for the connection |
| Message | Non-flow campaign with neither a payload message nor a templateSnapshot |
| Flow | flowId set but the flow is missing, foreign, or has never been published |
| Template | The channel strategy requires approval and templateSnapshot.status !== 'approved' |
| Billing | The billing provider's canSend verdict is not ok |
| Session cap | whatsapp_session daily cap exceeded, or the sender is busy with another campaign |
Existence and tenancy of a flowId are checked at create; publication is checked only at
send, because a flow campaign is routinely drafted against a flow still being built.
Channel strategies
campaign-channel-strategy.ts holds the only per-channel differences the campaign layer cares about.
Delivery belongs to the adapter; this table is address kinds, the approval gate, and the opt-out key.
| Channel | reachableKinds (preference order) |
Approved template required | Opt-out channel key |
|---|---|---|---|
whatsapp |
whatsapp, phone |
yes | whatsapp |
whatsapp_session |
whatsapp, phone |
no | whatsapp |
sms |
phone |
no | sms |
email |
email |
no | email |
facebook |
meta |
no | — |
instagram |
instagram, meta |
no | — |
An unknown channel gets a permissive default with empty reachableKinds, so no address is ever
found and every recipient is skipped rather than mis-sent.
The official WhatsApp API and the QR session share the opt-out key whatsapp on purpose: a STOP on
either should suppress both, because they reach the same person.
Per-recipient eligibility
Consent is evaluated in the worker, per recipient, against this tenant's InboxContactTenant
row — never at dispatch time. A campaign dispatched on Monday and delivered on Friday must honour an
unsubscribe that arrived on Wednesday. Enrolment does not filter on consent either, so the recipient
list stays a faithful record of who the campaign was aimed at.
A skip is recorded, never dropped: status: 'skipped' plus a skipReason. A campaign reporting
900 of 1,000 sent must be able to say what the other 100 were.
skipReason |
Meaning |
|---|---|
unknown_contact |
No InboxContactTenant row — this person has never been seen in this workspace |
unsubscribed |
Global unsubscribedAt, or a per-channel consent row with status: 'unsubscribed' |
suppressed |
marketingSuppressedUntil is still in the future (a bounce or complaint cool-off) |
low_engagement |
Engagement rating ≤ the threshold (default 2 of 5) and not overridden |
duplicate_template |
This contact already received this template, and resend was not opted into |
not_on_whatsapp |
The only candidate identifier is validated invalid |
no_reachable_identifier |
No identifier of any reachable kind for this channel |
Order matters: consent outranks reachability. A contact who unsubscribed and has no phone number is
reported unsubscribed, because that is the fact an operator (or a regulator) cares about.
Three payload overrides tune the soft exclusions — allowLowEngagement (defaults to include),
allowNotOnWhatsapp and allowDuplicateTemplateResend (both default to excluding), plus
lowEngagementThreshold.
Address resolution
reachableAddress walks the strategy's reachableKinds in preference order and takes the first
identifier of that kind whose status is not invalid. An invalid identifier of a reachable kind is
remembered but not used, unless payload.allowNotOnWhatsapp is set.
The resolved address is written onto InboxCampaignRecipient.recipient, because a contact may later
gain, lose or change identifiers and a delivery receipt has to stay attributable to what was actually
used.
The send itself
sendOne upserts a thread on (connectionId, 'dm', address) — the same key live ingest uses — so
the campaign message lands in the existing conversation with that person, links the contact onto
the thread, and calls InboxService.reply() with { silent: true, campaignId }.
silent: truekeeps a 12,198-recipient blast from publishing 12,198 realtime events and firing 12,198 outbound webhooks.campaignIdstampsInboxEntry.campaignId, which is what attributes the entry to the campaign and makes the blast readable in the thread, inseqorder, above the customer's reply.- A campaign send does not close the response turn. It is an outbound message in the conversation, not an answer to an inbound one.
Sends are sequential, not Promise.all. The rate limiter is per connection; firing 12,198 concurrent
sends at it converts the campaign into 12,198 rate-limit rejections. When the limiter does trip, the
recipient stays pending (a volume cap is "not yet", never "no"), the loop stops, and the job
re-enqueues at the limiter's own resetAt. Warm-up pacing defers the same way, but resumes in 24 h
because the warm-up cap is a daily unique-recipient budget. See Warm-up.
The worker is idempotent by construction: it attempts only pending and queued recipients and
writes a terminal status per recipient as it goes, so a retry after a crash at recipient 7,000
resumes at 7,001 rather than re-sending.
Flow campaigns
When flowId is set, an eligible recipient is enrolled into an InboxFlowRun instead of being
sent a template. The flow's own first node is what speaks. The same eligibility gate runs first — an
unsubscribed contact must not be enrolled into a flow any more than they may be messaged.
The dedupe key is campaign:<campaignId>:<recipientId>. It is derived from two immutable primary
keys, so re-draining the queue, a worker retry, or an operator pressing Send twice all recompute the
same string and @@unique([flowId, dedupeKey]) refuses the second enrolment in the database. The
recipient id rather than the contact id is deliberate: a contact enrolled by two different
campaigns converses on one thread, and keying on the thread would make the second campaign look like
a duplicate of the first. A conflict from enrol() is treated as success, not failure.
The recipient row still goes to sent — for a flow campaign that means "this recipient's run
started", which keeps every existing counter, filter and screen truthful without a second vocabulary.
GET /:id/flow-report gives the per-node funnel; a template campaign gets an available: false
shape rather than a 4xx, so a client can render it as an empty state on the same screen.
Receipt reconciliation
Recipients are not final when the worker finishes. Provider delivery receipts arrive asynchronously
on the inbound webhook and are matched to a recipient by
serviceMessageId — the provider's own message id, stored at send time.
The lifecycle is sent → delivered → read, or failed. Receipt statuses normalise as:
| Provider status group | Lifecycle |
|---|---|
pending, accepted, sent |
sent |
delivered |
delivered |
read, seen |
read |
undeliverable, rejected, expired, failed |
failed |
With no status group, a bare seenAt / doneAt / sentAt timestamp is used instead. Transitions
only ever move forward, and each state backfills the earlier timestamps it implies (a read receipt
sets sentAt and deliveredAt if they were missing).
A failure carries the provider's error taxonomy onto the recipient row: errorId, errorName,
errorGroupName, errorPermanent, errorMessage, plus the raw receipt under providerResponse.
errorPermanent is what distinguishes a number that will never work from one worth reattempting.
GET /:id/stats recounts from the recipient rows rather than trusting a cached counter, so it is
live while the job runs.
Click tracking
A campaign CTA points at a tracking hop rather than the destination:
InboxCampaignLinkholdsoriginalUrland a unique opaquetoken, optionally bound to therecipientIdit was rendered for.GET /c/:tokenis public and not under/v1. The token is the authorization — it resolves to exactly one link, and the person clicking is an end user, not an authenticated operator.- The hop records an
InboxCampaignLinkClick(with IP and user-agent) and 302s tooriginalUrl. - An unknown or dead token 302s to
/. It does not 404 and does not error — that would leak whether the token ever existed.
GET /:id/recipients/:recipientId/activity returns one recipient's row plus the clicks attributed to
them.
Routes
All under scope inbox. Writes are idempotency-protected — see
Idempotency.
| Method | Path | Notes |
|---|---|---|
| GET | /v1/campaigns |
Filters: status, channel, connectionId; cursor-paged |
| POST | /v1/campaigns |
201; creates draft or scheduled, never sends |
| GET | /v1/campaigns/free-sms/allowance |
Free-SMS allowance meter for the current cycle |
| POST | /v1/campaigns/preflight |
@ReadOnlyPost dry-run — no Idempotency-Key, nothing created |
| GET | /v1/campaigns/:id |
|
| PATCH | /v1/campaigns/:id |
Refused once a campaign has started sending |
| DELETE | /v1/campaigns/:id |
Cancels |
| POST | /v1/campaigns/:id/send |
202 |
| POST | /v1/campaigns/:id/cancel |
200 |
| POST | /v1/campaigns/:id/retry |
202; re-drains everything still unsent |
| POST | /v1/campaigns/:id/reattempt-failed |
202; resets failed → pending, then retries |
| GET | /v1/campaigns/:id/stats |
Recounted from recipient rows |
| GET | /v1/campaigns/:id/recipients |
?status=skipped is the skip report |
| GET | /v1/campaigns/:id/recipients/export |
Streamed CSV audience export |
| GET | /v1/campaigns/:id/recipients/:recipientId/activity |
Recipient row + attributed clicks |
| GET | /v1/campaigns/:id/flow-report |
available: false for a non-flow campaign |
| GET | /v1/campaigns/:id/flow-report/export |
CSV per-node funnel |
| GET | /v1/campaigns/senders/:connectionId/warmup-status |
Sender health over the last 7 days |
| GET | /v1/campaigns/senders/:connectionId/send-now |
Is this session sender free right now |
| POST | /v1/campaigns/media |
Multipart; 10 MB ceiling, returns { id, url, type } |
| GET | /c/:token |
Public, not under /v1 |
GET /v1/campaigns/free-sms/allowance is declared before GET /v1/campaigns/:id in the controller
so the static segment is matched as a route rather than captured as a campaign id. An SMS campaign
with payload.freeSms === true bills against the plan allowance under the billing channel free_sms
instead of the wallet.
senderWarmupStatus reports healthy (failure rate < 5%, or no traffic), warming (< 20%) or
at_risk, derived from the last 7 days of recipient outcomes on that connection.