# WA Campaigns — Architecture

A self-hosted campaign sender for the **official WhatsApp Business Platform (Cloud API)**.
No WhatsApp Web automation, no unofficial APIs, no scraping. Every outbound message goes
through `POST https://graph.facebook.com/<version>/<PHONE_NUMBER_ID>/messages`.

---

## 1. Requirements

### Functional
| # | Requirement | Where implemented |
|---|---|---|
| F1 | Secure login + dashboard | `src/auth.js`, `src/routes/auth.js`, `public/` |
| F2 | Contact management, CSV import, dedupe, phone validation | `src/services/contacts.js`, `src/util/phone.js` |
| F3 | Consent records + unsubscribe/suppression list | `src/services/consent.js` |
| F4 | Templates with personalization | `src/services/templates.js`, `src/services/render.js` |
| F5 | Preview, test send, immediate send, schedule, cancel | `src/services/campaigns.js` |
| F6 | Background queue, rate limit, retries, duplicate prevention | `src/queue/worker.js` |
| F7 | Delivery/read/failure status via verified webhooks | `src/routes/webhook.js` |
| F8 | Campaign reports | `src/services/reports.js` |
| F9 | Clear errors + setup instructions | `src/util/errors.js`, `README.md` |
| F10 | Demo mode without credentials | `src/providers/demo.js` |

### Non-functional
- **Credentials never reach the browser.** Access token / app secret live in `.env` or in
  the `settings` table encrypted with AES-256-GCM. API responses redact them.
- **Webhooks are authenticated** by `X-Hub-Signature-256` HMAC-SHA256 over the *raw* body.
- **Consent is enforced at three layers**: audience build, enqueue, and immediately before
  the HTTP send (state can change while a campaign is in flight).
- **Idempotency**: a `UNIQUE(campaign_id, phone_e164)` dedupe key plus an optimistic
  `queued → sending` compare-and-swap lease means a recipient can be dispatched at most once.
- **Uncertain outcomes are never blindly resent.** See §6.

---

## 2. Technology stack and why

| Layer | Choice | Reason |
|---|---|---|
| Runtime | Node.js 20+ (ESM) | One language across server/worker/frontend; Meta's own samples are Node. |
| HTTP | Express 5 | Smallest maintainable surface; native async error handling; zero known advisories. |
| DB | SQLite via `better-sqlite3` (WAL) | Target volume is <10k msgs/month. A single file means no DB server to run, back up, or secure. Synchronous API makes the queue's compare-and-swap trivially atomic. All SQL is plain and portable to Postgres later. |
| Queue | SQLite-backed lease queue, in-process worker | At this volume Redis/BullMQ is operational overhead with no benefit. The queue is a table + a poll loop; swapping in Redis later means reimplementing one module. |
| Auth | Server-side sessions, `scrypt` password hashing (Node built-in) | No JWT-in-localStorage footgun; httpOnly + SameSite cookie. No native crypto dependency. |
| Validation | `zod` | Every request body is parsed, not trusted. |
| Phone | `libphonenumber-js` | Real E.164 normalisation/validation, not a regex. |
| Frontend | Vanilla ES modules + a service worker, no build step | You can open a file and read it. `npm start` is the only command. Installable as a PWA. |
| Tests | `node:test` (built-in) | No test framework to maintain. |

**Trade-off accepted:** SQLite means one writer process. The API server and the queue worker
run in the same process by design. If you outgrow that, §10 lists the migration path.

---

## 3. Data model

```
users ──< sessions
users ──< audit_log

contacts ──< consent_events          (append-only audit trail; never updated in place)
contacts ──< campaign_recipients
suppressions (keyed by phone, survives contact deletion)

templates ──< campaigns ──< campaign_recipients ──< message_events
                                    │
                                    └── UNIQUE(campaign_id, phone_e164)   ← duplicate guard

inbound_messages     (opt-out keyword detection, service-window tracking)
webhook_deliveries   (raw payload + signature verdict, for forensics)
settings             (key/value; secrets stored AES-256-GCM encrypted)
pricing_rates        (country × category → rate, for cost estimates)
```

### Campaign state machine
```
draft ──▶ pending_approval ──▶ approved ──┬──▶ sending ──▶ completed
  │             │                 │       │                    ▲
  │             │                 │       └──▶ scheduled ───────┘
  └─────────────┴─────────────────┴──────────────▶ cancelled
```
`approved` is only reachable through the **preview endpoint**, which returns recipient count,
per-recipient rendered text, skip reasons, and estimated cost. Approval records the exact
recipient count and cost the human saw; if the audience changes afterwards the approval is
invalidated and must be repeated.

### Recipient state machine
```
queued ──▶ sending ──▶ sent ──▶ delivered ──▶ read
   │          │          └────────────────▶ failed        (webhook-reported failure)
   │          ├──▶ failed        (permanent API error)
   │          ├──▶ retry_wait ──▶ queued   (transient error, exponential backoff)
   │          └──▶ uncertain               (no response / timeout — NEVER auto-resent)
   └──▶ skipped_no_consent | skipped_suppressed | skipped_invalid | cancelled
```

---

## 4. User flows

1. **Setup** — log in → Settings → paste Phone Number ID / WABA ID / access token / app secret
   → *Test connection* → *Sync templates*. Or leave provider on `demo` and skip all of it.
2. **Build an audience** — Contacts → Import CSV → review the import report (added / updated /
   duplicates merged / invalid numbers with reasons) → record consent (CSV column, or bulk
   action with a stated source).
