Skip to main content
WaClient takes a WaClientOptions object and an optional logger:
Only store and sessionId are required; everything else has a sensible default.

Required options

store
WaStore
required
The store instance built by createStore. Holds every per-session domain (auth, signal, app-state, …).
sessionId
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 by sessionId, 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:
Each client pairs and reconnects independently. For the full picture β€” what’s per-session vs shared, the single-writer rule across processes, memory budget, sharding, and shutdown β€” see Multi-session deployments.

Device fingerprint

These control how the device appears under Linked devices on the phone:
deviceBrowser
string
default:"'chrome'"
Browser id advertised during pairing ('chrome', 'firefox', 'safari', …; see WA_BROWSERS). Drives the Linked Devices label.
devicePlatform
string
Numeric companion platform id override (WA_COMPANION_PLATFORM_IDS). Inferred from deviceBrowser when omitted; set explicitly for non-browser platforms.

History sync

history
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. Default true. Set to false to 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 arrives as 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 HTTP 405 / failure_client_too_old. You have three options to recover.
version
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 overrides mobileTransport.deviceInfo.appVersion in the login payload.
An invalid part count for the resolved transport throws on connect().
recoverFromClientTooOld
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.
Options: 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).
Options: everything the Web fetcher accepts (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 own whatsapp.com/android page 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-part 2.x.x.x and returns the first hit on the page.
The parsed string must have exactly four numeric parts; anything else throws (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

markOnlineOnConnect
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

signPasskeyAssertion
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)

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
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 instance
  • generateThumbnail?: boolean β€” image/video preview thumbnails
  • generateProbe?: boolean β€” probe width/height/duration
  • generateWaveform?: boolean β€” voice-note (PTT) waveform
  • generateStickerThumbnail?: boolean
  • normalizeVoiceNote?: boolean β€” re-encode PTT audio to the format WhatsApp expects
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 globally
  • fetchTimeoutMs?: number β€” how long to wait for the target page
  • uploadHqThumbnail?: boolean β€” upload a high-resolution preview thumbnail
  • allowPrivateHosts?: boolean β€” allow fetching private/loopback addresses (off by default, as an SSRF guard)
  • maxHtmlBytes?: number / maxThumbnailBytes?: number β€” size caps for the fetched HTML and image
  • userAgent?: string β€” User-Agent sent when fetching
  • proxy?: WaProxyTransport β€” proxy just this fetcher (same as proxy.linkPreview)
  • fetcher?: WaLinkPreviewFetcher β€” replace the default fetcher entirely (e.g. your own scraping pipeline)

Chat events

chatEvents
{ 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

writeBehind
WaWriteBehindOptions
Batches incoming messages before flushing to the messages / threads / contacts stores.
  • maxPendingKeys?: number
  • maxWriteConcurrency?: number
  • flushTimeoutMs?: number

Proxy

proxy
WaClientProxyOptions
Route each leg through a proxy independently:
  • ws β€” the WebSocket connection.
  • mediaUpload / mediaDownload β€” media transfers.
  • linkPreview β€” the default link-preview fetcher.
Each leg accepts a WaProxyTransport, which is either:
  • an undici dispatcher (WaProxyDispatcher, e.g. an undici ProxyAgent) β€” used for the fetch-based legs (media, link preview), or
  • a Node http/https Agent (WaProxyAgent) β€” used for the WebSocket (ws) leg.
zapo picks the right form per leg automatically.
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 undici ProxyAgent (a dispatcher) for the media/link-preview legs, and an https-proxy-agent (an http.Agent) for the ws leg:

SOCKS proxy

Use socks-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:
Point only the legs you need at a proxy β€” e.g. set just ws to tunnel the connection while letting media transfer directly, or vice-versa.

Logout store clearing

logoutStoreClear
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 to console.log / console.warn / console.error. Good for development, tests, and serverless functions where you cannot add a logger transport.

createPinoLogger

Async factory that dynamically loads pino (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 β€” construct PinoLogger 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.
The signature is 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 the Logger 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

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 the dangerous options 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.
Last modified on July 12, 2026