Node Bridge & Realtime
Audience: developers running or modifying the bundled Node.js service. The bridge is what makes the Unofficial API engine work, fires every time-based send, and feeds inbound messages back into the team inbox in near-real-time.
What the Bridge Is
The bridge is a standalone service in the node/ folder, with its own dependencies (separate from the front-end build). Its responsibilities:
- Hold one live WhatsApp Web connection per paired number (the Unofficial API engine).
- Expose HTTP endpoints the app calls to send, schedule, react/pin/star, and manage connections.
- Run a scheduler for one-off, recurring, broadcast, and campaign sends — in each recipient's timezone.
- For Cloud API workspaces, make the call to Meta itself (the app hands it the per-number credentials).
- Forward inbound messages, delivery receipts, and connection-status changes back to the app.
It is built on the Unofficial API WhatsApp Web library, plus scheduling, timezone, HTTP, and WebRTC (used for the WhatsApp voice-call feature) libraries.
Folder Layout
node/ ├── index.js startup, settings cache, session restore, scheduler loops ├── package.json the bridge's own dependencies ├── .env its settings (port, URLs, shared token) ├── config/ reads the .env settings ├── routes/ the HTTP routes ├── controllers/ handlers: connections, messages, schedules, broadcasts, │ campaigns, flows, and voice calls ├── services/ broadcast / campaign / flow / schedule / voice-call logic ├── classes/ per-number connection lifecycle + inbound handling ├── utils/ shared helpers (settings fetch, cleanup, send pacing) └── auth/ saved login state, one folder per connected device
Configuration & Environment
The bridge reads its own node/.env:
| Variable | Purpose |
|---|---|
PORT | Port the bridge listens on (e.g. 8888). |
DOMAIN_NAME | The bridge's own base URL. |
APP_DOMAIN_NAME | The app's base URL the bridge calls back to (status, inbound, settings). Also used for Twilio's status callback. |
NODE_WEBHOOK_TOKEN | The shared secret. Must equal the same value in the app's .env. |
On the app side, the bridge URL is taken from the workspace's connection, then the admin Unofficial API Server URL setting, then the SERVER_URL environment value. Set SERVER_URL in the app's .env to point at the bridge:
# app .env SERVER_URL=http://localhost:8888 NODE_WEBHOOK_TOKEN=<same 64-char secret as node/.env>
The two tokens must match. The app rejects any bridge call whose token doesn't match (and the bridge attaches the token to every callback). Rotate it by regenerating the secret and updating both .env files.
Authentication Between the Bridge and the App
The bridge and the app never share a login. All trust flows through the shared token:
- Bridge → app: every callback (status, inbound, schedule status, heartbeat, settings fetch) carries the token. The app compares it and rejects any mismatch.
- App → bridge: the bridge endpoints the app calls (such as cache-bust) check the same token before acting.
- Why it's separate from login: these endpoints sit outside the browser login system on purpose — it stops the bridge's frequent calls from competing for the browser session lock.
Dev fallback: if the token is left unset, the bridge sends no token and the app accepts any caller — convenient for local dev, but always set the token in production.
HTTP Endpoints
The :phone path segment is the sending device's phone number, digits only. Highlights:
Health / status GET / server info + active client count GET /health uptime, ready devices, memory, settings Client lifecycle GET /api/initialize-client/:phone start a socket (triggers QR / pairing) GET /api/client-status/:phone connection state GET /api/terminate-client/:phone soft-disconnect (session preserved) GET /api/check-connection/:phone liveness probe GET /api/get-pairing-code/:phone phone-number pairing code (alt to QR) Messaging (Unofficial API) POST /api/send-message/:phone text (+ buttons/footer) POST /api/send-media-message/:phone media + caption POST /api/send-media-only/:phone media, no caption POST /api/send-location/:phone location POST /api/send-reaction/:phone emoji reaction POST /api/pin-message/:phone pin / unpin POST /api/star-message/:phone star / unstar POST /api/send-contact/:phone contact card POST /api/edit-message/:phone edit a sent message POST /api/delete-message/:phone delete for everyone Scheduling & bulk (node-cron) POST /api/schedule-message/:phone single scheduled send POST /api/schedule-message-bulk/:phone one-off bulk POST /api/schedule-recurring/:phone recurring POST /api/broadcast/... broadcast send + pause/resume/cancel POST /api/wa/:phone/campaign/... campaign send + schedule + lifecycle Flows & voice POST /api/flow/start/:phone start a flow for a contact POST /api/flow/resume-by-phone/... resume a paused flow (WABA order webhook) POST /api/flow/resume-form/:sessionKey resume after a WhatsApp Form submission POST /api/waba-call/answer hand a WABA voice call to the AI bridge Cache control POST /api/cache-bust flush per-phone settings cache (X-Node-Token) GET /api/refresh-settings re-fetch all settings from Laravel
The Per-Number Settings Cache
On every send the bridge needs to know: is this number on Cloud API or the Unofficial API, what is the branding footer, and (for Cloud API/Twilio) the credentials. It fetches this from the app:
GET {APP_DOMAIN_NAME}/api/whatsapp-settings?phone=<digits>
Header: X-Node-Token: <token>
The app finds the matching connection by exact phone number, decrypts it, and returns whether it's on Cloud API, the Cloud API token + phone-number id (or the Twilio credentials), and the footer to append.
The bridge caches this response per workspace + number for 5 minutes. Important properties:
- Keyed per tenant — a phone number alone isn't unique across workspaces, so the cache is keyed by workspace and number. This closes a window where one workspace's token could be served to another on a cache hit.
- Stale-but-valid fallback — if a refresh times out, the last successful response is reused rather than falling back to defaults (which would lose the footer and Cloud API detection).
- Background warm-up — settings are refreshed for every connected number every 5 minutes, plus a warm-up at startup, so chat sends never wait on the app.
- Manual refresh — when an admin changes a Cloud API connection or the branding footer, the app tells the bridge to drop the cached value (for one number, or all) so the change takes effect immediately instead of after 5 minutes.
The Scheduler
Time-based sends are owned entirely by the bridge. The app registers a schedule with it, and the bridge turns it into a recurring timer:
app bridge
register one-off ── POST ──> /api/schedule-message-bulk/:phone
register recurring ── POST ──> /api/schedule-recurring/:phone
pause/resume/cancel ── POST ──> /api/{pause|resume}-schedule/:id
cron expression (built so it fires in the user's timezone):
one-off: "{min} {hour} {date} {month} *"
daily: "{min} {hour} * * *"
weekly: "{min} {hour} * * {days}"
monthly: "{min} {hour} {date} * *"
- Timezone-correct — times are passed with their timezone and the timer is created in the user's timezone, so it fires at the exact wall-clock moment they chose.
- Engine choice respected — the schedule carries the operator's chosen engine, so the bridge sends on that engine rather than guessing (important when the same number is set up for both Unofficial API and Cloud API).
- Survives restarts — timers live in memory, so on startup the bridge asks the app for every pending schedule and re-registers them. Without this, a restart would silently lose them.
- Failure handling — if the bridge is briefly unreachable, the app records the change locally so the UI stays correct and retries on a later pass.
Inbound Messages → the App
Each connection listens for new messages and forwards every relevant event to one endpoint:
POST {APP_DOMAIN_NAME}/api/inbound-message
Header: X-Node-Token: <token>
Three kinds of event flow through this endpoint:
- Inbound customer messages — text, media (capped at 16 MB), location, contact cards, and a forwarded flag. Button/list/quick-reply replies are decoded so the visible text is never empty.
- Replies sent from the phone itself — when the operator replies from their actual phone or another linked device, it is forwarded as an outbound message so the team-inbox thread shows both sides. Duplicates are filtered out by message id.
- Reactions — forwarded as a reaction event, not as a new message bubble.
Two WhatsApp routing subtleties are handled before forwarding:
- Phone-number formatting — the full international number is preserved (an older approach truncated longer 13-digit numbers like Argentina/Brazil and misrouted sends).
- Linked-device IDs — modern WhatsApp routes some chats through linked-device IDs that aren't phone numbers. The bridge recovers the real number and sends both identifiers so the app matches every inbound message to one conversation instead of splitting the thread.
Groups, status updates, and newsletters are filtered out. Forwarding is fire-and-forget — auto-reply, keyword, and flow logic run on the same message independently. The app's response may tell the bridge to start a flow, which it does in the same call.
Status Callbacks & Heartbeat
Beyond messages, the bridge keeps Laravel's view of device + delivery state fresh:
| Callback | Endpoint | Purpose |
|---|---|---|
| Connection status | POST /api/update-status | QR, connecting, connected, disconnected (with progress + QR payload). |
| Heartbeat | POST /api/node-heartbeat | Every 30s, the list of live connections so devices show as recently seen and a silent bridge crash flips them to disconnected. |
| Message status | POST /api/update-message-status | Delivery / read ticks for broadcast + chat messages. |
| Schedule / broadcast / campaign status | POST /api/update-schedule-status, /api/update-broadcast-status, /api/campaigns/update-status | Per-send and per-batch progress. |
Session Persistence & Lifecycle
- Saved login state — one folder per connected device, kept on disk so connections survive restarts.
- Graceful shutdown — on shutdown the bridge cleanly disconnects every connection so pairings survive; they auto-restore on the next start.
- Restore check — on startup, only healthy saved sessions are restored; incomplete ones are reported to the app as Disconnected so the Devices screen is accurate and no QR pops up on every boot. Re-pairing is always an explicit user action.
- Periodic cleanup — stale connection locks, finished flow sessions, and expired cooldowns are swept on their own schedules.
Running it: install dependencies inside the folder (cd node && npm install) and runnode index.js(or under a process manager such as pm2). On startup it loads settings, restores sessions, syncs schedules and campaigns from the app, then starts the refresh and heartbeat loops. The bridge is required — it carries the send path for the Unofficial API and Cloud API, and runs the scheduler and bulk pipeline (broadcasts, campaigns, scheduled, flows) for every engine, including Twilio. Only the paired WhatsApp connection is specific to the Unofficial API; Cloud API and Twilio installs still need the bridge running.
Related Pages
- Architecture Overview — where the bridge sits in the system.
- WhatsApp Engines — the dispatcher side that calls these endpoints.