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.
1. Service structure
Section titled “1. Service structure”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 machine2. Event lifecycle
Section titled “2. Event lifecycle”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 OKEvent 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.
3. Session storage
Section titled “3. Session storage”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).
4. HTTP API
Section titled “4. HTTP API”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.
5. Media pipeline (R2)
Section titled “5. Media pipeline (R2)”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_BYTESare 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/refetchre-downloads media the CRM missed, andwhatsapp.backfillMediabackfills 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.
6. CRM-side behaviour
Section titled “6. CRM-side behaviour”- Names: conversations resolve to a linked lead → contact → organization →
deal, then to a phone match (full normalized number preferred over trailing-10).
whatsapp.chatLinkCandidatessurfaces 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-copilotstreams 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/streampusheschangeevents so the inbox and open thread refresh without polling.
7. Hosting
Section titled “7. Hosting”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.
