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. For the inbox it has no external service dependencies — no cloud API, no third-party database, no message broker; the optional SMS feature is the one outbound integration, to an external SMS provider over HTTPS.
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 -->|"HTTPS · optional SMS"| SMS[("External SMS provider<br/>GidenSMS / MobilStor")]
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 |
| Conversation list (client) | public/inbox-order.js, public/inbox-unified-state.js, public/inbox-unified.js |
The single list: ordering and grouping rules and live-update state (pure, unit-tested modules), and the rendering in inbox-unified.js; UNIFIED_INBOX=0 falls back to the tabs |
| Start chat | inbox-outbound.js |
A first message to a directory customer: TR phone normalization, WhatsApp account lookup before sending, one Start chat per customer at a time |
| SMS provider | sms-provider.js, sms-providers/* |
Config-driven SMS to directory customers: a dispatcher plus one driver per provider (GidenSMS, MobilStor) exposing a single send/getCredit contract |
| Admin console + inbox | server.js, public/admin-overview.html, public/admin-inbox.html |
Super-admin cross-workspace health/triage, plus a cross-workspace inbox to read/reply/reassign/resolve any workspace |
| 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.
SMS (outbound to the directory). A manager, or an agent granted send_sms, picks
customers from the directory by id → the server resolves their phone numbers
server-side (never sent to the browser), selects the tenant's SMS config (own → a
specific platform provider → the platform default → env), and calls that provider's HTTP
API through the driver layer → the accepted campaign is recorded in inbox.db. Provider
API keys live server-side only; the browser only ever sees a masked hint.
Admin plane (cross-workspace). The Super-admin has two cross-workspace surfaces. The operations console aggregates health across all workspaces — numbers, queue depth, agents online — as counts and connection state only, with no customer names or message content. The admin inbox is the one place a Super-admin can read and act on any workspace's conversations (masked to directory names / aliases, never phones), with replies attributed to Admin. These are the only sanctioned cross-workspace reads; every agent/manager query stays workspace-scoped.
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, SMS provider configs + campaigns (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).
The added surfaces keep this boundary. SMS resolves phones server-side and never returns a provider API key to the browser (only a masked hint). The Super-admin surfaces stay masked too: the operations console exposes only counts, connection state, and — admin-only — a connected number, and the admin inbox shows directory names / aliases, never a customer's phone.
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. The Super-admin's operations
console and admin inbox are the only sanctioned cross-workspace reads (see Data flow); they
never widen an agent's or manager's tenant scope.
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) |
| SMS | send_sms (text directory customers through an SMS provider) |
Environment variables
Set in .env (copied from .env.example):
| Variable | Required | Purpose |
|---|---|---|
SESSION_SECRET |
No | Signs session cookies. If unset, Relay auto-generates one at data/session-secret.txt and reuses it across restarts; set it (openssl rand -hex 32) to control/rotate it centrally. |
COMPANY_NAME |
No | The TOTP issuer name shown in your team's authenticator app (admins appear as <name> Admin; default Relay). |
UNIFIED_INBOX |
No | Inbox layout for every workspace. Unset = one combined conversation list (default); 0 (or false / off / no) = the Active / Resolved / All tabs. Apply with docker compose up -d app; restart keeps the old value. |
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.