WaClient takes a WaClientOptions object and an optional logger:
store and sessionId are required; everything else has a sensible default.
Required options
WaStore
required
The store instance built by
createStore. Holds every per-session domain (auth, signal, app-state, …).string
required
Logical session identifier — it keys every domain inside
store. Use a stable string per device/account. Changing it between runs orphans the previous credentials and forces re-pairing.Sessions and multi-tenancy
Every store domain is keyed bysessionId, so a single store can hold many independent accounts. To run several accounts in one process, create one WaClient per sessionId over the same store:
Device fingerprint
These control how the device appears under Linked devices on the phone:string
default:"'chrome'"
Browser id advertised during pairing (
'chrome', 'firefox', 'safari', …; see WA_BROWSERS). Drives the Linked Devices label.string
Numeric companion platform id override (
WA_COMPANION_PLATFORM_IDS). Inferred from deviceBrowser when omitted; set explicitly for non-browser platforms.string
Human-readable OS name shown under Linked devices (
'Windows', 'Mac OS', 'Linux', …). Defaults to the current runtime’s OS.string
OS version advertised in
DeviceProps.version ('10', '14.6', …). Defaults to the detected runtime OS version. Set this alongside deviceOsDisplayName when advertising an OS the process is not running on, so the name and version stay a matching pair. Values that are not dotted-numeric leave the field unset — matching how WhatsApp Web itself behaves.For the MCP server the same overrides are available as
MCP_DEVICE_OS_DISPLAY / MCP_DEVICE_OS_VERSION environment variables — when only the version is set the display name is still derived from the host, so pin both to keep the advertised pair consistent.History sync
WaHistorySyncOptions
Controls processing of
historySyncNotification chunks — both the initial bootstrap WhatsApp pushes after pairing and the on-demand backfill triggered by message.requestHistorySync.enabled?: boolean— process incoming history chunks. Defaulttrue. Set tofalseto drop them silently (useful when you don’t persist mailbox/threads/contacts and the conversation download would just burn bandwidth). The lib still acks the chunk so the server stops re-sending it, matching wa-web.requireFullSync?: boolean— request the full archive instead of just recent chats.groupBundles?: boolean— opt into downloading the group-history bundle a member may share after somebody joins a group; emitsgroup_history_bundle. Off by default — a bundle is media a third party pushes at this account unprompted, so fetching it is opt-in. Bundles addressed to other members are dropped either way. See Groups → sharing group history.
conversations, so a large chat that would materialize into hundreds of MB of JS objects stays flat. Group history bundles get the same treatment. There is no consumer-facing knob; peak memory during ingest just stopped tracking chunk size.history_sync_chunk events.
Timeouts
All in milliseconds; defaults are tuned for production.WhatsApp version
zapo ships with a tested production version baked in per transport. WhatsApp occasionally rejects older clients during the noise handshake with HTTP405 / failure_client_too_old. You have three options to recover.
string | () => string | Promise<string>
Override the version string the client advertises. Either a literal or a resolver invoked once per
connect() — useful for fetching the current version lazily without rebuilding the client. The accepted shape depends on the transport resolved for the connect:- Web takes a 3- to 5-part version (
2.3000.x[.y.z]); the 4th and 5th parts, when supplied, are advertised in the noise payload. - Mobile takes exactly a 4-part Android app version (
2.26.x.y); it overridesmobileTransport.deviceInfo.appVersionin the login payload.
connect().boolean
default:"false"
When
true, on failure_client_too_old the client logs a warning, fetches the current version for the active transport (fetchLatestWaWebVersion() for Web, fetchLatestWaMobileVersion() for Mobile), applies it as a one-shot override, and reconnects automatically. On Mobile the override is applied by refreshing deviceInfo.appVersion for the next connect. Treat it as a stopgap until you upgrade zapo — the bundled default is still the recommended path.fetchLatestWaWebVersion()
Scrapes the current client_revision from web.whatsapp.com/sw.js and returns a version string in the 2.3000.x form accepted by version for a Web session.
timeoutMs (default 10s), proxy (undici dispatcher only — http.Agent is not honored by the global fetch), signal, userAgent, headers, and a fetch override for tests. Network and parse errors throw — wrap in try/catch if you want to fall back to the bundled default.
fetchLatestWaMobileVersion()
Scrapes the current WhatsApp for Android version from a public app-listing page and returns a 4-part 2.26.x.y string suitable for version on a Mobile session (or as an override for mobileTransport.deviceInfo.appVersion).
timeoutMs, proxy, signal, userAgent, headers, fetch) plus:
url?: string— override the page to scrape. The default source is a public app-listing mirror because WhatsApp’s ownwhatsapp.com/androidpage only shows the stale minimum-requirement version; retarget if the layout changes or is unreachable from your network.versionPattern?: RegExp— override the extraction regex. Must expose the version in capture group 1. The default matches a 4-part2.x.x.xand returns the first hit on the page.
invalid wa-mobile version parsed from page). Network and parse errors throw — wrap in try/catch if you want to fall back to a known-good hardcoded version.
Presence on connect
boolean
default:"false"
false(default) — announce as unavailable. Matches WhatsApp Web when the tab is not focused, and keeps headless bots invisible by default. With this off, you keep receiving notifications for messages while “offline”.true— announce the client as online (matches WhatsApp Web with the tab focused at login time).
Passkey-gated linking
WaShortcakeAssertionSigner
External WebAuthn signer for the server-forced Shortcake passkey handshake. Called with the raw
PublicKeyCredentialRequestOptions (Uint8Array) the server issued; must return { credentialId, webauthnAssertion }. The credential source (real / virtual authenticator, relay) stays outside the library.Without this, an account that gets a server-forced passkey prologue emits auth_passkey_required with hasSigner: false and the link stalls — see the reverse-engineering deep dive for the wire-level detail.Addons (reactions, poll votes)
{ autoDecrypt?: boolean, persistAllSecrets?: boolean }
default:"{ autoDecrypt: true, persistAllSecrets: false }"
Encrypted addons (poll votes, reactions, message edits, …) are decrypted automatically and emitted as typed
message_addon events. Set autoDecrypt: false to receive them encrypted and decrypt yourself via client.message.tryDecryptAddon(event). The parent message secret is looked up in the messageSecret cache first, then in the messages store.persistAllSecrets: true persists the 32-byte message secret of every sent and received message, not just the poll / event / bot-prompt ones the library knows will get a follow-up. Encrypted addons whose parent can be any message type — reactions, comments, secretEncryptedMessage edits — need the parent’s secret to decrypt; without this flag, those parents stay decryptable across a restart only when the full messages archive is persistent. Use it to keep them decryptable while storing only the secret (messages: 'none').Has no effect when the messageSecret cache is 'none' — every secret write lands in the noop store and is silently discarded. With the default 'memory' provider it works for the lifetime of the process but is lost on restart and bounded by the cache’s LRU and messageSecretMs TTL; point messageSecret at a persistent backend to keep secrets across restarts.Media
WaMediaOptions
Media processing. Pass a
processor (from @zapo-js/media-utils) to generate thumbnails/previews, probe dimensions and durations, and build voice-note waveforms before upload — then toggle each step. Without a processor media still uploads, just without this processing. See the media guide for the full wiring.processor?: WaMediaProcessor— the processor instancegenerateThumbnail?: boolean— image/video preview thumbnailsgenerateProbe?: boolean— probe width/height/durationgenerateWaveform?: boolean— voice-note (PTT) waveformgenerateStickerThumbnail?: booleannormalizeVoiceNote?: boolean— re-encode PTT audio to the format WhatsApp expects
Link previews
WaLinkPreviewOptions
Global configuration for the built-in link-preview fetcher used when sending text that contains a URL. Override per message with the
linkPreview send option.enabled?: boolean— turn automatic link-preview fetching on or off globallyfetchTimeoutMs?: number— how long to wait for the target pageuploadHqThumbnail?: boolean— upload a high-resolution preview thumbnailallowPrivateHosts?: boolean— allow fetching private/loopback addresses (off by default, as an SSRF guard)maxHtmlBytes?: number/maxThumbnailBytes?: number— size caps for the fetched HTML and imageuserAgent?: string— User-Agent sent when fetchingproxy?: WaProxyTransport— proxy just this fetcher (same asproxy.linkPreview)fetcher?: WaLinkPreviewFetcher— replace the default fetcher entirely (e.g. your own scraping pipeline)
Chat events
{ emitSnapshotMutations?: boolean }
Set
emitSnapshotMutations: true to re-emit mutation events for every change seen during an app-state snapshot sync. Off by default, since snapshot mutations represent historical state rather than live changes.Write-behind persistence
WaWriteBehindOptions
Batches incoming messages before flushing to the
messages / threads / contacts stores.maxPendingKeys?: numbermaxWriteConcurrency?: numberflushTimeoutMs?: number
Proxy
WaClientProxyOptions
Route each leg through a proxy independently:
ws— the WebSocket connection.mediaUpload/mediaDownload— media transfers.linkPreview— the default link-preview fetcher.
WaProxyTransport, which is either:
- an undici dispatcher (
WaProxyDispatcher, e.g. an undiciProxyAgent) — used for thefetch-based legs (media, link preview), or - a Node
http/httpsAgent (WaProxyAgent) — used for the WebSocket (ws) leg.
The
ws leg requires the ws package, because the runtime’s native WebSocket cannot accept an HTTP Agent. Without a proxy, no extra package is needed.HTTP / HTTPS proxy
Use an undiciProxyAgent (a dispatcher) for the media/link-preview legs, and an https-proxy-agent (an http.Agent) for the ws leg:
SOCKS proxy
Usesocks-proxy-agent (works as an http.Agent for every leg, including ws):
IPv4 and IPv6 hosts
The proxy host can be a domain or an IP literal. IPv6 addresses must be wrapped in brackets:Logout store clearing
WaLogoutStoreClearOptions
Per-domain control over what
logout() wipes.By default, the mailbox archive (messages, threads, contacts) is preserved so the user keeps their history when re-pairing. Every other domain (credentials, Signal state, app-state, caches, privacy tokens) is cleared to start the next pair clean. Explicit true / false always wins over the default.Logging
WaClient accepts a Logger as the second constructor argument. Omit it and a default ConsoleLogger('info') is used. Levels, lowest to highest: trace, debug, info, warn, error.
Two implementations ship with the package.
ConsoleLogger
Zero-dependency. Writes structured records toconsole.log / console.warn / console.error. Good for development, tests, and serverless functions where you cannot add a logger transport.
createPinoLogger
Async factory that dynamically loadspino (and pino-pretty when pretty: true), configures it, and wraps it in a PinoLogger adapter. Throws optional dependency "pino" is not installed when pino is missing — install with npm i pino pino-pretty.
PinoLogger (bring your own Pino)
If you already configure Pino centrally — child loggers, custom transports, file destinations — constructPinoLogger directly to wrap your existing instance. The factory is a convenience; the class is the actual adapter, and using it skips the dynamic pino import.
new PinoLogger(logger, level = 'info'). The level is forwarded to logger.level and used as the adapter’s reported level.
Custom logger
Need a sink the built-in implementations don’t cover — Datadog, OpenTelemetry, syslog, an internal observability pipeline? Implement theLogger interface and pass an instance to WaClient. The interface is small:
LogLevel is 'trace' | 'debug' | 'info' | 'warn' | 'error'. The library calls the five level methods directly — there is no level-gating layer in front, so your implementation is responsible for filtering against this.level if you want to skip cheap calls.
A minimal example that forwards to an external sink and tracks bindings through child():
child() is used internally to attach per-component bindings (e.g. { component: 'noise' }, { component: 'signal', sessionId }). Returning a new instance with merged bindings — instead of mutating — keeps those tags scoped to the producing subsystem.Plugins
readonly WaClientPluginDefinition[]
Optional
WaClient plugins — behavior hooks and/or coordinators exposed at client[exposeAs]. Authored with defineWaClientPlugin. The voice-calling plugin (@zapo-js/voip) is the reference implementation; see the plugin system page for how to wire and author plugins.Advanced options
Rarely needed — listed for completeness.chatSocketUrls?: readonly string[]— override the WhatsApp chat WebSocket endpoint list (e.g. to route through a fake server in tests, or pin a specific edge).privacyToken?: WaPrivacyTokenOptions— tune trusted-contact-token (TC token) issuance: token durations and bucket counts.testHooks?: WaClientTestHooks— test-only fixtures (e.g. a custom Noise root CA). These do not bypass any security check; to actually skip a check, use thedangerousoptions below.
Dangerous options
dangerous flags each disable a security check the production path enforces (signature verification, app-state MAC checks, …). They exist for testing against a fake server. Never enable them in production.