Architecture
Relay is a single Node.js application that talks to WhatsApp through one headless Chromium per connected number, stores everything in local SQLite, and serves a real-time agent UI behind nginx. It has no external service dependencies — no cloud API, no third-party database, no message broker.
Components
flowchart TB
subgraph browser["Agent / Manager / Admin browser"]
UI["Relay web UI"]
end
UI -->|"HTTPS :443"| NGINX["nginx — TLS termination + reverse proxy"]
NGINX -->|"HTTP :3000"| APP
subgraph container["app container"]
APP["Node.js / Express (server.js)"]
APP --> DB[("SQLite WAL<br/>inbox.db · sessions.db")]
APP --> POOL["client pool (pool.js)"]
POOL --> CH1["Chromium — number 1"]
POOL --> CH2["Chromium — number 2"]
APP --> MEDIA[("data/media<br/>decrypted inbound files")]
end
CH1 <-->|"whatsapp-web.js"| WA[("WhatsApp Web / phone")]
CH2 <-->|"whatsapp-web.js"| WA
APP -->|"SSE + 8s polling fallback"| UI
| Component | File(s) | Responsibility |
|---|---|---|
| HTTP server + routes | server.js |
Auth, sessions, all REST + SSE endpoints, static page serving |
| Client pool | pool.js |
One whatsapp-web.js client (one Chromium) per number; connect/reconnect, QR, send |
| Inbound pipeline | inbox-pipeline.js |
Dedup, LID→phone canonicalization, masking, persist, broadcast |
| Data layer | inbox-db.js |
All SQLite reads/writes (conversations, messages, customers, templates, topics, statuses) |
| Masking policy | inbox-masking.js, inbox-config.js |
Alias generation, leak assertions, phone-free message ids |
| Media | inbox-media.js |
Drift-immune inbound decrypt/store/serve; outbound image validation |
| Permissions | permissions.js |
The agent-capability registry (single source of truth) |
| Users | users.js |
Admin/manager/agent records + auth (JSON file, bcrypt) |
| Admin auth | auth.js |
Admin password + TOTP |
Data flow
Inbound. Chromium receives a WhatsApp message → the pool emits it (from both the
message and message_create events; LID contacts only fire the latter) → the pipeline
drops duplicates, canonicalizes a LID sender to their phone jid, masks the contact to an
alias, persists it, and pushes it to the agent UI over SSE. An 8-second poll on the open
thread is a belt-and-braces fallback for any missed SSE.
Outbound. An agent reply posts to the server → the server resolves which number the customer wrote to (replies always leave from that number; 503 if it is offline) → the pool sends it through that number's Chromium → the sent message is persisted and broadcast back.
Data stores
All state lives under data/ (bind-mounted from the host) plus one Docker volume:
| Path | Contents |
|---|---|
data/inbox.db |
Conversations, messages, contacts, customers, templates, topics, statuses (SQLite, WAL) |
data/sessions.db |
Logged-in agent/manager sessions (survives restarts) |
data/users.json |
Admin, workspace managers, agents (bcrypt password hashes, permissions) |
data/numbers.json |
Connected numbers (id, label, owning workspace) |
data/session-secret.txt |
Cookie-signing secret (auto-generated if unset; back it up) |
data/media/ |
Decrypted inbound media, filename = sha256(message id) |
data/branding/ |
Per-workspace assets |
wa_auth (Docker volume) |
WhatsApp device pairings (.wwebjs_auth) — reconnect without re-scanning |
The masking boundary (a hard rule)
Agents never see a customer's phone number or WhatsApp jid. The jid exists only in the
contacts table and server internals; every agent-facing REST/SSE payload carries a
contactId + masked alias only. Message identifiers, media filenames, URLs, and log
lines are phone-free by construction, and a runtime guard (assertNoLeak) rejects any
payload that would expose a number. The one exception is a manager who is explicitly
granted the Manage directory permission, which reveals the directory (phone numbers).
Workspace isolation
Every tenant table is scoped by merchant_id (the workspace id — the column name is a
historical carry-over), and a contact's identity is (workspace, phone). The same person
messaging two workspaces' numbers is two separate, isolated contacts. No list, report,
customer link, or live update crosses a workspace boundary.
Agent permissions
The registry in permissions.js is the single source of truth. Managers/admins
implicitly hold every permission; only agents carry a stored set. New agents default
to reply / accept / resolve on; everything else is opt-in per agent.
| Group | Capabilities |
|---|---|
| Conversations | reply, accept, resolve (default on) · send_media, monitor, join (take over), override, reassign |
| Customers | link_customer, manage_customers (reveals phone numbers) |
| Reports | view_reports, export_reports |
| Content | manage_templates, manage_topics |
| Numbers | manage_numbers (add/scan/reconnect; number removal stays owner-only) |
Environment variables
Set in .env (copied from .env.example):
| Variable | Required | Purpose |
|---|---|---|
SESSION_SECRET |
Yes | Signs session cookies. Generate with openssl rand -hex 32. |
COMPANY_NAME |
No | Shown in the admin panel title and as the TOTP issuer (default Relay). |
PORT |
No | Internal port the Node app listens on (default 3000; nginx proxies to it). |
The image also sets, and you should not normally change: NODE_ENV=production,
WA_AUTH_BASE (auth session path), and PUPPETEER_CACHE_DIR (the app-owned Chrome
location). An operator can override the browser binary with CHROMIUM_PATH.
Runtime characteristics
- Memory: ~400–800 MB per connected number (its Chromium), plus the Node process. Size the host for the number of connected numbers (see the README requirements table).
- Chrome: version-pinned and built into the image (Chrome-for-Testing 149), so a rebuild can never pull a browser that fails to launch.
- Restarts: WhatsApp pairings persist in the
wa_authvolume and reconnect in 30–60 s without a QR re-scan; agent sessions persist insessions.db.