WhatsApp Web CRM Sidecar
Capturing WhatsApp Web conversations into a CRM with no official API
Built a Manifest V3 Chrome extension that syncs WhatsApp Web conversations into a CRM by resolving the app's own internal module stores at runtime, and pages chat history without ever changing the chat a rep is looking at.
None — WhatsApp Business API does not cover reading a live personal Web session
Official API used
Named module lookup first, structural matching as fallback
Store discovery
None — active chat verified unchanged across a full history page
UI impact during sync
Milestone Timeline
Runtime store discovery
Located WhatsApp Web's Chat/Msg/Contact/Conn stores inside a minified webpack bundle by structural shape matching.
Two-tier rewrite
Switched to exact module-id lookup after a structural-typing false positive, keeping shape matching as a degradation path.
Background catch-up
Replaced UI-driven chat switching with direct history paging against the store.
What it does
A sales rep opens WhatsApp Web, and the conversation they are already having becomes a CRM lead — messages, contact identity, and call history synced in the background, without leaving WhatsApp and without the rep clicking anything per chat.
Why it existed
The WhatsApp Business API requires a business-verified number and covers sending on a business line. It does not cover “read my own personal WhatsApp Web session, live.” That is the exact thing reps do all day, so every piece of chat and contact data had to come from reading WhatsApp Web’s own in-browser state instead. There is no supported surface for that, which sets the constraint the rest of the design answers: everything is read-path, everything can break on a WhatsApp deploy, and nothing may disturb the session the rep is working in.
Everything crosses that boundary in one direction. The dashed node is the property that makes this a sidecar rather than an automation bot: there is no path back into WhatsApp, not a message send and not a chat switch.
Decisions and tradeoffs
Structural matching first, then named lookup. WhatsApp Web is a minified webpack bundle with no
public API. The first working approach pushed a fake chunk into WhatsApp’s own webpack runtime,
force-required every already-loaded module, and kept whichever ones duck-typed correctly — an object
carrying _models, modelClass, and a notSpam property is the Chat store.
That approach had a bug worth naming, because it is the kind that passes every test you think to write. The shape match was not unique: the Chat finder was resolving WhatsApp’s Newsletter collection, and the Contact finder was resolving group metadata, purely because they shared the same shape. A structural-typing false positive — right shape, wrong object.
The fix was not a better heuristic. WhatsApp Web exposes a working require(<module-id>) global
directly, even before login, which resolves each store by exact id (WAWebChatCollection,
WAWebConnModel) in one call with no scanning. Discovery became two tiers: named lookup first,
shape matching retained only as the fallback for whatever a future WhatsApp rebuild renames. That
is the difference between a hack that works today and something that degrades on a schedule you do
not control.
Resolve identity through the Contact store, not the chat. WhatsApp’s newer @lid privacy
identity means the “phone number” read straight off a chat is sometimes not a phone number at all.
Numbers get resolved through the Contact store rather than trusted from the chat record.
Disproving the obvious hypothesis about call history. WAWebCallCollection looks correct by
name and is not: it only tracks a call currently in progress, and its history array is empty the
moment a call ends. The real mechanism is that the Calls tab renders ordinary chat messages with
type === "call_log", already in memory for the chat list’s own preview text before the Calls tab
is ever opened. Neither whatsapp-web.js nor open-wa implement an equivalent.
Never touch the driver’s seat. Earlier versions called into the UI to background-sync a chat,
which visibly switched WhatsApp’s active chat mid-sync while the rep might be reading it.
WAWebChatLoadMessages.loadEarlierMsgs() pages history directly against the store with zero UI
interaction — verified live, with Store.Chat.getActive() identical before and after a full page —
and catch-up was rewritten around it. That property is what makes it a sidecar rather than an
automation bot.
Fail closed. Anything reading WhatsApp internals can be wrong after a deploy, so the sync indicator shows nothing rather than guessing. No badge is safer than a badge on the wrong contact.
Bugs closed in production logic
- Same-second dedup. WhatsApp timestamps are per-second, so two messages can share one
t. A “sync everything newer than X” check cannot distinguish already-synced from new-in-the-same-second. Fixed by tracking the specific message ids seen at the sync boundary alongside the timestamp. - Silent data loss in the outbox. A failed auto-sync batch was dropped the moment the next batch arrived. Failed batches now re-queue and drain on the following run.
- A batch size that could wedge sync permanently. The bulk endpoint was assumed to accept 200 messages per call; the server caps at 50. Any account with more backlog than that would resend an oversized request that could never succeed. Fixed the constant and documented why it is not a tunable.
What it demonstrates
Reverse-engineering a moving target and then designing for the fact that it moves — including disproving my own first hypothesis rather than shipping around it. The reusable part was extracted into a standalone package with its own reverse-engineering notes, so the findings outlive the extension.
Scope, stated honestly: this is a read-path CRM sync tool. It never sends WhatsApp messages and is deliberately not a bulk-messaging or anti-ban automation product.
Architecture write-up: WhatsApp Web CRM Sidecar Architecture.