Skip to main content
@zapo-js/fake-server is an in-process fake WhatsApp Web server that speaks Noise XX/IK, X3DH + Double Ratchet, group SenderKey, app-state sync, and media upload/download over self-signed HTTPS — everything a real conforming client sees on the wire. It exists so you can test your app end-to-end without touching WhatsApp: no phone tied up, no risk to a production account, no flakiness from a live network, CI-friendly.
This guide targets app authors — bots, CRMs, notification services, integrations — regardless of whether the underlying WhatsApp library is zapo-js, Baileys, whatsmeow, or another conforming client. For the library-internal use (cross-check suite, benchmarks) see Dev tools.

Works with any WhatsApp library

The fake server is a wire-level implementation of the WhatsApp protocol. It has no zapo-js dependency at runtime — it speaks the same Noise / Signal / app-state bytes as WhatsApp’s own edges. Strict clients (Baileys forks, whatsmeow, and others) work against it out of the box:
  • The Noise cert chain sets notBefore / notAfter so validity-checking clients accept it as unexpired.
  • The passive-set IQ and the encrypt <count> prekey-count query are answered by default (non-zapo clients block on both).
  • After every login the server sends <ib><offline count="0"/></ib> so buffered-event clients flush.
  • FakePeer message ids are WA-style hex — no @ characters that would tokenize as a JID in a strict decoder.
If your app runs against a real WA edge, it should run against the fake one with only a URL / cert swap.

Install

zapo-js is a peer dependency of the fake server (it imports the underlying Noise / Signal / crypto / proto primitives at runtime), so it needs to be present in the tree even when your app is built on a different WhatsApp library. Node.js >= 20.9.0.

Quick start (programmatic)

The zapo-js wiring below is what a zapo-js app looks like; for Baileys / whatsmeow, keep your existing client setup and only override the socket URL, media proxy, and Noise root CA to point at the fake server.
testHooks.noiseRootCa trusts the fake server’s certificate without bypassing verification — the full cert-chain check still runs.

Pinning across processes

listen() mints a fresh random root CA per start and exposes the public half on server.noiseRootCa — perfect for the in-process test above, where the client is built after the server is listening. A client in a separate process has to pin the trust anchor before it dials, and at that point the server is not yet listening: there’s nothing to read back. Pass a noiseRootCa derived from a shared seed instead, so both sides can compute the same CA ahead of time:
FakeNoiseRootCa carries the signing half — the server needs it to sign its cert chain — while the client only ever holds the public counterpart. It signs a chain no real WhatsApp client trusts, so it is test material rather than a real secret, but a seed committed beside the tests is the intended source, not one shared with anything that matters. Omitting the option keeps the previous behaviour: a fresh random CA per listen().

Simulating a peer

createFakePeer gives you a simulated contact backed by real Signal crypto. Push messages to your client with sendConversation / sendGroupConversation, and capture what your client sends with expectMessage:
The peer performs a real X3DH handshake, ratchets each message forward, and validates MACs — so a bug in your app’s Signal handling surfaces the same way it would against WhatsApp itself.

Asserting on outbound stanzas

For assertions that don’t need a peer’s viewpoint (a <presence> your app sent, a <receipt> it acked, an IQ it made) subscribe to captured stanzas:
onCapturedStanza returns an unsubscribe function; call it in afterEach to keep tests isolated.

Multi-session isolation

Run many app instances against a single server without cross-talk. Provide a sessionKey resolver — the fake server routes each authenticated connection to its own FakeServerSession (isolated peers, groups, prekeys, app-state, captured stanzas):
The resolver runs once per connection, right after authentication, so info.clientPayload is available for keying by login identity. Server-wide handlers (server.registerIqHandler) still apply to every session; session-scoped ones (session.registerIqHandler) stay local.

Mobile primary + companion hosting

To test a mobile-primary session — the client.mobile role, where zapo hosts companions — start the server with the raw TCP listener the mobile transport dials, and seed a registered primary into the store. Mobile registration happens out of band against WhatsApp’s HTTP endpoints, so the fake server can’t run it; seedFakeMobilePrimary writes the credentials directly.
To drive companion linking against this primary, connect a second client as the companion and hand it refs via server.offerCompanionPairing(companionPipeline) — the inverse of runPairing. The primary signs the identity for real; the server just relays:
This exercises the full client.mobile.linkCompanion / linkCompanionByCode handshake — signature, ADV epoch, key-index list republish, companion_host_linked event — end to end.
Pairing by code runs between the two clients with the server in the middle; the fake server relays each stage. parseClientPayload on the pipeline classifies the login as web or mobile and surfaces the phone identity (manufacturer, model, os, app version, phone id) for assertions.

Programmatic config

FakeWaServer.start(options) accepts: onPipeline(listener) fans out to multiple subscribers and returns an unsubscribe function; the pipeline’s parsed clientPayload is exposed on WaFakeConnectionPipeline for identity checks. IQ handlers may return null to fall through to the next matching handler (observe-then-delegate).

Standalone CLI

The package ships a fake-wa-server binary. Run it once the dev dep is installed:
The --pair <jid> mode drives QR pairing by prompting on stdin for the QR payload the client displays — pair once, then reconnect against the same fake server to iterate.

CI recipe

Spin one server per test file (or per suite), tear it down at the end. memory providers on the client keep every test hermetic:
Pair the fake server with in-memory stores on the client. Every test resets on run — no leaked pairing state, no fixture disk juggling.

See also

Dev tools

Benchmarks, cross-check suite, and the MCP dev-server — internal library tooling.

Fake server README

Full CLI reference and API surface.
Last modified on August 15, 2026