Skip to content

WhatsApp Bridge Architecture

The WhatsApp Bridge service (apps/whatsapp-bridge) is a standalone Node/Express service that links users’ WhatsApp accounts via Baileys and streams events into Wunderkint AI CRM.

There is no browser and no Chromium. Baileys speaks WhatsApp Web’s multi-device WebSocket protocol directly, so each linked user costs tens of megabytes rather than a full Chromium process. Sessions are keyed by the authenticated CRM user id.


apps/whatsapp-bridge/
├── src/
│ ├── index.ts # Express app, auth/CORS middleware, boot restore, maintenance loops
│ ├── config.ts # env parsing (incl. WA_PROXY_URL)
│ ├── session-manager.ts # Map<userId, WASocket>, reconnect, send, groups, media in/out
│ ├── auth-store.ts # Postgres-backed Baileys AuthenticationState
│ ├── session-store.ts # wa_session_map (user_id → jid/phone/status)
│ ├── outbox.ts # durable webhook outbox (retry/backoff)
│ ├── events.ts / webhook.ts # event → signed CRM webhook delivery
│ ├── storage.ts # media/avatar R2 object keys
│ ├── jid.ts # @s.whatsapp.net/@lid → @c.us boundary mapping
│ ├── chat-target.ts # outbound recipient normalization
│ └── routes.ts # HTTP API
├── Dockerfile # node:22-alpine, no Chromium
└── fly.toml # single always-on Fly machine

sequenceDiagram
participant WA as WhatsApp
participant Bridge as apps/whatsapp-bridge (Fly)
participant R2 as Cloudflare R2
participant PG as Neon (bridge DB)
participant Web as apps/web (Next.js/Vercel)
participant DB as Postgres (CRM)
WA->>Bridge: messages.upsert / messaging-history.set
Bridge->>PG: creds.update → save auth keys
Bridge->>R2: upload inbound media (+ thumbnail) / avatar
Bridge->>Web: POST /api/integrations/whatsapp/webhook (x-webhook-secret)
Web->>DB: upsert whatsapp_chats / whatsapp_messages
Web-->>Bridge: 200 OK

Event types pushed to the CRM (POST /api/integrations/whatsapp/webhook):

Event Trigger
message messages.upsert — inbound and from-me echo, with mediaUrl/thumbnailUrl (r2://…).
history messaging-history.set — batched, chunked under the serverless body limit.
message_status delivery/read receipts.
message_reaction reactions added/removed.
message_edit / message_delete edited/revoked messages (deletes also clean R2).
presence typing/subscribe presence (de-duped).
group_update subject/participant changes.
session connection transitions, including needsReauth.

The CRM database is the single source of truth; there is no history-sync polling endpoint.


Auth state is stored in Postgres (a dedicated database), not on disk:

Table Purpose
wa_auth_creds Serialized AuthenticationCreds per CRM user.
wa_auth_keys Per-user Signal keys (sessions, pre-keys, sender keys, LID map).
wa_session_map user_id → jid/phone/status; drives reconnect on boot.
wa_outbox Durable webhook delivery queue (retry/backoff, single-instance drain).

On startup SessionManager.restoreAll() reconnects every paired session, so a redeploy never requires users to re-scan. Keys are read through makeCacheableSignalKeyStore to keep Postgres round-trips low. A maintenance loop recycles aged sessions and watches RSS (WA_SESSION_RECYCLE_MS, WA_MEMORY_LIMIT_MB).


Bearer-token protected when BRIDGE_API_TOKEN is set:

Endpoint Purpose
POST /session/start Create/recover the session for a user.
GET /session/:userId/qr Poll status + QR data URI. 404 = unknown session.
GET /session/:userId/status Session health (known, needsReauth, reason).
POST /session/logout Log out and clear persisted auth state.
POST /message/send Send text and/or media; returns the WhatsApp message id.
POST /chat/:userId/read Mark a chat read (read receipts).
POST /chat/:userId/typing Send a typing/paused presence.
POST /group/create Create a WhatsApp group for deal rooms.
GET /group/:userId/:chatId/metadata Group subject + participants.
POST /media/refetch Re-download media for a recent cached message (backfill).
GET /health Liveness probe + outboxPending depth.

Recipients always arrive as @c.us/@g.us and are converted to @s.whatsapp.net before sending. Inbound JIDs are normalized the other way, with @lid resolved to a phone number via Baileys’ LID mapping.


Inbound media is downloaded at ingest and uploaded to Cloudflare R2; the CRM stores r2://<bucket>/<key> rather than an expiring WhatsApp CDN URL.

  • Inbound: images, video, audio/voice notes, documents, stickers; thumbnails are stored separately. Files over WA_MAX_MEDIA_BYTES are skipped.
  • Outbound: the CRM uploads to R2 via a presigned PUT, then sends the r2:// key; the bridge streams it back out.
  • Avatars: profile pictures are downloaded to whatsapp-avatars/ so they no longer expire.
  • Cleanup: deleting a message or conversation in the CRM deletes its R2 keys; an R2 lifecycle rule expires whatsapp-media/ after 90 days.
  • Backfill: the bridge keeps a bounded cache of recent raw messages (24h, 2 000 entries); POST /media/refetch re-downloads media the CRM missed, and whatsapp.backfillMedia backfills those rows on demand.

WA_PROXY_URL (http(s):// or socks5://) routes all WhatsApp egress through a proxy — set it to a stable Indian egress to reduce geo/ban risk.


  • Names: conversations resolve to a linked lead → contact → organization → deal, then to a phone match (full normalized number preferred over trailing-10). whatsapp.chatLinkCandidates surfaces every match so a teammate can pick and persist the right record.
  • Preview: docx → HTML (mammoth), xlsx → formatted sheets (SheetJS), text/CSV → text, PDF → native browser viewer. Generated previews are cached in R2 as <key>.preview.json.
  • Copilot: POST /api/ai/whatsapp-copilot streams a tool-using assistant over the thread (lookup_crm_records, propose_action, Google Search grounding), with per-(user, chat) memory persisted in the copilot store.
  • Realtime: the webhook bumps a per-user Redis revision; /api/integrations/ whatsapp/stream pushes change events so the inbox and open thread refresh without polling.

Deployed to Fly.io as exactly one machine (auto_stop_machines = false, min_machines_running = 1) and enforced by a Postgres advisory lock. Sessions are in-process and each linked user holds a live socket, so the service must not scale horizontally without a per-user routing layer. See docs/deployment/whatsapp-bridge-fly.md.