Skip to content

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_auth volume and reconnect in 30–60 s without a QR re-scan; agent sessions persist in sessions.db.