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

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:

  1. InboxCampaignLink holds originalUrl and a unique opaque token, optionally bound to the recipientId it was rendered for.
  2. GET /c/:token is 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.
  3. The hop records an InboxCampaignLinkClick (with IP and user-agent) and 302s to originalUrl.
  4. 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 failedpending, 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.