WaClient takes a WaClientOptions object and an optional logger:
store and sessionId are required; everything else has a sensible default.
Required options
The store instance built by
createStore. Holds every per-session domain (auth, signal, app-state, β¦).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:Browser id advertised during pairing (
'chrome', 'firefox', 'safari', β¦; see WA_BROWSERS). Drives the Linked Devices label.Numeric companion platform id override (
WA_COMPANION_PLATFORM_IDS). Inferred from deviceBrowser when omitted; set explicitly for non-browser platforms.History sync
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.
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.
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().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
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
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)
addons
{ 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
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
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
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
Batches incoming messages before flushing to the
messages / threads / contacts stores.maxPendingKeys?: numbermaxWriteConcurrency?: numberflushTimeoutMs?: number
Proxy
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
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
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.