3. **Send a campaign** — Campaigns → New → pick an *approved* template → map template variables
   to contact fields → pick audience → **Preview** (count, sample renders, skip list, cost) →
   *Send test* to your own number → **Approve & send** or **Approve & schedule**.
4. **Monitor** — Campaign report auto-refreshes: queued / sent / delivered / read / failed /
   skipped, failure reasons grouped by Meta error code, and a per-recipient table.
5. **Opt-out** — a contact replies `STOP` → inbound webhook writes a `consent_events` row and a
   `suppressions` row → all queued messages for that number in any campaign flip to
   `skipped_suppressed` before dispatch.

---

## 5. Security controls

- `helmet` with a strict CSP (no inline script; the frontend ships no `eval`).
- Session cookies: `httpOnly`, `SameSite=Lax`, `Secure` when `NODE_ENV=production`.
- CSRF: double-submit token required on every non-GET API call.
- Login rate limit + per-account lockout after repeated failures.
- All input validated with `zod`; SQL is exclusively parameterised.
- Secrets encrypted at rest (AES-256-GCM, key from `APP_ENCRYPTION_KEY`) and redacted in
  every API response (`***`).
- Webhook signature verified against the raw body with `crypto.timingSafeEqual`; unsigned or
  mismatched payloads are logged and rejected with 401, never processed.
- Role-based access: `admin` (settings, users, approve+send) vs `operator` (build, preview,
  request approval). Approval of a real, paid send requires `admin`.
- Audit log for login, settings change, import, approval, send, cancel, suppression change.

---

## 6. Reliability semantics — read this part

**Retryable** (auto-retry with exponential backoff + jitter, capped attempts):
HTTP 429, HTTP 5xx, Meta codes `130429` (rate limit), `131056` (pair rate limit),
`133016`/`131000` (transient internal), `80007`, and connection errors *that occurred before
the request was written*.

**Permanent** (no retry, recorded with the human-readable cause):
`131026` (not a WhatsApp user / cannot receive), `132xxx` (template problems), `131047`
(re-engagement / outside window), `100` (bad parameter), `190` (bad token), `131031`
(account restricted).

**Uncertain** (socket hang-up or timeout *after* the request was sent): the message may or may
not have been accepted. The recipient is parked in `uncertain`. The worker never resends it.
The report shows an "Uncertain — needs review" bucket with a one-click *Mark as failed and
requeue* action that a human must take. This is the deliberate choice to risk under-delivery
rather than double-messaging a customer.

**Rate limiting** — the worker sends at `SEND_RATE_PER_SECOND` (default 10/s, well under the
Cloud API's throughput ceiling) and respects a configurable per-24h unique-recipient cap that
should be set to your phone number's messaging tier.

### What this app cannot promise
No application can guarantee zero errors, uninterrupted delivery, or freedom from account
restrictions. Delivery depends on Meta's platform, the recipient's device and network, your
template quality rating, and your WhatsApp Business Account standing. Meta can lower your
messaging limits or restrict your number based on user blocks and reports — a well-built
sender reduces that risk but cannot eliminate it. Treat every number in the report as a
*status Meta reported*, not a guarantee that a human read the message.

---

## 7. WhatsApp platform rules this app enforces

Verified against Meta's documentation on 2026-09-08:

- **Per-message pricing** (since 1 July 2025), categorised as **marketing / utility /
  authentication / service**. The old per-conversation model is gone. Rates vary by recipient
  country; UAE rates changed 1 Oct 2025 and several markets changed again in April 2026.
- **Customer service window**: 24 hours from the customer's last inbound message. Inside it,
  free-form (non-template) messages are allowed and are free. Outside it, only an approved
  template may be sent, and it is billed. This app is a *template-first* sender, so it works
  in both cases, and the campaign preview flags which recipients are outside the window.
- **Free entry point**: users arriving via a Click-to-WhatsApp ad open a 72-hour free window.
- **Templates must be pre-approved** by Meta and are auto-categorised (`allow_category_change`
  was discontinued in April 2025). This app only lets you send templates whose status is
  `APPROVED` as reported by the API.
- **Messaging limits** are tiered per phone number and tied to quality rating; exceeding them
  returns `131049`/`130429`, and template classification violations return `131064`
  (introduced April 2026).
- Pricing and limits change. `data/pricing_rates` is seeded with editable estimates and the
  UI labels every cost figure as an **estimate — confirm in WhatsApp Manager**.

Sources: Meta's *Pricing on the WhatsApp Business Platform* and *WhatsApp changelog*.

---

## 8. Demo mode

`WHATSAPP_PROVIDER=demo` (the default) swaps the Cloud API client for a simulator that:
- accepts sends, returns realistic `wamid.` identifiers,
- asynchronously posts **real, correctly-signed webhook payloads** back into the app's own
  `/webhooks/whatsapp` endpoint, so the entire status pipeline is exercised,
- deterministically fails a configurable share of recipients with genuine Meta error codes,
- costs nothing and touches no Meta service.

The UI shows a persistent **DEMO MODE** banner. Switching to `cloud` requires credentials and
a successful *Test connection*.

---

## 9. Deployment

`docker compose up` runs the app plus a nightly SQLite backup sidecar. The webhook needs a
public HTTPS URL — Caddy config included, or use a tunnel during development. See `README.md`.

## 10. If you outgrow SQLite

1. Replace `src/db.js` with a `pg` pool; the SQL is standard apart from `INSERT OR IGNORE`.
2. Move the lease queue to `SELECT … FOR UPDATE SKIP LOCKED` or BullMQ; `src/queue/worker.js`
   is the only consumer.
3. Run the worker as a separate process (`npm run worker`) — it is already a separate module
   with its own entry point.
