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