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:

VariablePurpose
PORTPort the bridge listens on (e.g. 8888).
DOMAIN_NAMEThe bridge's own base URL.
APP_DOMAIN_NAMEThe app's base URL the bridge calls back to (status, inbound, settings). Also used for Twilio's status callback.
NODE_WEBHOOK_TOKENThe 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:

CallbackEndpointPurpose
Connection statusPOST /api/update-statusQR, connecting, connected, disconnected (with progress + QR payload).
HeartbeatPOST /api/node-heartbeatEvery 30s, the list of live connections so devices show as recently seen and a silent bridge crash flips them to disconnected.
Message statusPOST /api/update-message-statusDelivery / read ticks for broadcast + chat messages.
Schedule / broadcast / campaign statusPOST /api/update-schedule-status, /api/update-broadcast-status, /api/campaigns/update-statusPer-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 run node 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

WaDesk Documentation