# Architecture Source: https://zapo.to/en/concepts/architecture How the WaClient, coordinators, stores, transport, and event flow fit together inside zapo, and how data moves from WhatsApp into your code. `zapo` is organized around a thin **client** that delegates every feature to a focused **coordinator**. The client owns the connection, authentication, and event emitter; coordinators own the domain logic. ## The client [`WaClient`](/en/reference/client) is the single entry point. You construct it with options and an optional logger, then call `connect()`: ```ts theme={null} const client = new WaClient({ store, sessionId: 'default' }, logger) await client.connect() ``` The client itself exposes only a small surface: connection lifecycle (`connect`, `disconnect`, `logout`), state queries (`getState`, `getCredentials`), and the typed event emitter (`on`, `once`, `off`). Everything else lives behind a coordinator getter. ## Coordinators Each coordinator is reached through a getter on the client. They are lazily wired at construction and are safe to hold references to. | Getter | Coordinator | Responsibility | | ---------------------- | ------------------------------- | ----------------------------------------------- | | `client.auth` | `WaAuthClient` | Pairing, credentials, registration state | | `client.message` | `WaMessageCoordinator` | Send/receive, receipts, media download, addons | | `client.presence` | `WaPresenceCoordinator` | Own/peer presence and chat-state | | `client.chat` | `WaAppStateMutationCoordinator` | Chat settings: mute, pin, archive, read, delete | | `client.group` | `WaGroupCoordinator` | Groups and communities | | `client.newsletter` | `WaNewsletterCoordinator` | Channels: create, send, follow, admin | | `client.status` | `WaStatusCoordinator` | Status broadcast send and reactions | | `client.broadcastList` | `WaBroadcastListCoordinator` | Broadcast list management and sends | | `client.privacy` | `WaPrivacyCoordinator` | Privacy categories, blocklist | | `client.profile` | `WaProfileCoordinator` | Profile picture, status text, username | | `client.business` | `WaBusinessCoordinator` | Business profile, verified names | | `client.bot` | `WaBotCoordinator` | Bot profiles and prompts (Meta AI and others) | | `client.email` | `WaEmailCoordinator` | Bind/verify email on the account | | `client.lowlevel` | `WaLowLevelCoordinator` | Raw node send/query escape hatch | Because the coordinator types are exported from the package root, you can annotate them in TypeScript: ```ts theme={null} import type { WaGroupCoordinator } from 'zapo-js' const groups: WaGroupCoordinator = client.group ``` ## Data flow ```mermaid theme={null} flowchart TD S["WhatsApp Web servers"] T["Transport · binary node decode"] P["Parsers / normalizers · src/client/events"] C["Coordinators · emit typed events"] L["Your listeners"] D[("Store · auth, signal, app-state, messages …")] S -->|Noise-encrypted frames| T --> P --> C --> L C --> D ``` * **Incoming**: frames are decoded into binary nodes, parsed and normalized into typed event payloads, then emitted (`message`, `receipt`, `group`, …). * **Outgoing**: your call to a coordinator (e.g. `client.message.send`) is built into a protocol node, encrypted, and written to the socket; the coordinator resolves once the server acks. ## Engineering conventions If you read the source, these conventions are pervasive and explain a lot of the API shape: * **`Uint8Array` everywhere** for binary data (`Buffer` is avoided), with zero-copy views in hot paths. * **Named exports only** — there are no default exports. * **No enums** — constants use `Object.freeze({ ... } as const)`, surfaced as the `WA_*` objects. * **Bounded in-memory structures** to prevent unbounded growth in long-lived processes. ## Next Pairing with QR or an 8-character code, and credential lifecycle. The full event map and how to listen. Providers, domains, and backends. Every `WaClientOptions` field explained. # Authentication Source: https://zapo.to/en/concepts/authentication Pair a device with a QR code or 8-character pairing code, persist Noise credentials across restarts, and cleanly log out of a WhatsApp session. `zapo` connects as a **companion device** — exactly like linking WhatsApp Web or Desktop. The first connection pairs the device; after that, credentials stored in your [store](/en/concepts/stores) are reused automatically. ## The pairing flow Pairing is driven entirely through events emitted during `connect()`: ```mermaid theme={null} sequenceDiagram participant App as Your app participant C as WaClient participant WA as WhatsApp App->>C: connect() C->>WA: open + Noise handshake WA-->>C: pairing required C-->>App: auth_qr (or auth_pairing_code) Note over App,WA: user scans the QR / enters the code on their phone WA-->>C: paired C-->>App: auth_paired · credentials persisted ``` | Event | Payload | When | | ----------------------- | ------------------------------------ | ---------------------------------------------------------- | | `auth_qr` | `{ qr: string, ttlMs: number }` | A new QR is available to render. Re-emitted as it rotates. | | `auth_pairing_code` | `{ code: string }` | An 8-character pairing code was issued (code flow). | | `auth_pairing_required` | `{ forceManual: boolean }` | The session needs pairing input. | | `auth_paired` | `{ credentials: WaAuthCredentials }` | Pairing succeeded; credentials are now persisted. | Once `auth_paired` fires, the credentials are written to the store and reused on every subsequent `connect()` — you will not see `auth_qr` again unless the session is unlinked or cleared. Paired. Credentials now live in your store — restart the process and `connect()` resumes the session with no new QR. ## Pairing with a QR code This is the default flow. Render the `qr` string as a QR image and scan it from **WhatsApp → Linked devices → Link a device**. ```ts theme={null} import qrcode from 'qrcode-terminal' client.on('auth_qr', ({ qr, ttlMs }) => { qrcode.generate(qr, { small: true }) console.log(`QR valid for ${ttlMs}ms`) }) client.on('auth_paired', ({ credentials }) => { console.log('Paired as', credentials.meJid) }) await client.connect() ``` The QR rotates automatically; `auth_qr` fires again with a fresh value each time, so always render the latest one. ## Pairing with a code Prefer entering an 8-character code on the phone instead of scanning? Request one through `client.auth` after the connection is established. Listen for `auth_pairing_required`, then request the code for the target phone number (digits only, with country code): ```ts theme={null} client.on('auth_pairing_required', async () => { const code = await client.auth.requestPairingCode('5511999999999') // Format for display, e.g. "ABCD-1234" console.log('Enter on your phone:', code.match(/.{1,4}/g)?.join('-')) }) client.once('auth_paired', () => console.log('Paired!')) await client.connect() ``` `requestPairingCode(phoneNumber, shouldShowPushNotification?, customCode?)` requires an active connection and returns the code as a string. On the phone, open **Linked devices → Link with phone number instead**. ## Credentials After pairing, the current credentials are available synchronously: ```ts theme={null} const credentials = client.getCredentials() // WaAuthCredentials | null console.log(credentials?.meJid) ``` `WaAuthCredentials` contains the device's secret keys. It is marked `@sensitive` for a reason: anything that can read these can impersonate the device. If you persist them outside the built-in store, encrypt them at rest. ## Logging out `logout()` unpairs the companion device server-side (it removes this device from the account's linked devices). It requires an authenticated session: ```ts theme={null} await client.logout() ``` By default this also clears stored state. You can control exactly which store domains are wiped on logout via the `logoutStoreClear` option — see [Configuration](/en/concepts/configuration#logout-store-clearing). ## Disconnect vs. logout | | `disconnect()` | `logout()` | | ------------------ | -------------------------------------------- | ------------------------- | | Closes the socket | Yes | Yes | | Keeps credentials | **Yes** — reconnect later without re-pairing | No — device is unlinked | | Server-side effect | None | Removes the linked device | Use `disconnect()` for a graceful shutdown you intend to resume; use `logout()` to permanently unlink. ## Next Where credentials and Signal state are persisted. Handle `connection: close` and reconnect. # Configuration Source: https://zapo.to/en/concepts/configuration Configure WaClient: sessions, timeouts, history sync, presence on connect, addons, proxy, logging, logout cleanup, and production knobs. `WaClient` takes a `WaClientOptions` object and an optional logger: ```ts theme={null} const client = new WaClient(options, logger) ``` Only `store` and `sessionId` are required; everything else has a sensible default. ## Required options The store instance built by [`createStore`](/en/concepts/stores). 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 Because every store domain is keyed by `sessionId`, a single store can hold many independent accounts. To run several accounts in one process, create one `WaClient` per `sessionId` over the same store: ```ts theme={null} const store = createStore({ /* ... */ }) const accountA = new WaClient({ store, sessionId: 'account-a' }, logger) const accountB = new WaClient({ store, sessionId: 'account-b' }, logger) await Promise.all([accountA.connect(), accountB.connect()]) ``` Each client pairs and reconnects independently. This is the foundation for multi-tenant deployments. ## 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. ```ts theme={null} new WaClient({ store, sessionId: 'default', deviceBrowser: 'Chrome' }, logger) ``` ## History sync Controls processing of `historySyncNotification` chunks — both the initial bootstrap WhatsApp pushes after pairing and the on-demand backfill triggered by [`message.requestHistorySync`](/en/guides/receiving-messages#requesting-older-history). * `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). * `requireFullSync?: boolean` — request the full archive instead of just recent chats. ```ts theme={null} new WaClient({ store, sessionId: 'default', history: { enabled: true, requireFullSync: true } }, logger) ``` History arrives as [`history_sync_chunk`](/en/concepts/events) events. ## Timeouts All in milliseconds; defaults are tuned for production. | Option | Purpose | | -------------------------------- | ----------------------------------------------------------------- | | `iqTimeoutMs` | Default timeout for IQ queries (default 60s). | | `nodeQueryTimeoutMs` | Default timeout for raw node `query()` calls. | | `keepAliveIntervalMs` | Interval between keep-alive ping IQs. | | `deadSocketTimeoutMs` | How long without a reply before the socket is considered dead. | | `mediaTimeoutMs` | Media upload/download timeout. | | `appStateSyncTimeoutMs` | App-state sync round timeout. | | `messageAckTimeoutMs` | How long `message.send` waits for the server `` per attempt. | | `messageMaxAttempts` | Max attempts for a single `message.send`. | | `messageRetryDelayMs` | Delay between message-send retries. | | `signalFetchKeyBundlesTimeoutMs` | Timeout for Signal prekey-bundle fetches. | ## WhatsApp Web version zapo ships with a tested production WA Web version baked in. WhatsApp occasionally rejects older clients during the noise handshake with HTTP `405` / `failure_client_too_old`. You have three options to recover. Override the version string the client advertises (`'x.y.z'`). Either a literal or a resolver invoked **once per `connect()`** — useful for fetching the current version lazily without rebuilding the client. When `true`, on `failure_client_too_old` the client logs a warning, fetches the current WA Web version via [`fetchLatestWaWebVersion()`](#fetchlatestwawebversion), applies it as a one-shot override, and reconnects automatically. Treat it as a stopgap until you upgrade zapo — the bundled default is still the recommended path. ```ts theme={null} import { WaClient, fetchLatestWaWebVersion } from 'zapo-js' // Pin a specific version new WaClient({ store, sessionId: 'default', version: '2.3000.1027421623' }, logger) // Resolve lazily on each connect() new WaClient({ store, sessionId: 'default', version: async () => (await fetchLatestWaWebVersion()).version }, logger) // Auto-recover from HTTP 405 once new WaClient({ store, sessionId: 'default', recoverFromClientTooOld: true }, logger) ``` ### `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`. ```ts theme={null} import { fetchLatestWaWebVersion } from 'zapo-js' const { version, parts } = await fetchLatestWaWebVersion({ timeoutMs: 10_000, // Route through the same dispatcher you use for media / link-preview proxy: dispatcher }) ``` 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. ## 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). ## Addons (reactions, poll votes) Encrypted [addons](/en/reference/glossary#addon) (poll votes, reactions, message edits, …) are decrypted automatically and emitted as typed [`message_addon`](/en/guides/receiving-messages#addons) events. Set `autoDecrypt: false` to receive them encrypted and decrypt yourself via `client.message.tryDecryptAddon(event)`. When auto-decrypt is on, the `messageSecret` cache must be a real store — the in-tree memory provider is the default, while `'none'` defeats the cache and the client logs a warning at startup. ## Media Media processing. Pass a `processor` (from [`@zapo-js/media-utils`](/en/installation#sending-media)) 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](/en/guides/media#media-processing) 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 ## 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](/en/guides/sending-messages#send-options-reference). * `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`](#proxy)) * `fetcher?: WaLinkPreviewFetcher` — replace the default fetcher entirely (e.g. your own scraping pipeline) ## Chat events Set `emitSnapshotMutations: true` to re-emit [`mutation`](/en/concepts/events) 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?: number` * `maxWriteConcurrency?: number` * `flushTimeoutMs?: number` ## Proxy 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`](/en/installation#optional-peer-dependencies) 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: ```ts theme={null} import { ProxyAgent } from 'undici' import { HttpsProxyAgent } from 'https-proxy-agent' const url = 'http://user:pass@proxy.example.com:8080' // or https://… const dispatcher = new ProxyAgent(url) const wsAgent = new HttpsProxyAgent(url) const client = new WaClient({ store, sessionId: 'default', proxy: { ws: wsAgent, mediaUpload: dispatcher, mediaDownload: dispatcher, linkPreview: dispatcher } }, logger) ``` ### SOCKS proxy Use `socks-proxy-agent` (works as an `http.Agent` for every leg, including `ws`): ```ts theme={null} import { SocksProxyAgent } from 'socks-proxy-agent' // socks5 (or socks4) — host can be a domain or an IP const agent = new SocksProxyAgent('socks5://user:pass@127.0.0.1:1080') const client = new WaClient({ store, sessionId: 'default', proxy: { ws: agent, mediaUpload: agent, mediaDownload: agent, linkPreview: agent } }, logger) ``` ### IPv4 and IPv6 hosts The proxy host can be a domain or an IP literal. **IPv6 addresses must be wrapped in brackets**: ```ts theme={null} // IPv4 new ProxyAgent('http://203.0.113.10:8080') new SocksProxyAgent('socks5://203.0.113.10:1080') // IPv6 — bracket the address new ProxyAgent('http://[2001:db8::1]:8080') new SocksProxyAgent('socks5://[2001:db8::1]:1080') // With credentials new ProxyAgent('http://user:pass@[2001:db8::1]:8080') ``` 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 Per-domain control over what [`logout()`](/en/concepts/authentication#logging-out) 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. ```ts theme={null} // Preserve everything except auth (re-pair without touching state) logoutStoreClear: { signal: false, appState: false } // Wipe the mailbox too (full reset) logoutStoreClear: { messages: true, threads: true, contacts: true } ```

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. ```ts theme={null} import { ConsoleLogger } from 'zapo-js' const client = new WaClient(options, new ConsoleLogger('info')) ``` ### createPinoLogger Async factory that dynamically loads [`pino`](https://github.com/pinojs/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`. ```ts theme={null} import { createPinoLogger } from 'zapo-js' const logger = await createPinoLogger({ level: 'info', pretty: true }) const client = new WaClient(options, logger) ``` | Field | Type | Description | | --------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `level` | `LogLevel` | Minimum level to emit. Default `'info'`. | | `name` | `string` | Pino instance name attached to every record. | | `base` | `Record \| null` | Base bindings merged into every record. Pass `null` to drop pino's default `pid`/`hostname`. | | `pinoOptions` | `Record` | Passthrough into `pino()` for anything not surfaced above (redaction, custom serializers, …). | | `pretty` | `boolean` | When `true`, wires `pino-pretty` as the transport. Keep at `false` (default) in production to emit JSON lines. | | `prettyOptions` | `PinoPrettyOptions` | Forwarded into the `pino-pretty` transport — see the [`pino-pretty` options](https://github.com/pinojs/pino-pretty#options). | ### 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. ```ts theme={null} import pino from 'pino' import { PinoLogger } from 'zapo-js' const root = pino({ name: 'my-app', transport: { /* ... */ } }) const child = root.child({ component: 'whatsapp' }) const client = new WaClient(options, new PinoLogger(child, 'info')) ``` The signature is `new PinoLogger(logger, level = 'info')`. The level is forwarded to `logger.level` and used as the adapter's reported `level`. ## 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.** # Events Source: https://zapo.to/en/concepts/events Reference for every WaClient event you can subscribe to: messages, receipts, groups, history sync, app-state mutations, and failures. `WaClient` is a strongly-typed event emitter. Every incoming activity — messages, receipts, group changes, presence — is surfaced as an event with a typed payload. ## Listening ```ts theme={null} import type { WaIncomingMessageEvent } from 'zapo-js' client.on('message', (event: WaIncomingMessageEvent) => { console.log(event.key.remoteJid, event.message) }) client.once('auth_paired', ({ credentials }) => { console.log('paired', credentials.meJid) }) const handler = (e) => { /* ... */ } client.on('receipt', handler) client.off('receipt', handler) // stop listening ``` `on`, `once`, and `off` are all type-checked against the event map — the payload type is inferred from the event name, so listeners get full autocomplete. ## Auth & connection | Event | Payload | Description | | ----------------------- | ------------------- | --------------------------------------- | | `auth_qr` | `{ qr, ttlMs }` | A QR code to render for pairing. | | `auth_pairing_code` | `{ code }` | An 8-character pairing code was issued. | | `auth_pairing_required` | `{ forceManual }` | The session needs pairing input. | | `auth_paired` | `{ credentials }` | Pairing succeeded. | | `connection` | `WaConnectionEvent` | Socket opened or closed (see below). | The `connection` event is a discriminated union on `status`: ```ts theme={null} client.on('connection', (event) => { if (event.status === 'open') { console.log('online; new login?', event.isNewLogin) } else { console.log('closed:', event.reason, 'logout?', event.isLogout) } }) ``` See [Reconnection](/en/guides/reconnection) for the handling pattern. ## Messages | Event | Payload | Description | | ------------------- | -------------------------------- | --------------------------------------------------- | | `message` | `WaIncomingMessageEvent` | An incoming (or self-sent) message. | | `message_addon` | `WaIncomingAddonEvent` | Reactions, poll votes, comments (decrypted addons). | | `message_protocol` | `WaIncomingProtocolMessageEvent` | Protocol messages (edits, revokes, …). | | `message_bot_chunk` | `WaIncomingBotChunkEvent` | Streamed bot response chunks. | | `receipt` | `WaIncomingReceiptEvent` | Delivery / read / played receipts. | See [Receiving messages](/en/guides/receiving-messages) for payload details and text extraction. ## Presence & chat-state | Event | Payload | Description | | ----------- | -------------------------- | ----------------------------------------------------- | | `presence` | `WaIncomingPresenceEvent` | A contact's presence changed (available / last-seen). | | `chatstate` | `WaIncomingChatstateEvent` | Typing / recording / paused. | | `call` | `WaIncomingCallEvent` | Incoming call signaling. | ## Groups, newsletters & profiles | Event | Payload | Description | | --------------------------- | ---------------------------------------- | ---------------------------------------------------- | | `group` | `WaGroupEvent` | Group create/subject/participant/setting changes. | | `newsletter` | `WaIncomingNewsletterEvent` | Newsletter activity. | | `newsletter_message_update` | `WaIncomingNewsletterMessageUpdateEvent` | Edits/reactions/poll updates on newsletter messages. | | `business` | `WaBusinessEvent` | Business profile changes. | | `picture` | `WaPictureEvent` | Profile/group picture changes. | ## State, history & MEX | Event | Payload | Description | | -------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `mutation` | `WaAppStateMutationEvent` | App-state mutation (mute, pin, archive, …) synced from another device. | | `history_sync_chunk` | `WaHistorySyncChunkEvent` | A chunk of synced message history (initial bootstrap or `message.requestHistorySync` backfill). Skipped only when `history.enabled` is explicitly `false`. | | `offline_resume` | `WaOfflineResumeEvent` | Progress of the post-connect offline-message drain. | | `mex_notification` | `WaMexNotificationEvent` | MEX (GraphQL) notifications: username, status, LID changes, capping. | ## Failures | Event | Payload | Description | | ---------------- | ---------------------------- | -------------------------------------------------- | | `stream_failure` | `WaIncomingFailureEvent` | A stream-level failure (may precede a disconnect). | | `stanza_error` | `WaIncomingErrorStanzaEvent` | An error stanza from the server. | ## Debug events A family of `debug_*` events expose low-level internals — raw frames, decoded nodes, decode errors, unhandled stanzas, and client errors. They are useful for protocol debugging but noisy; subscribe selectively. ```ts theme={null} client.on('debug_transport_node_in', ({ node }) => console.dir(node, { depth: null })) client.on('debug_client_error', ({ error }) => console.error(error)) ``` Mobile-registration events (`mobile_registration_code`, `mobile_account_takeover_notice`) exist for the mobile-registration path and are not part of the standard companion flow. # Identities: phone numbers & LID Source: https://zapo.to/en/concepts/identities How WhatsApp's phone-number JIDs (PN) and privacy LIDs differ, why both exist in multi-device, and how zapo maps and resolves between them. WhatsApp addresses users two ways, and `zapo` surfaces both. Understanding the distinction matters as soon as you touch groups, because WhatsApp is migrating group identities to **LID** for privacy. ## PN vs LID | | Phone-number JID (PN) | LID | | ------------------------ | ------------------------------ | ----------------- | | Form | `5511999999999@s.whatsapp.net` | `@lid` | | Server suffix | `@s.whatsapp.net` | `@lid` | | Reveals the phone number | Yes | **No** | | Detect with | `!isLidJid(jid)` | `isLidJid(jid)` | A **LID** ("linked identity") is a stable, opaque identifier that represents a user **without exposing their phone number**. WhatsApp increasingly uses LIDs in groups and communities so members can interact without sharing their number. ```ts theme={null} import { isLidJid } from 'zapo-js' isLidJid('5511999999999@s.whatsapp.net') // false (PN) isLidJid('199998888777@lid') // true (LID) ``` ## Addressing mode When sending into a **group**, the message is addressed to participants either by PN or by LID — the `addressingMode: 'pn' | 'lid'`. `zapo` resolves this automatically: it scans the group participants and uses **`lid`** if *any* participant is a LID, otherwise **`pn`**. The server can confirm or override the choice, which is reflected back in the publish result: ```ts theme={null} const result = await client.message.send(groupJid, 'hi') console.log(result.ack.addressingMode) // 'pn' | 'lid' ``` You normally don't set this yourself — it's derived from the group's membership. ## What you get on an incoming message `WaIncomingMessageEvent.key` carries both identifiers when the server provides them. In groups the **sender** is `key.participant`; in 1:1 chats it is `key.remoteJid`. Each gets a parallel `*Alt` field with the alternate addressing. | Field | Meaning | | -------------------- | -------------------------------------------------------------------------------------------- | | `key.remoteJid` | The chat JID (group, 1:1 peer, broadcast, newsletter). In 1:1 chats this is also the sender. | | `key.remoteJidAlt` | The alternate form of `remoteJid` (PN ↔ LID) in 1:1 chats, when the server shares it. | | `key.participant` | The sender's JID in groups / broadcasts (the addressing mode matches the chat). | | `key.participantAlt` | The alternate form of `participant` in groups, when the server shares it. | | `key.recipientJid` | Your receiving JID. | | `key.recipientAlt` | The alternate form of the recipient (when available). | ```ts theme={null} client.on('message', (event) => { const sender = event.key.participant ?? event.key.remoteJid const senderAlt = event.key.participantAlt ?? event.key.remoteJidAlt console.log('primary:', sender) // e.g. 1999...@lid in a LID group console.log('alt: ', senderAlt) // e.g. 5511...@s.whatsapp.net }) ``` So in a LID-addressed group you'll typically see `key.participant` as a `@lid` and `key.participantAlt` as the phone JID (if the server shares it). ## Replying — which JID to use `client.message.send` accepts **either** a PN or a LID JID and normalizes the target for you, so you rarely have to convert: ```ts theme={null} // All valid: await client.message.send('5511999999999', 'hi') // digits → PN JID await client.message.send('5511999999999@s.whatsapp.net', 'hi') // PN JID await client.message.send('199998888777@lid', 'hi') // LID JID ``` **Always prefer sending by LID when you have one.** The LID is the privacy-preserving, forward-compatible identity WhatsApp is migrating to — addressing a peer by LID is the future-proof choice and avoids leaking/relying on phone numbers. Fall back to the PN only when no LID is available. Get the LID from the incoming event's `key.participantAlt` / `key.remoteJidAlt` (when the primary is a PN) or resolve it with [`getLidsByPhoneNumbers`](#mapping-a-phone-number-to-its-lid). **In a group, always reply to `event.key.remoteJid`** (the group JID), not to a participant's JID. For 1:1 chats, prefer the peer's LID; `event.key.remoteJid` also works whether it is a PN or a LID. ## Mapping a phone number to its LID To resolve LIDs for a set of phone numbers, use the profile coordinator: ```ts theme={null} const results = await client.profile.getLidsByPhoneNumbers([ '5511999999999', '5511888888888' ]) for (const r of results) { // SignalLidSyncResult: { phoneJid, lidJid, exists } console.log(r.phoneJid, '→', r.lidJid, '(exists:', r.exists, ')') } ``` `lidJid` is `null` when the server has no LID mapping for that number. ## LID changes A user's LID can change (server-side, for privacy). When it does, you receive a `mex_notification` of kind `lid_change`: ```ts theme={null} client.on('mex_notification', (event) => { if (event.kind === 'lid_change') { console.log('LID changed:', event.oldLidJid, '→', event.newLidJid) // WaMexLidChangeEvent } }) ``` `zapo` handles the underlying Signal-session bookkeeping; this event is for your own caches/bookkeeping. ## Where PN ↔ LID linkage is stored Several app-state schemas track the relationship and sync it across your devices (see [chat mutations](/en/reference/chat-mutations#contacts)): | Schema | Role | | -------------- | ---------------------------------------------------------------------- | | `LidContact` | Contact profile (name/username) keyed by a LID. | | `PnForLidChat` | Remembers the phone JID for a chat that is primarily addressed by LID. | | `ShareOwnPn` | Whether your own phone number is shared in a given context. | ## Signal sessions Signal sessions are keyed by the canonical JID (PN or LID) plus device id. `zapo` canonicalizes hosted server variants (`hosted.lid` → `lid`, hosted → `s.whatsapp.net`) before lookups, and maintains sessions for both addressing forms. If you ever need to force a fresh session sync for a peer, use: ```ts theme={null} await client.message.syncSignalSession(jid) ``` # Architecture in depth Source: https://zapo.to/en/concepts/internals How zapo handles Noise handshakes, Signal sessions, prekey rotation, sender keys, app-state mutations, and the write-behind store internally. This page goes deeper than the [architecture overview](/en/concepts/architecture) — into the subsystems and data flows inside `zapo`. It's aimed at contributors and anyone debugging at the protocol level. For the protocol itself, see [The WhatsApp protocol](/en/concepts/protocol). ## Module map | Directory | Responsibility | | ---------------- | ------------------------------------------------------------------------ | | `src/client/` | Client orchestration, coordinators, connection lifecycle, event routing. | | `src/transport/` | Socket, Noise handshake, binary node codec, node orchestration. | | `src/signal/` | Signal sessions, ratchet (1:1), sender keys (groups), key generation. | | `src/crypto/` | Primitives: AES-GCM/CBC/CTR, SHA, HMAC, HKDF, Curve25519/Ed25519. | | `src/message/` | Outgoing build/encode + incoming parse/decrypt pipeline. | | `src/appstate/` | App-state sync engine (mutations, snapshots, crypto, MACs). | | `src/store/` | Store contracts + memory providers; persistence boundary. | | `src/auth/` | Pairing/QR/credential flows. | | `src/media/` | Media upload/download/encryption. | | `src/retry/` | Retry tracking for failed sends/decryptions. | | `src/protocol/` | Constants (node tags, IQ types, xmlns) and JID helpers. | | `src/infra/` | Logging, bounded collections, locks, perf utilities. | | `src/util/` | Byte/async/primitive helpers. | ## The stack ```mermaid theme={null} flowchart TD A["WaClient · thin EventEmitter · coordinator getters"] B["WaMessageDispatchCoordinator
build · encrypt · fanout · ack/retry"] C["Signal · SignalProtocol / SenderKeyManager
msg · pkmsg · skmsg"] D["WaNodeOrchestrator · IQ id matching"] E["WaNodeTransport · BinaryNode to wire frame"] F["Binary codec · encoder / decoder / tokens"] G["WaComms + Noise · encrypted frames"] H["WaWebSocket / WaMobileTcpSocket"] A --> B --> C --> D --> E --> F --> G --> H ``` Stores cut across every layer; the connection manager and keep-alive sit beside the transport. ## Connection lifecycle `WaConnectionManager` (in `src/client/connection/`) drives connect/disconnect: 1. `WaComms` opens the socket (`WaWebSocket` or `WaMobileTcpSocket`) and runs the **Noise** handshake (`src/transport/noise/`), authenticating the server and deriving session keys. 2. `WaNodeTransport.bindComms()` attaches the binary codec to the encrypted socket. 3. `WaKeepAlive` (`src/transport/keepalive/`) starts periodic ping IQs to detect a dead socket and estimate server clock skew. 4. The client runs post-connect passive tasks (history sync, offline-message drain) and emits [`connection`](/en/concepts/events#auth--connection). `zapo` deliberately does **not** auto-reconnect — that policy belongs to your app (see [Reconnection](/en/guides/reconnection)). ## Outgoing message pipeline ```mermaid theme={null} sequenceDiagram participant App as Your code participant Disp as MessageDispatch participant Sig as Signal participant Net as NodeOrchestrator participant WA as WhatsApp App->>Disp: message.send(to, content) Disp->>Disp: build Proto.IMessage · upload media Disp->>Sig: encrypt per recipient device Sig-->>Disp: msg / pkmsg / skmsg Disp->>Net: send message stanza Net->>WA: encrypted frame WA-->>Net: ack Net-->>App: WaMessagePublishResult ``` `client.message.send` → `WaMessageDispatchCoordinator`: 1. **Build** — content (the [send union](/en/reference/message-types)) is built into a `Proto.IMessage`, uploading media if needed. 2. **Resolve devices** — the recipient's device list is resolved (fanout, `src/client/messaging/`), fetching prekey bundles for devices without a session. 3. **Encrypt** — per device: 1:1 via the Signal ratchet (`SignalProtocol` → `msg`/`pkmsg`), groups via `SenderKeyManager` (`skmsg`) plus sender-key distribution to members who need it. Your own devices get a `deviceSentMessage`. 4. **Assemble** — `src/transport/node/builders/message.ts` wraps the encrypted participants into one `` stanza with the device identity, participant hash, and `addressing_mode` (pn/lid). 5. **Send & ack** — `WaNodeOrchestrator.sendNode()` encodes and writes it; the coordinator waits for the server `` and returns a [`WaMessagePublishResult`](/en/guides/sending-messages). Failures are retried per the configured attempts/backoff. ## Incoming pipeline 1. `WaComms` decrypts a Noise frame; `WaNodeTransport.dispatchIncomingFrame()` decodes it into a `BinaryNode`. 2. `WaIncomingNodeCoordinator` routes by tag/type to the right handler (`src/client/events/`). 3. For messages, the body is decrypted (Signal ratchet or sender key), device-sent wrappers are unwrapped, and PKCS7 padding removed (`src/message/primitives/incoming.ts`). 4. The result is normalized into a typed payload and emitted (`message`, `receipt`, `group`, …). A [stanza filter](/en/reference/low-level#filtering-inbound-stanzas) can drop stanzas before handlers run; the coordinator still acks `message`/`receipt`/`notification` so the server stops re-delivering. Decryption failures are tracked and can trigger a retry-receipt so the sender re-encrypts. ## App-state engine `WaAppStateSyncClient` (`src/appstate/`) reconciles per-account settings: * A sync sends the last known version per collection; the server returns **patches** (or a full **snapshot**). * Each mutation's index and value are MAC-verified and the value decrypted (`WaAppStateCrypto`: AES-CBC + HMAC, per-collection keys derived via HKDF). * Verified mutations are applied to the `appState` store and surfaced as [`mutation`](/en/concepts/events#state-history--mex) events. * Outgoing changes from [`client.chat`](/en/reference/chat-mutations) are encoded as mutations, batched, and flushed. ## Store layer The store is split into **contracts** (`src/store/contracts/` — the interface each domain implements) and **providers** (the built-in `memory` provider, plus the external [backend packages](/en/reference/stores)). This is what lets you mix backends per domain in [`createStore`](/en/concepts/stores). Two performance boundaries sit here: * **Write-behind** — incoming messages/threads/contacts are batched and flushed asynchronously (tuned via [`writeBehind`](/en/concepts/configuration#write-behind-persistence)) so the hot path isn't blocked on the database. * **Bounded caches** — `retry`, `groupMetadata`, `deviceList`, and `messageSecret` are bounded in-memory with TTLs to prevent unbounded growth in long-lived processes. ## Reliability * **Retry tracker** (`src/retry/`) — maps failed message ids to retry metadata and enforces attempt/backoff limits. * **Receipt queue** (`WaReceiptQueue`) — buffers receipts that fail to send during a disconnect, replaying them on reconnect (bounded to avoid growth). * **Keep-alive** — periodic ping IQs detect dead sockets and measure clock skew; it skips pinging while a query is already in flight. ## Client composition `WaClientFactory` is the composition root: it constructs the auth client, connection manager, transport + orchestrator, keep-alive, Signal/sender-key managers, app-state client, stores, and every feature coordinator, then injects them into `WaClient`. `WaClient` itself stays thin — an `EventEmitter` exposing coordinator getters and the connection lifecycle. ## Crypto `src/crypto/` provides the primitives: * **Symmetric** — AES-GCM (Noise, app-state values), AES-CBC (sender keys), AES-CTR (media). * **Hash/MAC/KDF** — SHA-1/256/512, HMAC, HKDF. * **Elliptic curve** — Curve25519 (X25519 DH) and Ed25519/XEdDSA signatures. Everything here is **synchronous except the elliptic-curve operations**, which are async. Keeping the rest of crypto synchronous removed per-call async overhead and measurably improved throughput on the hot paths; the curve operations stay async by design. ## Conventions These hold across the codebase and explain much of the API shape: * `Uint8Array` everywhere for binary data; `Buffer` is avoided. Zero-copy (`subarray`, byte views) in critical paths. * Bounded in-memory structures to prevent unbounded growth. * Named exports only; no default exports. * No enums — constants use `Object.freeze({ … } as const)`, surfaced as the `WA_*` objects. * Path aliases (`@client`, `@crypto`, `@store`, …) instead of relative `../` imports. # Mobile connections Source: https://zapo.to/en/concepts/mobile Connect zapo as a primary mobile (Android) WhatsApp client over the TCP transport instead of a companion device, including the limitations involved. Besides the standard **companion** mode (linking via QR / pairing code, like WhatsApp Web), `zapo` can connect as a **primary mobile client** — speaking the Android app's protocol over a raw TCP socket. Mobile support is **stable and functional.** The one thing `zapo` does **not** provide is a **registration API** — requesting an SMS/voice code, submitting an OTP, or approving a takeover. Registering a number is complex and requires a physical phone, so it's intentionally out of scope. You connect with an **already-registered** credential set, and that path is solid. ## How it differs from companion mode | | Companion (default) | Mobile | | ----------- | --------------------- | ----------------------------------------------- | | Transport | WebSocket (`wss://…`) | TCP socket (`tcp://g.whatsapp.net:443`) | | Auth | QR / pairing code | Pre-registered credentials + device fingerprint | | Identity | Linked device | Primary account | | Platform | Browser (`chrome`, …) | `android` | | Device info | Not required | **Required** (hardware fingerprint) | ## Enabling mobile mode Mobile mode is triggered by the `mobileTransport` option (a `WaMobileTransportOptions`). Its presence — or persisted `deviceInfo` in the loaded credentials — switches the client from the WebSocket transport to the TCP transport. ```ts theme={null} const client = new WaClient( { store, sessionId: 'mobile', mobileTransport: { deviceInfo: { manufacturer: 'OnePlus', device: 'OnePlus8Pro', osVersion: '12', osBuildNumber: 'SKQ1.210216.001', appVersion: '2.23.1.1', mcc: '55', // mobile country code (optional) mnc: '11' // mobile network code (optional) }, passive: false // send keep-alives } }, logger ) await client.connect() ``` ### `WaMobileTransportOptions` | Field | Type | Notes | | ------------------------ | ----------------------------- | ---------------------------------------------- | | `deviceInfo` | `WaMobileTransportDeviceInfo` | **Required** hardware fingerprint (see below). | | `tcpUrl` | `string` | Defaults to `tcp://g.whatsapp.net:443`. | | `passive` | `boolean` | `false` sends keep-alives; `true` is idle. | | `pushName` | `string` | Display name. | | `yearClass` / `memClass` | `number` | Device performance/memory class. | ### `WaMobileTransportDeviceInfo` `manufacturer`, `device`, `osVersion`, `osBuildNumber`, `appVersion` are required; `mcc`, `mnc`, `localeLanguageIso6391`, `localeCountryIso31661Alpha2`, `phoneId`, `deviceBoard`, `deviceModelType` are optional. A **stable** fingerprint across runs matters — persist it and reuse the same values. ## Credentials Mobile mode needs an **already-registered** credential set: a `WaAuthCredentials` with `meJid` populated, `platform: 'android'`, and `deviceInfo` attached. You seed these into the auth store before connecting (e.g. imported from a device bundle). Once credentials with `deviceInfo` are persisted, later reconnects **automatically** use the mobile TCP transport — you don't need to pass `mobileTransport` again. ## Registration events While your mobile session is connected, you're notified when **someone tries to register your number on another device** — a security-relevant signal, surfaced as these events: ```ts theme={null} client.on('mobile_registration_code', (event) => { // WaRegistrationCodeEvent — someone requested a code to register YOUR number elsewhere console.log('registration code issued:', event.code, 'expires:', event.expiryTimestampMs) }) client.on('mobile_account_takeover_notice', (event) => { // WaAccountTakeoverNoticeEvent — another device is taking over your number console.log('takeover attempt from', event.newDevicePlatform, event.newDeviceName) }) ``` | Event | Payload | Meaning | | -------------------------------- | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | `mobile_registration_code` | `{ code, expiryTimestampMs, fromDeviceId }` | Someone requested a registration code to register **your** number on another phone; the issued code is surfaced here. | | `mobile_account_takeover_notice` | `{ serverToken, attemptTimestampMs, newDeviceName?, newDevicePlatform?, newDeviceAppVersion? }` | Another device is claiming (taking over) your number. | These events are **informational** — `zapo` surfaces them but intentionally does not expose methods to submit a code or respond to a takeover. Provisioning a number is done on a real phone; bring the resulting credentials to zapo and connect. ## Email binding Mobile-only `client.email` ([`WaEmailCoordinator`](/en/reference/client)) binds and verifies an email address on the account — a recovery/login factor. It is **mobile-only**: every method throws on a Web/companion connection. ```ts theme={null} // Current binding state const status = await client.email.getStatus() // { email: string | null, verified: boolean, confirmed: boolean } // Bind an address, request a code, submit it, then confirm await client.email.setEmail('me@example.com') await client.email.requestVerificationCode({ /* BuildRequestEmailVerificationCodeInput */ }) const result = await client.email.verifyCode('123456') // { verified, autoVerifyFailed, email } await client.email.confirm() ``` ## Standard features still apply Once connected in mobile mode, the rest of the API is unchanged — `client.message`, `client.group`, events, stores, etc. all work the same way. The only difference is the transport and the auth/identity model. # The WhatsApp protocol Source: https://zapo.to/en/concepts/protocol A tour of the WhatsApp multi-device protocol — Noise transport, XML stanzas, Signal encryption, and how zapo implements each layer in TypeScript. `zapo` is an independent, from-scratch implementation of the WhatsApp multi-device protocol. This page explains what the protocol looks like on the wire and points at the modules where `zapo` handles each layer. You don't need any of it to use the library — it's here for the curious and for contributors. The protocol-definition artifacts under `spec/` (protobuf, app-state, and MEX schemas) are generated from the open [`vinikjkkj/wa-spec`](https://github.com/vinikjkkj/wa-spec) repository, which tracks the WhatsApp protocol definitions. ## The layers From the socket up: ```mermaid theme={null} flowchart TD A["Your code"] --> B["Coordinators"] --> C["Message pipeline"] C --> D["Signal / sender-key encryption"] D --> E["Binary node codec"] E --> F["Noise · encrypted"] F --> G["WebSocket web / TCP mobile"] G --> H["WhatsApp servers"] ``` ## Transport & the Noise handshake WhatsApp doesn't speak plain WebSocket — every byte after connect is wrapped in a **Noise protocol** session (an `XX`-style handshake using Curve25519, AES-GCM, and SHA-256). The handshake authenticates the server, negotiates session keys, and from then on every frame is AES-GCM encrypted with a counter nonce. In `zapo`: * `src/transport/WaComms.ts` owns the socket + Noise lifecycle. * `src/transport/noise/` implements the handshake state machine (`WaNoiseHandshake`), the encrypted socket wrapper, and the login/registration **client payload** (device metadata, app version, locale). * The socket itself is pluggable: `src/transport/WaWebSocket.ts` for the browser/Node WebSocket (companion mode), `src/transport/node/WaMobileTcpSocket.ts` for the raw TCP transport ([mobile mode](/en/concepts/mobile)). ## Stanzas: the binary node codec Inside the Noise tunnel, WhatsApp speaks a compact binary form of XMPP-like **stanzas**. `zapo` models every stanza as a [`BinaryNode`](/en/reference/low-level#binary-nodes): ```ts theme={null} interface BinaryNode { tag: string attrs: Record content?: Uint8Array | string | readonly BinaryNode[] } ``` The wire format uses a **token dictionary** (common strings like `s.whatsapp.net` are single bytes), nibble/hex packing for JIDs and numbers, and optional compression. `zapo`'s codec lives in `src/transport/binary/` (`encoder.ts`, `decoder.ts`, `tokens.ts`) and is written for **zero-copy** — the decoder returns `subarray` views over the received bytes instead of copying. ## Requests & responses (IQ) Many operations are request/response **IQ** stanzas (`` → ``), correlated by a stanza `id`. `src/transport/node/WaNodeOrchestrator.ts` assigns ids, tracks in-flight queries in a map, and resolves the matching response (or times out). The typed coordinators are built on top of this; you can reach it directly via [`client.lowlevel.query`](/en/reference/low-level#issuing-an-iq). ## End-to-end encryption (Signal) Message bodies are end-to-end encrypted with the **Signal protocol**. `zapo` implements it in `src/signal/` on top of the primitives in `src/crypto/`: * **Identity & prekeys** — each device has a long-term identity key and a set of one-time prekeys. Establishing a session with a new peer fetches their prekey bundle. * **1:1 chats** — a Double-Ratchet session encrypts each message. On the wire the envelope is `msg` (an established session) or `pkmsg` (a prekey message that also bootstraps the session). * **Groups** — a **sender-key** scheme (`skmsg`): each member distributes a sender key once, then encrypts group messages symmetrically (AES-CBC) under it. Distribution messages ride along to members who don't have your sender key yet. The envelope discriminator (`'msg' | 'pkmsg' | 'skmsg'`) appears throughout as the encrypted message type. ## Multi-device & fanout WhatsApp is **multi-device**: an account is a set of devices, and a message must be encrypted **once per recipient device**. `zapo` resolves the device list for each recipient, establishes Signal sessions as needed, and fans the ciphertext out into a single `` stanza. Recipients are addressed either by phone-number JID or by [LID](/en/concepts/identities), the `addressing_mode` chosen from group membership. Your own other devices receive a `deviceSentMessage` copy so all your devices stay in sync. ## App-state sync Settings that must look the same on every device — mute, pin, archive, read state, labels, contacts — are **not** messages. They sync through a separate **app-state** channel: encrypted, MAC'd **mutations** layered into collections, reconciled with an LT-hash so devices converge. `zapo` implements this in `src/appstate/` (`WaAppStateSyncClient` + `WaAppStateCrypto`) and surfaces it through [`client.chat`](/en/reference/chat-mutations) and the [`mutation`](/en/concepts/events#state-history--mex) event. ## Media Media isn't sent inline. The bytes are encrypted with a per-message media key (AES-CBC + HMAC) and uploaded to a WhatsApp CDN; the stanza carries the URL, keys, and digests. The recipient downloads and decrypts. `zapo` handles upload/download in `src/media/` — see the [media guide](/en/guides/media). ## MEX (GraphQL) Newer surfaces — newsletters, parts of business, some notifications — use **MEX**, a GraphQL-over-IQ layer. `zapo` wraps these queries in the relevant coordinators (e.g. [`client.newsletter`](/en/guides/newsletters)); the optional [`argo-codec`](/en/installation#optional-peer-dependencies) peer decodes certain MEX responses. ## Design choices `zapo` makes deliberate, protocol-informed choices: * **`index-first`** — behavior is validated against WhatsApp Web before it's implemented. * **`performance-first`** — `Uint8Array` everywhere, zero-copy in hot paths, bounded in-memory structures. Crypto is **synchronous** except elliptic-curve operations (which are async) — see [internals](/en/concepts/internals#crypto). * **`async-first` I/O** — network and I/O are async; the hot decode/encode paths avoid needless allocation. For how these layers are wired into the client, see [Architecture in depth](/en/concepts/internals). # Stores Source: https://zapo.to/en/concepts/stores Persist authentication state, Signal sessions, and per-domain protocol data through zapo's pluggable store interface and bundled backend packages. A **store** is where `zapo` persists everything a session needs to survive a restart: pairing credentials, Signal protocol state, app-state collections, and optionally your message/thread/contact archive. You build one with `createStore` and pass it to the client. ```ts theme={null} import { createStore } from 'zapo-js' import { createSqliteStore } from '@zapo-js/store-sqlite' const store = createStore({ backends: { sqlite: createSqliteStore({ path: '.auth/state.sqlite', driver: 'auto' }) }, providers: { auth: 'sqlite', signal: 'sqlite', preKey: 'sqlite', session: 'sqlite', identity: 'sqlite', senderKey: 'sqlite', appState: 'sqlite', privacyToken: 'sqlite', messages: 'sqlite', threads: 'sqlite', contacts: 'sqlite' } }) ``` ## The model `createStore` separates **backends** (where data lives) from **providers** (which backend each domain uses). This lets you mix backends — e.g. keep hot signal state in Redis while archiving messages in Postgres. ```ts theme={null} createStore({ backends: { redis: createRedisStore({ redis }), postgres: createPostgresStore({ pool }) }, providers: { auth: 'redis', signal: 'redis', preKey: 'redis', session: 'redis', identity: 'redis', senderKey: 'redis', appState: 'redis', privacyToken: 'redis', messages: 'postgres', threads: 'postgres', contacts: 'postgres' } }) ``` ## Providers are required when you set `backends` As soon as `backends` contains at least one entry, **every persistence domain must be assigned explicitly** in `providers`. The required domains are `auth`, `signal`, `preKey`, `session`, `identity`, `senderKey`, `appState`, `privacyToken`, `messages`, `threads`, and `contacts`. Both the TypeScript types and a runtime check enforce this — `createStore` throws and lists the missing `providers.*` keys when any are omitted. Three values are valid for each domain: * A backend name from `backends` (e.g. `'sqlite'`) — persist that domain there. * `'memory'` — keep that domain in the in-tree memory provider for this run. * `'none'` — only valid for the optional archive domains (`messages`, `threads`, `contacts`); skips the domain entirely. This guard exists because partial coverage is almost always a bug. If you persist only `auth` and let Signal state, app-state, or the mailbox fall back to memory, the device pairs once and then loses its protocol state on every restart. Pick `'memory'` deliberately when that is what you want. ```ts theme={null} createStore({ backends: { sqlite: createSqliteStore({ path: '.auth/state.sqlite' }) }, providers: { auth: 'sqlite', signal: 'sqlite', preKey: 'sqlite', session: 'sqlite', identity: 'sqlite', senderKey: 'sqlite', appState: 'sqlite', privacyToken: 'sqlite', messages: 'none', // skip the message archive threads: 'none', contacts: 'none' } }) ``` When `backends` is empty or omitted, every domain falls back to memory (mailbox domains to `'none'`) — useful for tests, but the device re-pairs on every restart. ## Persisted domains These hold the state required to keep a session alive. Back them with a durable backend in production. | Domain | Holds | | -------------- | ---------------------------------------------------------- | | `auth` | Pairing credentials and device identity. **Persist this.** | | `signal` | Signal sessions (umbrella over the sub-stores below). | | `preKey` | Signal pre-keys. | | `session` | Signal sessions. | | `identity` | Signal identity keys. | | `senderKey` | Group sender keys. | | `appState` | App-state collections (mute, pin, read, archive, …). | | `privacyToken` | Trusted-contact / privacy tokens. | ## Optional archive domains These accept `'none'` to disable persistence entirely: | Domain | Holds | | ---------- | -------------------------------------------- | | `messages` | Message archive (`B \| 'memory' \| 'none'`). | | `threads` | Thread metadata. | | `contacts` | Contact directory. | ## Cache domains Configured under `cacheProviders` and default to bounded memory with TTLs: | Domain | Holds | | --------------- | -------------------------------- | | `retry` | Outbound message retry queue. | | `groupMetadata` | Group metadata cache. | | `deviceList` | Device list cache. | | `messageSecret` | Message-secret cache for addons. | ```ts theme={null} createStore({ backends: { sqlite }, providers: { /* ... */ }, cacheProviders: { groupMetadata: 'sqlite', deviceList: 'sqlite' }, memory: { cacheTtlMs: { groupMetadataMs: 600_000, deviceListMs: 600_000 } } }) ``` Each backend evicts expired entries differently: `memory` runs an in-process sweep, Redis and MongoDB use native TTL, SQLite filters on read, and **PostgreSQL/MySQL require an opt-in poller** (`result.startCleanup(sessionId)`) or cache tables grow forever. See [Cache expiry and cleanup](/en/reference/stores#cache-expiry-and-cleanup) for the per-backend matrix. ## Read-through cache layer When a hot signal domain points at a persistent backend, every send/recv round-trip pays the backend's latency to fetch the same peer's session, identity, or sender key. The `cacheLayer` option wraps the backend store with a bounded-LRU L1 (the in-tree memory provider) so repeated reads of the same peer skip the backend, while writes stay write-through so the backend remains authoritative. Four hot domains can be cached: | Domain | Strategy | | -------------- | ------------------------------------------------------------------------------------------------------------- | | `session` | Signal Double-Ratchet sessions. Read-through + write-through. | | `identity` | Remote identity keys. Read-through + write-through. | | `senderKey` | Per-(group, sender) sender keys. Read-through + write-through. | | `privacyToken` | Trusted-contact tokens. Read-through + **invalidate-on-write** (the backend merges partial fields on upsert). | All flags default to `false`. A flag is a no-op unless that domain resolves to a real backend in `providers` — caching `'memory'` or `'none'` in front of itself buys nothing and is skipped. ```ts theme={null} createStore({ backends: { postgres, redis }, providers: { auth: 'postgres', signal: 'postgres', preKey: 'postgres', session: 'postgres', identity: 'postgres', senderKey: 'postgres', appState: 'postgres', privacyToken: 'postgres', messages: 'postgres', threads: 'postgres', contacts: 'postgres' }, cacheLayer: { session: true, identity: true, senderKey: true, privacyToken: true, limits: { session: 10_000, identity: 10_000, senderKey: 5_000, privacyToken: 5_000 } } }) ``` `limits` caps per-domain entry counts; once exceeded, the L1 evicts LRU. When unset, each domain defaults to the matching memory-provider cap. ### When to enable it Turn it on when your backend is a network hop (Redis, Postgres, MySQL, MongoDB) and you send or receive at a rate where the same peers repeat — typical for bots, group fan-out, and multi-tenant gateways. With a local SQLite backend the wins are smaller; measure before flipping it on. ### Single-writer assumption The L1 is per-process and has no cross-process invalidation channel. Enable `cacheLayer` only when a single process owns a given `sessionId`'s backend rows — the library's standard connection model. Different sessions sharing one backend are fine; the same session opened from two processes is not. Do not enable `cacheLayer` when multiple processes share one backend for the **same** `sessionId`. Another process's writes would leave this cache stale and corrupt the Signal ratchet. ### Why not every domain? `signal`, `appState`, and `preKey` are deliberately excluded: * **`signal`** — the per-send registration read is already memoized inside the signal lock; a second cache adds nothing. * **`appState`** — the sync client already caches collection state for the sync-context lifetime, the only scope where reads both repeat and stay coherent. * **`preKey`** — one-time pre-keys are read exactly once then consumed. Serving a consumed key from a stale cache would reuse it and break forward secrecy. ## Backends `@zapo-js/store-sqlite` — local, single-process. `@zapo-js/store-postgres` — distributed, relational. `@zapo-js/store-mysql` — distributed, relational. `@zapo-js/store-redis` — cache + persistence. `@zapo-js/store-mongo` — document store. Built in. Great for tests; does not survive a restart. See the [stores reference](/en/reference/stores) for each backend's config options. ## Memory-only (tests) For quick experiments or tests, omit `backends` entirely — every domain falls back to memory: ```ts theme={null} const store = createStore({}) const client = new WaClient({ store, sessionId: 'test' }, logger) ``` A memory-only store loses all credentials on restart, so you re-pair every boot. Use a durable backend for anything long-lived. # Dev tools (MCP & fake server) Source: https://zapo.to/en/dev-tools Optional dev-only packages: an MCP server to drive a live WaClient from an AI agent, and an in-process fake WhatsApp server for offline integration tests. `zapo` ships two optional packages aimed purely at **development and testing** — neither is meant for production: * [**MCP server**](#mcp-server) (`@zapo-js/mcp-server`) — expose a live `WaClient` to an AI agent (Claude Code, Cursor) so it can connect, pair, send, and inspect state interactively. * [**Fake server**](#fake-server) (`@zapo-js/fake-server`) — an in-process fake WhatsApp Web server that drives the real `WaClient` end-to-end, so you can test deterministically without touching WhatsApp's servers. *** ## MCP server **Development & testing only.** `@zapo-js/mcp-server` is a debugging aid, **not** a production protocol server. It exposes a live `WaClient` — and the whole `zapo-js` module — to an AI agent that can send messages, read state, and run arbitrary library calls on a real WhatsApp account. Run it only against accounts you control. It exposes a live [`WaClient`](/en/reference/client) instance **and** the `zapo-js` module namespace as [MCP](https://modelcontextprotocol.io) tools. An LLM agent can then drive end-to-end WhatsApp flows — connect, pair, send, query groups/newsletters, inspect events, walk SQL state — without you writing throwaway scripts. ### Tool surface | Tool | What it does | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `call` / `inspect` | Walk dotted paths against `client` (the `WaClient`) or `lib` (the `zapo-js` namespace, including `proto.*` and helpers like `parsePhoneJid`). `call` invokes functions; `inspect` lists members. | | `events` / `events_clear` | Bounded ring buffer of every [`WaClientEventMap`](/en/concepts/events) event — filter by `types` / `since` / `limit` / `drain`. | | `logs` / `logs_clear` | Queryable buffer mirroring every runtime + lib log line (also stderr; JSONL to `MCP_LOG_FILE` if set). | | `lifecycle` | `status` / `start` / `destroy` for the client. | | `restart` | `soft` (drop the client + clear buffers) or `process_exit` (also exit so a supervisor respawns it). | Each tool inlines its own schema and examples — the agent reads them at runtime rather than memorizing flags. ### Install & register ```bash theme={null} npm install @zapo-js/mcp-server # peers: zapo-js, @zapo-js/store-sqlite, @zapo-js/media-utils (better-sqlite3 optional, for SQLite persistence) ``` Register with Claude Code at user scope (build first), so it works from any directory: ```bash theme={null} npm run build --workspace @zapo-js/mcp-server claude mcp add zapo --scope user -- node /packages/mcp-server/dist/bin.js ``` For tight iteration on the library itself, register the **source** through `tsx` — no build step, and `zapo-js` resolves straight from `src/`: ```bash theme={null} claude mcp add zapo --scope user -- node --import tsx /packages/mcp-server/src/bin.ts ``` An HTTP transport with `node --watch` gives the smoothest dev loop: edit a `.ts` → the process restarts → the next tool call reconnects automatically, with no manual `/mcp` reconnect. See the package README for the `dev` script and HTTP setup. ### Pairing gotcha `client.connect()` blocks until pairing finishes, so always start it without awaiting, then poll the event buffer: ```text theme={null} call({ path: 'connect', noAwait: true }) events({ types: ['auth_qr', 'auth_pairing_code', 'auth_paired', 'connection'] }) ``` Surface the `auth_qr` string to the user, wait for `auth_paired`, then continue. ### Key environment variables | Var | Default | Purpose | | --------------------------------------------------- | ----------------------------- | --------------------------------------------- | | `MCP_AUTH_PATH` | `/.auth/state.sqlite` | SQLite credential store path | | `MCP_SESSION_ID` | `default_2` | `sessionId` passed to `WaClient` | | `MCP_LOG_LEVEL` | `info` | `trace` / `debug` / `info` / `warn` / `error` | | `MCP_TRANSPORT` | `stdio` | `stdio` or `http` | | `MCP_HTTP_HOST` / `MCP_HTTP_PORT` / `MCP_HTTP_PATH` | `127.0.0.1` / `3737` / `/mcp` | HTTP listener config | | `MCP_EVENT_BUFFER_SIZE` / `MCP_LOG_BUFFER_SIZE` | `1000` / `500` | In-memory ring sizes | One `WaClient` per process (multi-session needs multiple servers with distinct `MCP_AUTH_PATH` + `MCP_SESSION_ID`); no auto-reconnect (call `connect` again on `connection: close`); `restart` `soft` does **not** pick up code changes while `process_exit` + a supervisor does. Full reference: `packages/mcp-server/README.md`. *** ## Fake server `@zapo-js/fake-server` is an **in-process fake WhatsApp Web server** that drives the real `zapo-js` `WaClient` end-to-end — full Noise XX/IK handshake, QR pairing, Signal Protocol (X3DH + Double Ratchet), group SenderKey, media upload/download over self-signed HTTPS, and app-state sync — all without touching WhatsApp's servers. It powers the library's own cross-check test suite and benchmarks, and you can use it to test your integration deterministically. ### Quick start ```ts theme={null} import { FakeWaServer } from '@zapo-js/fake-server' import { createStore, WaClient } from 'zapo-js' const server = await FakeWaServer.start() const client = new WaClient({ store: createStore({ providers: { auth: 'memory', signal: 'memory', senderKey: 'memory', appState: 'memory' } }), sessionId: 'test', chatSocketUrls: [server.url], testHooks: { noiseRootCa: server.noiseRootCa }, proxy: { mediaUpload: server.mediaProxyAgent, mediaDownload: server.mediaProxyAgent } }) await client.connect() const pipeline = await server.waitForAuthenticatedPipeline() // drive pairing, create peers, send/receive messages, assert on both sides await server.stop() ``` The wiring uses three [client options](/en/concepts/configuration) built for exactly this: `chatSocketUrls` (point at the fake WebSocket), `testHooks.noiseRootCa` (trust the fake server's certificate **without** bypassing verification — the full cert-chain check still runs), and `proxy.mediaUpload` / `proxy.mediaDownload` (route media to the fake HTTPS server). ### What it simulates * **`FakeWaServer`** — the WebSocket listener, Noise handshake, an IQ router that answers every IQ the lib emits during normal operation (prekey upload/fetch, usync, `media-conn`, app-state sync, groups, privacy, profile, blocklist, …), plus state registries for peers and groups. * **`FakePeer`** — a simulated contact with real Signal crypto: `peer.sendConversation(text)` / `peer.sendGroupConversation(groupJid, text)` push messages to the client, and `peer.expectMessage()` captures and decrypts what the client sends. * **Pairing** — `server.runPairing(pipeline, { deviceJid }, materialFn)` drives the full QR-pairing handshake; afterward the lib reconnects with the IK handshake (capture it via `waitForNextAuthenticatedPipeline()`). ### Standalone CLI Run it as a standalone server for manual experiments: ```bash theme={null} npm --workspace=@zapo-js/fake-server run cli -- --port 5222 --peer 5511888@s.whatsapp.net --log ``` ### Benchmarking The package ships a messaging profiler (send/recv × 1:1/group) used to track the library's performance, plus focused scenario suites for connect lifecycle, history sync, bulk usync, group provisioning, media upload, receipts flood, reconnect/resume, and app-state: ```bash theme={null} npm --workspace=@zapo-js/fake-server run bench:messaging # or one of: bench:connect, bench:history, bench:usync, bench:group, # bench:media, bench:receipts, bench:reconnect, bench:appstate ``` Tune the workload with `ZAPO_BENCH_*` env vars (`ZAPO_BENCH_CONTACTS`, `ZAPO_BENCH_GROUP_MEMBERS`, `ZAPO_BENCH_MESSAGES`, `ZAPO_BENCH_SCENARIOS`, …) and add `--cpu` / `--heap` / `--separate-process` for profiles. With `--separate-process`, the bench drives the fake server in a child process over an RPC bridge and emits a matching server-side CPU profile and heap snapshot alongside the lib-side ones. Pick a store backend with `ZAPO_BENCH_STORE` (`memory`, `sqlite`, `postgres`, `mysql`, `redis`, or `mongo`) — the same `ZAPO_TEST_*` connection env vars as the cross-store test harness apply. To sweep every bench across multiple stores in one shot: ```bash theme={null} npm --workspace=@zapo-js/fake-server run bench:all-stores -- \ --stores=memory,sqlite --benches=connect-lifecycle,history-sync ``` Add `--start-docker` to bring up the bundled Postgres/MySQL/Redis/Mongo services on ephemeral ports and tear them down at the end. See `packages/fake-server/README.md` for the full flag reference. The fake server is an in-process testing harness, not a runtime you deploy. Pair it with the **memory** store for fast, isolated tests that reset on every run. # Bots Source: https://zapo.to/en/guides/bots Discover WhatsApp bots, send prompts, and receive streamed responses with zapo. Works with any WhatsApp bot, including Meta AI and third-party agents. `client.bot` ([`WaBotCoordinator`](/en/reference/client#bot)) works with WhatsApp **bots** - any account on the `@bot` domain. Meta AI is the most common one, but it is **not** the only bot; `listBots()` returns every bot available to your account. The coordinator discovers bots, reads their profiles, sends prompts, and decrypts the **streamed** chunks of a bot's reply. ## Discovering bots & getting a bot JID You don't hard-code bot JIDs - you discover them with `listBots()` and pick one: ```ts theme={null} const bots = await client.bot.listBots() // WaBotInfo[]: { jid, fbidJid, personaId, isDefault, section?, count? } // Pick the default bot (typically Meta AI), or choose another by section/name const bot = bots.find((b) => b.isDefault) ?? bots[0] const botJid = bot.jid // e.g. '13135550002@bot' // Inspect a bot's profile (commands, prompts, creator metadata) const profile = await client.bot.getBotProfile(botJid) // WaBotProfileResult | null: name, description, category, prompts, commands, creator… ``` `WaBotInfo.jid` is the value you pass below as `to` (direct path) or `options.botJid` (mention path). ## Sending a prompt `sendPrompt(to, content, options?)` invokes a bot. There are two paths depending on `to`: ### Direct path — chat with the bot When `to` is a `@bot` JID, you're chatting with the bot directly. zapo generates a fresh `aiThreadId` (a conversation id); reuse it on later prompts to keep context: ```ts theme={null} // Start a conversation const first = await client.bot.sendPrompt(botJid, 'Explain WebSockets in one line') // Continue it — pass the same aiThreadId to keep context await client.bot.sendPrompt(botJid, 'Now in two lines', { aiThreadId: myThreadId }) ``` ### Mention path — invoke a bot inside a group When `to` is a **group/chat** JID, you must name the bot via `options.botJid`. The bot is invoked indirectly through a mention: ```ts theme={null} // botJid comes from listBots() — see "Discovering bots" above await client.bot.sendPrompt(groupJid, '@MetaAI summarize the last messages', { botJid, extraMentionedJids: [] // optional extra mentions alongside the bot }) ``` On the mention path, `aiThreadId` / `aiThreadType` are ignored — bots drop the request if persona/thread metadata is attached to a mention. `WaBotPromptOptions` extends [`WaSendMessageOptions`](/en/guides/sending-messages#send-options-reference) and adds `botJid`, `personaId`, `capabilities`, `extraMentionedJids`, `aiThreadId`, and `aiThreadType`. ## Receiving the streamed reply A bot's reply does **not** arrive as one `message`. It streams as multiple encrypted chunks, surfaced on the `message_bot_chunk` event. zapo decrypts them automatically on every incoming message, so you just listen: ```ts theme={null} const buffers = new Map() client.on('message_bot_chunk', (event) => { // WaIncomingBotChunkEvent const text = event.message?.conversation ?? '' // Concatenate chunks in arrival order using editType const prev = buffers.get(event.targetMessageId) ?? '' buffers.set(event.targetMessageId, prev + text) if (event.editType === 'last' || event.editType === 'full') { console.log('full reply:', buffers.get(event.targetMessageId)) buffers.delete(event.targetMessageId) } }) ``` The chunk event fields: | Field | Meaning | | ----------------------------------- | --------------------------------------------------------------- | | `key.participant` / `key.remoteJid` | The bot (sender = `key.participant ?? key.remoteJid`). | | `targetMessageId` | The id of the prompt this reply answers — your stream key. | | `editType` | Chunk position: `first` → `inner` → `last`, or a single `full`. | | `message` | The decrypted chunk content (`Proto.IMessage`). | | `plaintext` | Raw decrypted bytes. | Reconstruct the full answer by concatenating chunks for a given `targetMessageId` in arrival order until you see `last` (or a single `full`). ## Manual chunk decryption zapo calls `tryDecryptChunk` for you on each incoming message, so you rarely need it. If you manage incoming events yourself, you can invoke it explicitly: ```ts theme={null} client.on('message', async (event) => { await client.bot.tryDecryptChunk(event) }) ``` It silently no-ops when the chunk isn't addressed to you or the parent prompt secret isn't available. # Broadcast lists Source: https://zapo.to/en/guides/broadcast-lists Define a WhatsApp broadcast list with zapo and send a single message to many recipients at once without creating a group or revealing the list. A **broadcast list** sends a single message to many contacts at once — each recipient receives it as a normal 1:1 chat and can't see who else is on the list. Broadcast lists live on `client.broadcastList` ([`WaBroadcastListCoordinator`](/en/reference/client#broadcastlist)). Business-only **Business-only.** Broadcast lists are backed by the `BusinessBroadcastList` app-state schema and only work on **WhatsApp Business** accounts. On a regular account the server rejects the underlying mutations. ## Defining a list `setList` creates or updates a list definition — it then appears under **Broadcast lists** on the phone, synced through [app-state](/en/concepts/stores). Participants are identified by their [LID](/en/concepts/identities) (`lidJid`), optionally paired with the phone-number JID (`pnJid`): ```ts theme={null} await client.broadcastList.setList({ id: 'list-1', listName: 'Friends', participants: [ { lidJid: 'a@lid', pnJid: 'a@s.whatsapp.net' }, { lidJid: 'b@lid' } ], labelIds: ['L1'] // optional — attach business labels }) ``` Remove a list by its `id`: ```ts theme={null} await client.broadcastList.removeList('list-1') ``` ## Sending to a list `send` takes the same [content union](/en/guides/sending-messages#the-content-union) as `client.message.send` — text, media, polls, and so on — plus the broadcast `listJid` (the list `id` with an `@broadcast` suffix) and the explicit `recipients` to fan out to: ```ts theme={null} const result = await client.broadcastList.send({ listJid: 'list-1@broadcast', content: 'Weekend sale starts now! 🎉', recipients: ['a@lid', 'b@lid'] // options: { ... } // same shape as client.message.send options }) console.log(result.id) // the published message id ``` Each recipient is encrypted for individually (a fanout), so a single `send` call is effectively N direct sends behind one request. Pass the usual [send options](/en/guides/sending-messages#send-options-reference) through `options`. Broadcast lists are not [newsletters/channels](/en/guides/newsletters): a broadcast reaches your existing contacts as private 1:1 messages, while a channel is a public, follower-based feed. ## Related Full `client.broadcastList` method signatures. Why participants are keyed by LID. # Managing chats Source: https://zapo.to/en/guides/chats Mute, pin, archive, mark as read, lock, star, clear, and delete WhatsApp chats with the typed client.chat coordinator backed by app-state mutations. Per-chat settings live on `client.chat` (`WaAppStateMutationCoordinator`). These are [app-state](/en/reference/glossary#app-state) mutations — they sync across **all your linked devices**, and changes made elsewhere arrive back as the [`mutation`](/en/concepts/events#state-history--mex) event. These operations affect **your** account's view (and your other devices). They do **not** change anything for the other participants — e.g. deleting a chat doesn't delete it for them. For delete-for-everyone, use a [revoke](/en/guides/interactive-messages#revoking-delete-for-everyone). ## Mute ```ts theme={null} // Mute for 8 hours await client.chat.setChatMute(chatJid, true, Date.now() + 8 * 3600_000) // Unmute await client.chat.setChatMute(chatJid, false) ``` `muteEndTimestampMs` is required when muting (epoch ms). For "mute forever", pass a far-future timestamp. The client does **not** auto-unmute when the timer expires — that's when WhatsApp re-enables notifications. ## Pin & archive ```ts theme={null} await client.chat.setChatPin(chatJid, true) // pin await client.chat.setChatArchive(chatJid, true) // archive ``` Pin and archive are **mutually exclusive** — pinning a chat clears its archive flag and vice-versa. WhatsApp caps the number of pinned chats server-side. ## Read / unread ```ts theme={null} await client.chat.setChatRead(chatJid, true) // mark read await client.chat.setChatRead(chatJid, false) // mark unread ``` ## Lock ```ts theme={null} await client.chat.setChatLock(chatJid, true) ``` Locking also clears archive and pin. ## Star a message A message is identified by a `WaAppStateMessageKey`: ```ts theme={null} interface WaAppStateMessageKey { chatJid: string id: string // the message (stanza) id fromMe: boolean participantJid?: string // group sender } await client.chat.setMessageStar( { chatJid, id: stanzaId, fromMe: false, participantJid: senderJid }, true ) ``` ## Clear & delete ```ts theme={null} // Clear messages but keep the chat (local-only) await client.chat.clearChat(chatJid, { deleteStarred: false, deleteMedia: true }) // Delete the chat entirely (removes it from the list + stored messages) await client.chat.deleteChat(chatJid, { deleteMedia: true }) ``` `clearChat` keeps starred messages and media by default; set `deleteStarred` / `deleteMedia` to wipe those too. Neither leaves a group — use [`client.group.leaveGroup`](/en/guides/groups#leaving) for that. ## Delete a message for me Removes a single message from your own device(s) only — recipients still see it: ```ts theme={null} await client.chat.deleteMessageForMe( { chatJid, id: stanzaId, fromMe: false }, { deleteMedia: true } ) ``` To delete for everyone instead, send a [revoke](/en/guides/interactive-messages#revoking-delete-for-everyone). ## Beyond the helpers The methods above are typed shortcuts. For anything without a dedicated helper — contacts, labels, quick replies, status privacy, and the full list of app-state schemas — use the generic `client.chat.set()` / `client.chat.remove()`. See the [chat mutations reference](/en/reference/chat-mutations). ## Reacting to changes When a chat setting changes on another device, you receive a `mutation` event: ```ts theme={null} client.on('mutation', (event) => { console.log(event.collection, event.schema, event.operation) }) ``` # Errors & disconnects Source: https://zapo.to/en/guides/errors Read DisconnectReason codes, handle stream failures and error stanzas from WhatsApp, and decide when to reconnect versus stop the session for good. `zapo` surfaces problems through three event channels: * **`connection`** with `status: 'close'` — the socket dropped, carrying a `reason` and optional `code`. * **`stream_failure`** — a stream-level failure, often *just before* a close. * **`stanza_error`** — a single request/stanza was rejected, without dropping the connection. For the **reconnection loop** itself (backoff, `isLogout`, graceful shutdown), see [Reconnection](/en/guides/reconnection). This page is about understanding *why* something failed. ## Disconnect reasons The `close` event is `{ status: 'close', reason, code, isLogout }`. `reason` is a [`WaDisconnectReason`](/en/reference/jid-helpers#constants) string; `code` is a numeric `WaConnectionCode` (or `null`). Use them to decide whether to reconnect: ```ts theme={null} client.on('connection', (event) => { if (event.status !== 'close') return if (event.isLogout || isFatal(event.reason)) { console.error('not reconnecting:', event.reason, event.code) return } void reconnect() // see the Reconnection guide }) const FATAL = new Set([ 'stream_error_replaced', // same credentials connected elsewhere 'stream_error_device_removed', // device unlinked 'stream_error_force_logout', // server forced logout (code 516) 'failure_not_authorized', // 401 'failure_banned', // 406 'failure_locked', // 403 'failure_client_too_old', // 405 — bump the advertised version 'failure_bad_user_agent', // 409 'primary_identity_key_change' // account identity changed — re-pair ]) const isFatal = (reason: string) => FATAL.has(reason) ``` `stream_error_force_login` (code **515**) is **not** fatal — it's a routine "reconnect now" the server sends right after pairing and occasionally during a session. Just reconnect with the stored credentials. Transient reasons worth reconnecting on include `stream_error_force_login`, `stream_error_ack`, `stream_error_xml_not_well_formed`, `stream_error_other`, `failure_service_unavailable`, and `comms_stopped`. `client_disconnected` means *you* called `disconnect()` — expected, don't reconnect. ## Code reference When present, `code` (on the close event and on `stream_failure.failureCode`) is one of: | Code | Meaning | Action | | ----- | --------------------- | -------------------------- | | `401` | Not authorized | Re-pair | | `402` | Temporarily banned | Back off hard | | `403` | Locked | Stop | | `405` | Client too old | Bump `version` | | `406` | Banned | Stop | | `409` | Bad user agent | Fix device fingerprint | | `500` | Internal server error | Retry later | | `503` | Service unavailable | Back off and retry | | `515` | Reconnect required | Reconnect now | | `516` | Forced logout | Re-pair (`isLogout: true`) | The reason strings live in `WA_DISCONNECT_REASONS` and the `515`/`516` stream codes in `WA_STREAM_SIGNALING`, both exported from the package root. The numeric `code` values are typed as `WaConnectionCode` (also exported). ## Stream failures `stream_failure` (`WaIncomingFailureEvent`) carries the raw failure detail and usually precedes a disconnect — log it for context: ```ts theme={null} client.on('stream_failure', (event) => { console.warn('stream failure', { reason: event.failureReason, code: event.failureCode, message: event.failureMessage, url: event.failureUrl }) }) ``` ## Error stanzas `stanza_error` (`WaIncomingErrorStanzaEvent`) reports that a single stanza was rejected — for example a malformed query or a throttled request. The connection stays up: ```ts theme={null} client.on('stanza_error', (event) => { console.warn('stanza error', event.code, event.text) }) ``` A rejected `client.message.send` or `client.lowlevel.query` typically rejects its own promise too, so wrap individual calls in `try/catch` for per-operation handling; use `stanza_error` for visibility into errors that aren't tied to a call you `await`. ## See also The backoff loop and `isLogout` handling. Common pitfalls and quick answers. # Groups & communities Source: https://zapo.to/en/guides/groups Create groups, manage participants and admins, handle invites, configure community sub-groups, and react to group events with zapo. Group operations live on `client.group` ([`WaGroupCoordinator`](/en/reference/client#group)). Group JIDs end in `@g.us`. ## Querying groups ```ts theme={null} // All groups the account belongs to const groups = await client.group.queryAllGroups() // One group's metadata const meta = await client.group.queryGroupMetadata('123456@g.us') console.log(meta.subject, meta.participants.length) ``` `WaGroupMetadata` includes the subject, owner, participant list (`WaGroupParticipant[]` with `isAdmin` / `isSuperAdmin`), and the full set of group flags (`announce`, `restrict`, `ephemeral`, community flags, …). ## Creating a group `createGroup` returns the full `WaGroupMetadata` for the new group — no need to call `queryGroupMetadata` afterward: ```ts theme={null} const group = await client.group.createGroup('My group', [ '5511999999999@s.whatsapp.net', '5511888888888@s.whatsapp.net' ]) console.log(group.jid, group.participants.length) ``` ## Managing participants The four participant methods (`addParticipants`, `removeParticipants`, `promoteParticipants`, `demoteParticipants`) return a typed `WaParticipantActionResult[]` — one entry per jid you passed in. The IQ as a whole succeeds even when some participants fail (blocked you, privacy settings disallow add, already a member, …), so inspect the per-jid `code` to surface partial failures. ```ts theme={null} const jids = ['5511999999999@s.whatsapp.net'] const results = await client.group.addParticipants(groupJid, jids) for (const r of results) { if (r.status === 'ok') { console.log('added', r.jid) } else { // HTTP-style code: 403 = privacy block, 408 = not allowed, // 409 = already in, 404 = not on WhatsApp, ... console.warn('failed', r.jid, r.code) } } await client.group.removeParticipants(groupJid, jids) await client.group.promoteParticipants(groupJid, jids) // make admin await client.group.demoteParticipants(groupJid, jids) // remove admin ``` Each result also carries `phoneNumber` and `username` when the server resolved them, plus the raw `BinaryNode` under `raw` for any extra tags the server attached (some `409`/`408` partial failures hint at how to recover). ## Group settings ```ts theme={null} await client.group.setSubject(groupJid, 'New name') await client.group.setDescription(groupJid, 'A description') // null to clear await client.group.setSetting(groupJid, 'announcement', true) // admins-only messages await client.group.setSetting(groupJid, 'restrict', true) // admins-only edit info await client.group.setSetting(groupJid, 'ephemeral', true) // disappearing messages on/off ``` `setSetting` also covers the boolean toggles `ephemeral`, `group_history`, `allow_admin_reports`, `no_frequently_forwarded`, and the community flags. Use it to flip a feature on or off; for settings that need a value (mode or duration), use the dedicated setters below. ### Who can add, link, and share history ```ts theme={null} // Who can add new members await client.group.setMemberAddMode(groupJid, 'admin_add') // admins only await client.group.setMemberAddMode(groupJid, 'all_member_add') // anyone // Who can share the invite link await client.group.setMemberLinkMode(groupJid, 'admin_link') await client.group.setMemberLinkMode(groupJid, 'all_member_link') // Whether new members see prior chat history await client.group.setMemberShareGroupHistoryMode(groupJid, 'admin_share') // hide history await client.group.setMemberShareGroupHistoryMode(groupJid, 'all_member_share') // expose backlog ``` All three are admin-only — non-admins receive a `403 not-authorized` error. ### Disappearing messages `setSetting(groupJid, 'ephemeral', false)` is the explicit disable path. To turn disappearing messages on with a specific lifetime, use `setEphemeralDuration`: ```ts theme={null} // 24h / 7d / 90d in seconds await client.group.setEphemeralDuration(groupJid, 86_400) await client.group.setEphemeralDuration(groupJid, 604_800) await client.group.setEphemeralDuration(groupJid, 7_776_000) // Disable await client.group.setSetting(groupJid, 'ephemeral', false) ``` Admin-only. Passing `0` disables disappearing messages — the same as `setSetting('ephemeral', false)`. ## Invites ```ts theme={null} // Preview an invite code (the path segment of chat.whatsapp.com/) const info = await client.group.queryGroupInviteInfo('AbCdEf...') console.log(info.subject, info.size, info.desc) // info.participants is a trimmed sample — not the full roster. // Call queryGroupMetadata after joining for everyone. // Fetch the current invite code for a group you admin — does NOT rotate it. const code = await client.group.queryInviteCode(groupJid) console.log('current invite:', `https://chat.whatsapp.com/${code}`) // Join via invite code — returns the joined group's metadata const joined = await client.group.joinGroupViaInvite('AbCdEf...') console.log(joined.jid, joined.participants.length) // Rotate the invite — returns the freshly-issued code const { code: rotated, affectedParticipants } = await client.group.revokeInvite(groupJid) console.log('new invite:', `https://chat.whatsapp.com/${rotated}`) // affectedParticipants lists anyone who joined via the now-revoked code // that the server surfaced in the response (typically with code: 404). ``` `queryInviteCode` and `revokeInvite` are admin-only — non-admins receive a `403 not-authorized`. ## Leaving ```ts theme={null} await client.group.leaveGroup([groupJid]) // batched — accepts multiple ``` `leaveGroup` resolves to `void` once the server acknowledges the request. ## Membership approval For groups that require admin approval to join: ```ts theme={null} const requests = await client.group.queryMembershipApprovalRequests(groupJid) await client.group.approveMembershipRequests(groupJid, [requesterJid]) await client.group.rejectMembershipRequests(groupJid, [requesterJid]) // Cancel your own pending request await client.group.cancelMembershipRequests(groupJid, [myJid]) ``` ## Communities Communities are parent groups that link sub-groups: ```ts theme={null} // Create a community const community = await client.group.createCommunity('My community') // Link / unlink existing groups as sub-groups await client.group.linkSubGroups(community.jid, [subGroupJidA, subGroupJidB]) await client.group.unlinkSubGroups(community.jid, [subGroupJidA], { removeOrphanedMembers: true }) // List sub-groups (and the announcement group) const subs = await client.group.fetchSubGroups(community.jid) // Join a linked sub-group you don't yet belong to. // The IQ result carries no group payload — call queryGroupMetadata // on the sub-group after this resolves to get the full metadata. await client.group.joinLinkedGroup(community.jid, subGroupJid) const subMeta = await client.group.queryGroupMetadata(subGroupJid) // Merged participants across the whole community const everyone = await client.group.queryLinkedGroupsParticipants(community.jid) ``` Other community operations include `deactivateCommunity`, `transferCommunityOwnership`, and `fetchSubgroupSuggestions`. ## Group events Changes made by others (subject, participants, settings) arrive on the `group` event: ```ts theme={null} client.on('group', (event) => { console.log(event.action, 'in', event.groupJid) }) ``` See [Events](/en/concepts/events#groups-newsletters--profiles) for the full payload. # Polls, reactions & edits Source: https://zapo.to/en/guides/interactive-messages Send polls and votes, react to messages, pin and edit content, revoke sent messages, and handle the events for each — through the typed content union. Beyond text and media, `client.message.send` accepts a family of typed interactive content objects. Each is discriminated by its `type` field. ## Targeting a message Reply / reaction / revoke / pin / keep / event-response all accept a `WaMessageTargetInput`: either a received `message` event passed **verbatim** (its `key` is used) or an explicit `WaMessageKey`: ```ts theme={null} interface WaMessageKey { remoteJid: string // the chat the target lives in id: string // the target's message (stanza) id fromMe: boolean // was the target sent by you? participant?: string // the author — required in groups when targeting someone else's message } ``` The easiest path is to pass the event you already have — `event.key` is already a `WaMessageKey`: ```ts theme={null} client.on('message', async (event) => { // Use the event itself as the target — its key is read for you. await client.message.send(event.key.remoteJid, { type: 'reaction', emoji: '👍', target: event }) }) ``` ## Reactions ```ts theme={null} // Pass the event verbatim, or an explicit WaMessageKey await client.message.send(jid, { type: 'reaction', emoji: '👍', target: event }) ``` Pass an **empty string** as `emoji` to remove a previous reaction: ```ts theme={null} await client.message.send(jid, { type: 'reaction', emoji: '', target: event }) ``` ## Polls ```ts theme={null} const result = await client.message.send(jid, { type: 'poll', name: 'Lunch?', options: ['Pizza', 'Sushi', 'Salad'], selectableCount: 1, // how many options a voter may pick allowAddOption: false }) ``` Options may be plain strings or `{ name }` objects. **Order matters** — it is used for vote hashing. ### Voting on a poll Voting requires the original poll's identity and its `messageSecret` (32 bytes from the poll's `messageContextInfo.messageSecret`): ```ts theme={null} await client.message.send(jid, { type: 'poll-vote', poll: { id: pollStanzaId, // the poll's stanza id fromMe: false, authorJid: pollAuthorJid, messageSecret: pollMessageSecret, // Uint8Array, 32 bytes participant: pollAuthorJid // required outside 1:1 chats }, selectedOptionNames: ['Pizza'] // exactly as they appeared in the poll }) ``` Incoming votes arrive as [`message_addon`](/en/guides/receiving-messages#addons) events once decrypted. ## Editing a message To edit, send the **new** content and pass `editKey` in the options. The original must be `fromMe`. You can pass the received `message` event verbatim, its `key`, or an explicit `WaSendEditKey` (`{ id, participant?, timestampMs? }`): ```ts theme={null} // Easiest: forward the original event await client.message.send(jid, 'Corrected text', { editKey: originalEvent }) // Or build one explicitly await client.message.send(jid, 'Corrected text', { editKey: { id: originalStanzaId, // participant required in groups for lid/pn-addressed originals participant: undefined } }) ``` The new payload is wrapped in a `MESSAGE_EDIT` protocol message targeting `editKey.id`. ## Revoking (delete for everyone) ```ts theme={null} // Easiest: pass the event you want to revoke await client.message.send(jid, { type: 'revoke', target: event }) // Or build the target explicitly await client.message.send(jid, { type: 'revoke', target: { remoteJid: jid, id: targetStanzaId, fromMe: true, // participant required when an admin revokes someone else's message in a group participant: undefined } }) ``` Sender-vs-admin revoke is auto-detected from `target.fromMe`: `false` triggers an admin revoke. There is no `subtype` option to pass. ## Pinning ```ts theme={null} await client.message.send(jid, { type: 'pin', target: event }) // pin await client.message.send(jid, { type: 'unpin', target: event }) // unpin ``` ## Keep-in-chat For disappearing-message chats, keep (or un-keep) a specific message: ```ts theme={null} await client.message.send(jid, { type: 'keep', target: event }) await client.message.send(jid, { type: 'unkeep', target: event }) ``` ## Events Create a calendar-style event message: ```ts theme={null} await client.message.send(groupJid, { type: 'event', name: 'Team sync', description: 'Weekly catch-up', startTime: Math.floor(Date.now() / 1000) + 3600, // unix seconds location: { latitude: -23.5, longitude: -46.6, name: 'HQ' }, joinLink: 'https://meet.example.com/abc', hasReminder: true, reminderOffsetSec: 600 }) ``` ### Responding to an event ```ts theme={null} await client.message.send(jid, { type: 'event-response', event: { id: eventStanzaId, // the event's stanza id fromMe: false, authorJid: eventAuthorJid, messageSecret: eventMessageSecret // 32 bytes }, response: 'going' // 'going' | 'not_going' | 'maybe' }) ``` ## Locations & contacts There is no dedicated builder for static locations or contact cards yet — send them as a raw `Proto.IMessage`: ```ts theme={null} await client.message.send(jid, { locationMessage: { degreesLatitude: -23.5, degreesLongitude: -46.6 } }) await client.message.send(jid, { contactMessage: { displayName: 'Jane', vcard: 'BEGIN:VCARD\n...\nEND:VCARD' } }) ``` # Media Source: https://zapo.to/en/guides/media Send images, video, audio voice notes, documents, and stickers — and stream or download incoming WhatsApp media attachments with zapo's media helpers. Media is sent through the same `client.message.send` method, using a typed media content object. The builder fills in the protocol-managed fields (encryption keys, SHA-256 digests, direct path, upload) for you — you provide the source and, optionally, a `mimetype`. For **usable** media, install [`@zapo-js/media-utils`](/en/installation#sending-media) and wire a processor through the [`media`](#media-processing) client option. Media still uploads without it, but without a processor it has **no thumbnail/preview, dimensions, or waveform** — so it may arrive as a plain attachment. ## Mimetype resolution `mimetype` is optional. The builder resolves it in this order: 1. The `mimetype` you pass on the content object wins. 2. If a `WaMediaProcessor` with `detectMimetype` is configured, the builder calls it (sniffing magic bytes). `@zapo-js/media-utils` implements this on top of [`file-type`](https://github.com/sindresorhus/file-type) ^19 — install `file-type` to enable detection. 3. Otherwise the builder throws for `image`/`video`/`audio`/`document`/`ptv` messages. Stickers default to `image/webp` when no `mimetype` is set. `Readable` stream inputs with no mimetype are staged to a temp file before detection runs. ## Media input The `media` field accepts several input types: ```ts theme={null} type MediaInput = Uint8Array | ArrayBuffer | Readable | string ``` **Prefer a file path (`string`) or a `Readable` stream over a `Buffer`/`Uint8Array`.** `zapo` streams media through the pipeline without buffering the whole file in memory — passing a path or stream keeps memory flat regardless of file size. Reading a large file into a `Buffer` first defeats that and is discouraged. (`Buffer` is also avoided internally in favor of `Uint8Array`.) ## Images ```ts theme={null} // Preferred — pass a file path; zapo streams it await client.message.send(jid, { type: 'image', media: './photo.jpg', mimetype: 'image/jpeg', caption: 'A photo' }) // Or a Readable stream (e.g. from an HTTP response) import { createReadStream } from 'node:fs' await client.message.send(jid, { type: 'image', media: createReadStream('./photo.jpg'), mimetype: 'image/jpeg' }) ``` ## Video ```ts theme={null} await client.message.send(jid, { type: 'video', media: './clip.mp4', mimetype: 'video/mp4', caption: 'A clip', gifPlayback: false }) ``` For a round **push-to-video** (PTV) message, use `type: 'ptv'` with the same shape. ## Audio & voice notes ```ts theme={null} // Regular audio await client.message.send(jid, { type: 'audio', media: './song.mp3', mimetype: 'audio/mpeg' }) // Voice note (push-to-talk) await client.message.send(jid, { type: 'audio', media: './voice.ogg', mimetype: 'audio/ogg; codecs=opus', ptt: true }) ``` Voice notes render best as Opus in an OGG container. Enable [media processing](#media-processing) to auto-generate waveforms and normalize voice notes. ## Documents ```ts theme={null} await client.message.send(jid, { type: 'document', media: './report.pdf', mimetype: 'application/pdf', fileName: 'Q3 Report.pdf', caption: 'The quarterly report' }) ``` ## Stickers ```ts theme={null} await client.message.send(jid, { type: 'sticker', media: await readFile('./sticker.webp'), mimetype: 'image/webp' }) ``` For a full **sticker pack**, use `type: 'sticker-pack'` with `stickers`, a `trayIcon`, and pack metadata (`stickerPackId`, `name`, `publisher`). ## View-once Wrap image/video/audio as view-once with the send option: ```ts theme={null} await client.message.send(jid, { type: 'image', media: './secret.jpg', mimetype: 'image/jpeg' }, { viewOnce: true }) ``` ## Downloading incoming media The message coordinator decrypts and downloads media from an incoming event. Three flavors are available — **prefer the streaming ones**: ```ts theme={null} client.on('message', async (event) => { if (!event.message?.imageMessage) return // Preferred — stream to a file (constant memory) await client.message.downloadToFile(event, './incoming.jpg') // Or consume the Readable stream yourself const stream = await client.message.download(event) // Avoid for large media — buffers the entire file in memory const bytes = await client.message.downloadBytes(event) }) ``` `download()` / `downloadToFile()` stream the media and keep memory flat regardless of size. `downloadBytes()` materializes the whole file in memory — reach for it only on small media, and cap it with `maxBytes`. All three accept either a `WaIncomingMessageEvent` or a raw `Proto.IMessage`, plus optional `WaDownloadMediaOptions` (for example `maxBytes` to cap `downloadBytes`). ## Media processing For proper media, use a **media processor**. Install `@zapo-js/media-utils` and pass one through the `media` client option — it probes and processes media (dimensions, duration, thumbnails, waveforms, voice-note normalization) before upload. Without it, media still uploads but lacks this processing: ```ts theme={null} import { createMediaProcessor } from '@zapo-js/media-utils' const client = new WaClient({ store, sessionId: 'default', media: { processor: createMediaProcessor(), generateThumbnail: true, generateWaveform: true, normalizeVoiceNote: true } }, logger) ``` `@zapo-js/media-utils` shells out to `ffmpeg`/`ffprobe` and uses `sharp`. Make sure those binaries are available in your environment. # Migrating from Baileys Source: https://zapo.to/en/guides/migrating-from-baileys Coming from Baileys? Here's how the connection lifecycle, store layout, message API, and event names map onto zapo's coordinator-based client. `zapo` is an **independent** implementation of the WhatsApp Web protocol — not a fork of Baileys. The concepts overlap (companion pairing, Signal sessions, an event stream), but the API is different and it is **not** a drop-in replacement. This page maps the patterns you already know. Auth state, message shapes, and method names differ. Plan to rewrite your socket setup and handlers — but the mental model (pair → listen → send) carries over directly. ## Creating the socket Baileys gives you a socket from a factory; zapo gives you a `WaClient` plus an explicit [store](/en/concepts/stores). ```ts theme={null} // Baileys (typical) const { state, saveCreds } = await useMultiFileAuthState('auth') const sock = makeWASocket({ auth: state }) sock.ev.on('creds.update', saveCreds) // zapo import { createStore, WaClient } from 'zapo-js' import { createSqliteStore } from '@zapo-js/store-sqlite' const store = createStore({ backends: { sqlite: createSqliteStore({ path: '.auth/state.sqlite', driver: 'auto' }) }, providers: { auth: 'sqlite', signal: 'sqlite', preKey: 'sqlite', session: 'sqlite', identity: 'sqlite', senderKey: 'sqlite', appState: 'sqlite', privacyToken: 'sqlite', messages: 'none', threads: 'none', contacts: 'none' } }) const client = new WaClient({ store, sessionId: 'default' }, logger) await client.connect() ``` There is **no `creds.update` to save by hand** — the store persists credentials automatically. Pick any backend (SQLite, Postgres, MySQL, Redis, Mongo) on [Installation](/en/installation#add-a-storage-backend). ## Events Baileys multiplexes everything through `sock.ev`; zapo exposes a typed event per concern on `client`. | Baileys | zapo | | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | `sock.ev.on('connection.update', ({ connection, qr, lastDisconnect }) => …)` | `client.on('connection', …)` + `client.on('auth_qr', …)` + `client.on('auth_paired', …)` | | `sock.ev.on('creds.update', saveCreds)` | *(automatic — the store persists creds)* | | `sock.ev.on('messages.upsert', ({ messages }) => …)` | `client.on('message', (event) => …)` | | `sock.ev.on('messages.update', …)` (edits/reactions/polls) | `client.on('message_addon', …)` / `client.on('message_protocol', …)` | | `sock.ev.on('message-receipt.update', …)` | `client.on('receipt', …)` | | `sock.ev.on('groups.update' / 'group-participants.update', …)` | `client.on('group', …)` | | `sock.ev.on('presence.update', …)` | `client.on('presence', …)` / `client.on('chatstate', …)` | The full map is in [Events](/en/concepts/events). Each event is strongly typed via `WaClientEventMap`. ## Sending messages ```ts theme={null} // Baileys await sock.sendMessage(jid, { text: 'hello' }) await sock.sendMessage(jid, { image: { url: './pic.jpg' }, caption: 'hi' }) // zapo await client.message.send(jid, 'hello') // string shorthand for text await client.message.send(jid, { type: 'image', media: './pic.jpg', mimetype: 'image/jpeg', caption: 'hi' }) ``` zapo uses a discriminated [content union](/en/guides/sending-messages#the-content-union) (`{ type: 'image' | 'video' | 'audio' | 'document' | 'poll' | 'reaction' | … }`) instead of Baileys' shape-by-key object. Quoting/mentions move from the content object into the `options` argument (`{ quote, mentions }`). ## API mapping | Baileys | zapo | | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | | `makeWASocket(...)` | `new WaClient(options, logger)` | | `useMultiFileAuthState(...)` | `createStore({ backends, providers })` | | `sock.sendMessage(jid, content)` | `client.message.send(jid, content, options?)` | | `downloadMediaMessage(...)` | `client.message.download(event)` / `downloadToFile(event, path)` | | `sock.groupMetadata(jid)` | `client.group.queryGroupMetadata(jid)` | | `sock.groupCreate(...)` / `groupParticipantsUpdate(...)` | `client.group.createGroup(...)` / `addParticipants` / `removeParticipants` / `promoteParticipants` / … | | `sock.updateProfilePicture(...)` / `updateProfileStatus(...)` | `client.profile.setProfilePicture(...)` / `setStatus(...)` | | `sock.updateBlockStatus(...)` | `client.privacy.blockUser(jid)` / `unblockUser(jid)` | | `sock.sendPresenceUpdate(...)` | `client.presence.send(...)` / `sendChatstate(...)` | | `sock.logout()` | `client.logout()` | | `jidNormalizedUser(...)` / `jidDecode(...)` | `toUserJid(...)` / `splitJid(...)` / `parseJidFull(...)` ([JID helpers](/en/reference/jid-helpers)) | | `proto.Message` | `proto` (exported from the package root) | ## Key differences to keep in mind * **LID-first.** zapo prefers the privacy-preserving [LID](/en/concepts/identities) identity over the phone-number JID. Reply to `event.key.remoteJid`, and prefer LIDs when you have them. * **Coordinator API.** Features are grouped on getters (`client.message`, `client.group`, `client.privacy`, …) instead of flat socket methods — see [Architecture](/en/concepts/architecture). * **Pluggable, typed stores.** Persistence is a first-class layer with official backends, not a JSON folder. See [Stores](/en/concepts/stores). * **No auto-reconnect.** Like Baileys, you drive reconnection — but read [Errors & disconnects](/en/guides/errors) for the reason codes. * **No number registration.** zapo connects with already-paired/registered credentials; it does not register new numbers. # Newsletters (channels) Source: https://zapo.to/en/guides/newsletters Create, discover, follow, post to, react on, and administer WhatsApp channels (newsletters) using the client.newsletter coordinator in zapo. Newsletters — WhatsApp **channels** — live on `client.newsletter` ([`WaNewsletterCoordinator`](/en/reference/client#newsletter)). The coordinator combines three operation sets: **discovery**, **admin**, and **messaging**. Newsletter JIDs end in `@newsletter`. ## Discovery ```ts theme={null} // Fetch metadata by JID or invite code const meta = await client.newsletter.fetch('1234567890@newsletter') const byInvite = await client.newsletter.fetchByInvite('AbCdEf') // Channels the account follows const subscribed = await client.newsletter.listSubscribed() // Search the public directory const results = await client.newsletter.searchDirectory({ /* text, categories */ }) // Recommendations & similar channels const recommended = await client.newsletter.fetchRecommended() const similar = await client.newsletter.fetchSimilar(newsletterJid) ``` ## Following ```ts theme={null} await client.newsletter.follow(newsletterJid) await client.newsletter.unfollow(newsletterJid) await client.newsletter.mute({ newsletterJid, mute: true }) ``` ## Posting `send` takes the same [content union](/en/guides/sending-messages#the-content-union) as a normal message — text, media, polls, and so on: ```ts theme={null} const result = await client.newsletter.send(newsletterJid, 'Hello, subscribers!') // Edit a published message await client.newsletter.editMessage(newsletterJid, result.id, 'Edited') // Revoke it await client.newsletter.revoke({ newsletterJid, originalMessageId: result.id }) ``` ### Reactions & poll votes ```ts theme={null} await client.newsletter.react({ newsletterJid, parentMessageServerId, reactionCode: '🔥' }) await client.newsletter.votePoll({ /* WaNewsletterVotePollInput */ }) await client.newsletter.sendViewReceipt({ /* WaNewsletterViewReceiptInput */ }) ``` ### Reading messages ```ts theme={null} const page = await client.newsletter.fetchMessages({ newsletterJid, count: 50 }) // Edits / reactions / poll updates since a point const updates = await client.newsletter.fetchMessageUpdates({ newsletterJid, count: 50, since: someTimestamp }) // Live updates subscription const { durationSeconds } = await client.newsletter.subscribeLiveUpdates(newsletterJid) ``` ## Administration For channels the account owns: ```ts theme={null} // Create a channel const created = await client.newsletter.create({ name: 'My channel', description: '...' }) // Update editable fields (name / description / picture) await client.newsletter.update(newsletterJid, { name: 'Renamed' }) // Delete await client.newsletter.delete(newsletterJid) // Admin views const adminInfo = await client.newsletter.fetchAdminInfo(newsletterJid) const followers = await client.newsletter.fetchFollowers(newsletterJid) const insights = await client.newsletter.fetchInsights(newsletterJid, metrics) ``` ### Admins & ownership ```ts theme={null} await client.newsletter.createAdminInvite({ /* WaNewsletterAdminInviteInput */ }) await client.newsletter.changeOwner({ /* ... */ }) await client.newsletter.demoteAdmin({ /* ... */ }) ``` ### Poll voters & reaction senders ```ts theme={null} const voters = await client.newsletter.fetchPollVoters({ newsletterJid, messageServerId, voteHash }) const reactors = await client.newsletter.fetchMessageReactionSenders({ newsletterJid, messageServerId }) ``` ## Events Newsletter activity arrives on `newsletter`, and edits/reactions/poll updates on `newsletter_message_update`: ```ts theme={null} client.on('newsletter', (event) => console.log(event)) client.on('newsletter_message_update', (event) => console.log(event)) ``` # Presence & status Source: https://zapo.to/en/guides/presence-status Broadcast online presence, send typing and recording indicators, subscribe to contact presence, and post WhatsApp status updates with text and media. ## Own presence Broadcast whether the account is online with `client.presence.send`: ```ts theme={null} await client.presence.send('available') // appears online await client.presence.send('unavailable') // appears offline ``` The presence announced right after connecting is controlled by the [`markOnlineOnConnect`](/en/concepts/configuration#presence-on-connect) option. ## Typing indicators (chat-state) `sendChatstate` sends a per-chat hint such as typing or recording: ```ts theme={null} // "typing…" await client.presence.sendChatstate(jid, { state: 'composing' }) // "recording audio…" await client.presence.sendChatstate(jid, { state: 'recording' }) // clear the indicator await client.presence.sendChatstate(jid, { state: 'paused' }) ``` A common pattern is to show typing briefly before replying: ```ts theme={null} await client.presence.sendChatstate(jid, { state: 'composing' }) await new Promise((r) => setTimeout(r, 1200)) await client.message.send(jid, 'Done thinking!') await client.presence.sendChatstate(jid, { state: 'paused' }) ``` ## Subscribing to a contact To receive a contact's presence and chat-state, subscribe to them: ```ts theme={null} await client.presence.subscribe(jid) client.on('presence', (event) => { console.log(event.type, event.lastSeen) }) client.on('chatstate', (event) => { console.log(event.state, 'from', event.participantJid) }) ``` Subscriptions are **per-JID** and live only for the current connection. After a [reconnect](/en/guides/reconnection) you must re-subscribe to keep receiving updates. ## Status broadcasts Post a status (the "stories" feature) with `client.status` ([`WaStatusCoordinator`](/en/reference/client#status)). The content is the same [content union](/en/guides/sending-messages#the-content-union) as a normal message; you provide the recipient list: ```ts theme={null} const result = await client.status.send({ content: 'Hello from my status!', recipients: ['5511999999999@s.whatsapp.net', '5511888888888@s.whatsapp.net'] }) ``` Media works too: ```ts theme={null} await client.status.send({ content: { type: 'image', media: './story.jpg', mimetype: 'image/jpeg' }, recipients }) ``` ### Status privacy & mute ```ts theme={null} // Who can see your status await client.status.setPrivacy({ /* WaSetStatusPrivacyInput */ }) // Mute a contact's status await client.status.setUserMuted(jid, true) // Revoke a status you posted await client.status.revokeStatus({ messageId, recipients }) ``` # Production & deployment Source: https://zapo.to/en/guides/production Run zapo reliably in production: durable persistence, graceful shutdown, scaling multiple sessions, reconnection strategy, and the knobs that matter. `zapo` is built for long-lived, multi-session workloads. This page collects the operational decisions that matter once you move past a local prototype. ## Persist credentials (don't re-pair) Production sessions **must** use a durable [store](/en/concepts/stores) for the `auth` and Signal domains, plus a **stable `sessionId`** across restarts. The in-memory store loses everything on exit, forcing a re-pair on every boot. ```ts theme={null} const client = new WaClient({ store, sessionId: 'tenant-42' }, logger) ``` Changing `sessionId` orphans the previous credentials — treat it as the durable key for a device/account. ## Choose a store backend | Backend | Best for | | -------------------------------------------------- | ------------------------------------------------------------------ | | `@zapo-js/store-sqlite` | Single process / single host — the simplest, fastest local option. | | `@zapo-js/store-postgres` · `@zapo-js/store-mysql` | Multiple hosts, relational ops, managed backups. | | `@zapo-js/store-redis` | Low-latency cache + persistence. | | `@zapo-js/store-mongo` | Document-oriented deployments. | You can mix backends per domain (e.g. `auth`/`signal` in Postgres, caches in Redis). See [Installation](/en/installation#add-a-storage-backend) and the [stores reference](/en/reference/stores). ## Graceful shutdown Call `disconnect()` (never `logout()`) on shutdown — it flushes pending [write-behind](/en/concepts/configuration#write-behind-persistence) data and closes the socket **without** unlinking the device, so the next boot resumes from the store. ```ts theme={null} for (const signal of ['SIGINT', 'SIGTERM'] as const) { process.on(signal, async () => { await client.disconnect() process.exit(0) }) } ``` `logout()` unlinks the device server-side and clears stored state — it forces a full re-pair. Use it only to permanently disconnect, never as a shutdown hook. ## Run many sessions A single store can hold many independent accounts, each keyed by `sessionId`. Create one `WaClient` per account: ```ts theme={null} const clients = tenants.map((id) => new WaClient({ store, sessionId: id }, logger)) await Promise.all(clients.map((c) => c.connect())) ``` Each client pairs, reconnects, and emits events independently. Budget memory/CPU per session (each holds Signal state and in-memory caches), and shard across processes/hosts as you scale — one process per session is the simplest isolation model. See [multi-tenancy](/en/concepts/configuration#sessions-and-multi-tenancy). ## Tune for throughput * **Write-behind** batches incoming message/thread/contact writes off the hot path. Tune `writeBehind.maxPendingKeys` / `maxWriteConcurrency` / `flushTimeoutMs` to your database. ([config](/en/concepts/configuration#write-behind-persistence)) * **History sync** (`history.enabled`) is on by default and adds a large initial download. Set it to `false` if you don't persist mailbox/threads/contacts; set `requireFullSync` deliberately. * **Bots that shouldn't appear online**: `markOnlineOnConnect` defaults to `false`, so bots are invisible on connect out of the box. Pass `true` only when you want a visible "online" presence. * **Timeouts** (`iqTimeoutMs`, `keepAliveIntervalMs`, `deadSocketTimeoutMs`, …) ship with production defaults — override only with a reason. ([config](/en/concepts/configuration#timeouts)) ## Reconnection & error policy `zapo` does **not** auto-reconnect — own the policy. Wire a backoff loop ([Reconnection](/en/guides/reconnection)) and classify failures ([Errors & disconnects](/en/guides/errors)) so you stop on fatal reasons (`banned`, `not_authorized`, logout) instead of hammering the server. ## Logging Use a structured logger in production: ```ts theme={null} const logger = await createPinoLogger({ level: 'info', pretty: false }) ``` `pretty: false` emits JSON lines suited to log aggregators. Drop to `debug` / `trace` only when investigating. ## Security & versioning * **Credentials are secrets.** `WaAuthCredentials` holds the device keys — if you persist them outside the built-in store, encrypt at rest. ([Authentication](/en/concepts/authentication#credentials)) * **Never enable `dangerous.*`** in production — those flags disable security checks. ([config](/en/concepts/configuration#dangerous-options)) * **Pin exact versions.** `zapo` is pre-1.0; breaking changes are expected until the first major release. # Profile, privacy & business Source: https://zapo.to/en/guides/profile-privacy Manage your WhatsApp profile, change privacy settings, edit the blocklist, and read business profiles and hours with the profile coordinator. ## Profile `client.profile` ([`WaProfileCoordinator`](/en/reference/client#profile)) reads and writes profile fields for your account and looks them up for others. ### Profile picture ```ts theme={null} import { readFile } from 'node:fs/promises' // Read someone's picture (or your own) const pic = await client.profile.getProfilePicture(jid) // Set your own await client.profile.setProfilePicture(await readFile('./avatar.jpg')) // Remove it await client.profile.deleteProfilePicture() ``` ### About / status text ```ts theme={null} const about = await client.profile.getStatus(jid) await client.profile.setStatus('Available') ``` ### Push name `pushName` is the display name peers see for your account in chats and group participant lists. The change is queued through an app-state mutation and propagates the next time you send a message — there's no immediate broadcast. ```ts theme={null} await client.profile.setPushName('Alice') ``` Passing an empty string resets the name to the device fingerprint default. ### Default disappearing mode `setDisappearingMode` sets the account-wide default lifetime applied to **new** 1:1 chats you start. Existing chats keep their per-chat setting. ```ts theme={null} // 0 disables, 86400 = 24h, 604800 = 7d, 7776000 = 90d await client.profile.setDisappearingMode(604_800) // Disable await client.profile.setDisappearingMode(0) ``` For per-group disappearing messages, see [Groups → disappearing messages](/en/guides/groups#disappearing-messages). ### Batched lookups ```ts theme={null} const profiles = await client.profile.getProfiles([jidA, jidB]) const usernames = await client.profile.getUsernames([jidA, jidB]) const modes = await client.profile.getDisappearingMode([jidA, jidB]) ``` ### Check if a number is on WhatsApp Resolve phone numbers to their [LID](/en/concepts/identities) and learn whether each is registered on WhatsApp: ```ts theme={null} const results = await client.profile.getLidsByPhoneNumbers(['+55 11 99999-9999']) // → [{ phoneJid: '5511999999999@s.whatsapp.net', lidJid: '…@lid', exists: true }] for (const r of results) { if (r.exists) console.log(r.phoneJid, 'is on WhatsApp →', r.lidJid) } ``` ### Usernames ```ts theme={null} const mine = await client.profile.getOwnUsername() const available = await client.profile.checkUsernameAvailability('myhandle') await client.profile.setUsername({ username: 'myhandle' }) await client.profile.deleteUsername() ``` ## Privacy `client.privacy` ([`WaPrivacyCoordinator`](/en/reference/client#privacy)) controls privacy categories and the blocklist. ### Privacy settings ```ts theme={null} const settings = await client.privacy.getPrivacySettings() // Update a single setting await client.privacy.setPrivacySetting('last', 'contacts') ``` Setting names and values come from the `WA_PRIVACY_*` constants (`last`, `online`, `profile`, `status`, `readreceipts`, `groupadd`, …). See the [JID & constants reference](/en/reference/jid-helpers#constants). ### Blocklist ```ts theme={null} const { jids } = await client.privacy.getBlocklist() await client.privacy.blockUser(jid) await client.privacy.unblockUser(jid) ``` ### Disallowed lists For settings scoped to a specific list of contacts (e.g. "share with everyone except…"): ```ts theme={null} const result = await client.privacy.getDisallowedList(category) ``` ## Business `client.business` ([`WaBusinessCoordinator`](/en/reference/client#business)) reads business profiles and verified names, and manages your own business profile. Business account — required to **edit** your own profile or cover photo; the read methods work for any account. ```ts theme={null} // Read business profiles (batched) const profiles = await client.business.getBusinessProfile([jidA, jidB]) // Verified-name lookups const name = await client.business.getVerifiedName(jid) const names = await client.business.getVerifiedNames([jidA, jidB]) // Edit your own business profile await client.business.editBusinessProfile({ /* WaEditBusinessProfileInput */ }) // Cover photo await client.business.updateCoverPhoto(mediaSource) await client.business.deleteCoverPhoto(coverId) ``` ### Typed business hours `WaBusinessHoursDay` and `WaBusinessHoursMode` are union aliases (`'sun' | 'mon' | …`, `'open_24h' | 'specific_hours' | 'appointment_only'`). The values are also frozen on `WA_BUSINESS_HOURS_DAYS` and `WA_BUSINESS_HOURS_MODES`. Passing an unknown `mode` to `editBusinessProfile` now throws a clear local error instead of the server replying with `406 not-acceptable` — closed days are still expressed by **omitting** them from `config`, not by a dedicated mode. ```ts theme={null} import { WA_BUSINESS_HOURS_DAYS, WA_BUSINESS_HOURS_MODES } from 'zapo-js' await client.business.editBusinessProfile({ businessHours: { timezone: 'America/Sao_Paulo', config: [ { dayOfWeek: WA_BUSINESS_HOURS_DAYS.MON, mode: WA_BUSINESS_HOURS_MODES.SPECIFIC_HOURS, openTime: 540, // minutes from midnight; 540 = 09:00 closeTime: 1080 // 18:00 }, { dayOfWeek: WA_BUSINESS_HOURS_DAYS.SAT, mode: WA_BUSINESS_HOURS_MODES.OPEN_24H } // sun is closed — omit it ] } }) ``` ## Chat settings Per-chat settings — mute, pin, archive, read, lock, star, clear, delete — live on `client.chat` and sync across your devices. They have their own guide: Mute, pin, archive, mark read, lock, star messages, clear, and delete chats. # Receiving messages Source: https://zapo.to/en/guides/receiving-messages Handle incoming message events: extract text and media, send delivery and read receipts, decrypt addons, and request older history. Incoming messages arrive on the `message` event as a `WaIncomingMessageEvent`. ```ts theme={null} import type { WaIncomingMessageEvent } from 'zapo-js' client.on('message', (event: WaIncomingMessageEvent) => { // ... }) ``` ## The event payload `WaIncomingMessageEvent` carries a rich `key` (a superset of `Proto.IMessageKey`) plus a few top-level fields. Pass the event (or just its `key`) verbatim to reply / edit / react / revoke / pin / keep. | Field | Type | Description | | ------------------------------------------------------ | ---------------- | -------------------------------------------------------------------------------------------- | | `key.remoteJid` | `string` | The conversation JID (group or 1:1). | | `key.id` | `string` | The message (stanza) id. | | `key.fromMe` | `boolean` | True when the message was sent by this account. | | `key.participant` | `string?` | The author in groups / broadcasts (omitted in 1:1). | | `key.isGroup` / `key.isBroadcast` / `key.isNewsletter` | `boolean` | Chat-kind flags derived from `remoteJid`. | | `key.remoteJidAlt` | `string?` | The `remoteJid`'s alternate addressing (PN if addressed by LID, or vice-versa) in 1:1 chats. | | `key.participantAlt` | `string?` | The `participant`'s alternate addressing in group chats. | | `key.senderDevice` | `number` | Sender's device id; `0` when the source JID has no `:device` segment. | | `key.senderUsername` | `string?` | Sender's username when the server attached it. | | `key.recipientJid` / `key.recipientAlt` | `string?` | Your receiving JID and its alternate form. | | `key.serverId` | `number?` | Server-assigned message id for newsletter / channel messages. | | `message` | `Proto.IMessage` | The decrypted message content. | | `timestampSeconds` | `number?` | Server timestamp (unix seconds). | | `expirationSeconds` | `number?` | Disappearing-message TTL the sender attached to this message, when present. | | `pushName` | `string?` | The sender's display name. | You also receive your **own** outgoing messages here (multi-device sync), flagged with `key.fromMe === true`. Filter them out if you only want inbound traffic. The whole event (or `event.key`) is accepted as the `target` for replies/reactions/revokes/pins/keeps and as `editKey` for edits — no need to reshape it. ## Extracting text A message's text lives in different fields depending on its type. A small helper covers the common cases: ```ts theme={null} function extractText(message?: Proto.IMessage | null): string | undefined { if (!message) return undefined return ( message.conversation ?? message.extendedTextMessage?.text ?? message.imageMessage?.caption ?? message.videoMessage?.caption ?? undefined ) } client.on('message', (event) => { const text = extractText(event.message) if (text) console.log(`${event.pushName}: ${text}`) }) ``` ## Identifying the message type `message` is a protobuf union — inspect which field is set: ```ts theme={null} client.on('message', (event) => { const m = event.message if (!m) return if (m.conversation || m.extendedTextMessage) console.log('text') else if (m.imageMessage) console.log('image') else if (m.videoMessage) console.log('video') else if (m.audioMessage) console.log('audio') else if (m.documentMessage) console.log('document') else if (m.stickerMessage) console.log('sticker') else if (m.pollCreationMessage) console.log('poll') else if (m.locationMessage) console.log('location') }) ``` To download media from an image/video/audio/document message, see [Media → downloading](/en/guides/media#downloading-incoming-media). ## Sending receipts `client.message.sendReceipt` marks messages as received/read/played. The easiest form takes the event(s) directly: ```ts theme={null} client.on('message', async (event) => { // mark as read await client.message.sendReceipt(event, { type: 'read' }) }) ``` You can also pass an array of events, or address it manually by JID and ids: ```ts theme={null} await client.message.sendReceipt(chatJid, [id1, id2], { type: 'read' }) ``` ## Calls Read-only Incoming call signaling surfaces as the read-only `call` event ([`WaIncomingCallEvent`](/en/concepts/events)). zapo **reports** calls — it does not place, accept, or reject them. ```ts theme={null} client.on('call', (event) => { console.log( event.type, // 'offer' | 'accept' | 'terminate' | … | 'unknown' event.isVideo ? 'video' : 'voice', 'from', event.callerPnJid ?? event.callCreatorJid, event.groupJid ? `(group ${event.groupJid})` : '' ) }) ``` Useful fields: `type` (the signaling stage), `callId`, `callCreatorJid` / `callerPnJid` (who's calling), `isVideo`, `groupJid` (group calls), and `callerPushName`. There is no API to answer a call. ## Addons **Addons** are encrypted follow-ups attached to a message: reactions, poll votes, and comments. They surface as the `message_addon` event. ### Automatic decryption Addons are decrypted and emitted for you by default — just subscribe to `message_addon`: ```ts theme={null} const client = new WaClient({ store, sessionId: 'default' }, logger) client.on('message_addon', (event) => { console.log('addon:', event) }) ``` ### Manual decryption Pass `addons: { autoDecrypt: false }` to receive the encrypted payload and decrypt on demand from the originating message event: ```ts theme={null} const client = new WaClient({ store, sessionId: 'default', addons: { autoDecrypt: false } }, logger) client.on('message', async (event) => { await client.message.tryDecryptAddon(event) }) ``` ## Protocol messages Edits, revokes, and other protocol-level updates arrive on `message_protocol` as `WaIncomingProtocolMessageEvent` (it extends the message event with a `protocolMessage` field): ```ts theme={null} client.on('message_protocol', (event) => { console.log(event.protocolMessage) }) ``` ## Requesting older history The initial pairing flow streams a bounded window of message history. To pull older messages for a specific chat on demand, call `client.message.requestHistorySync`: ```ts theme={null} const { messageId } = await client.message.requestHistorySync({ chatJid, oldestMsgId: topMessage.key.id, oldestMsgFromMe: topMessage.key.fromMe, oldestMsgTimestampMs: topMessage.timestampSeconds * 1000, count: 50 }) ``` The method returns once the request is dispatched — **not** when the chunk arrives. The backfill is delivered later as a `history_sync_chunk` event, same as the bootstrap history. Subscribe before calling if you need to react to it: ```ts theme={null} client.on('history_sync_chunk', (event) => { // event.conversations, event.pushnamesCount, event.progress, ... }) await client.message.requestHistorySync({ chatJid }) ``` Pair `oldestMsgId`, `oldestMsgFromMe`, and `oldestMsgTimestampMs` from the topmost message currently visible to page backwards correctly. Omit `count` to let the server apply its own default (\~50). ## Receipts (inbound) When others read or play your messages, you receive `receipt` events: ```ts theme={null} client.on('receipt', (event) => { // event.status: 'delivered' | 'read' | 'played' | 'inactive' console.log(event.status, 'for', event.stanzaId) }) ``` `receipt` events still expose `stanzaId` / `chatJid` directly (they extend `WaIncomingBaseEvent`); the rename only applies to `message`, `message_addon`, and `message_bot_chunk` payloads, which now use `event.key`. # Recipes Source: https://zapo.to/en/guides/recipes Copy-paste patterns for the most common things you'll build with zapo — command bots, media handling, threaded replies, and group moderation tasks. Short, complete patterns built on the real API. They assume you already have a connected `client` — see the [Quickstart](/en/quickstart) for setup. ## Extract text from any message Incoming text can arrive as a plain `conversation` or an `extendedTextMessage` (when it has a reply/preview). Normalize both: ```ts theme={null} function getText(message: { conversation?: string | null; extendedTextMessage?: { text?: string | null } | null } | null | undefined) { return message?.conversation ?? message?.extendedTextMessage?.text ?? undefined } ``` ## Command router Parse a leading `/command` and dispatch. Skip your own outgoing messages with `event.key.fromMe`: ```ts theme={null} client.on('message', async (event) => { if (event.key.fromMe) return // ignore our own sends (multi-device echo) const text = getText(event.message)?.trim() const to = event.key.remoteJid if (!text || !text.startsWith('/') || !to) return const [command, ...args] = text.slice(1).split(/\s+/) switch (command) { case 'ping': await client.message.send(to, 'pong') break case 'echo': await client.message.send(to, args.join(' ') || '(nothing to echo)') break default: await client.message.send(to, `Unknown command: ${command}`) } }) ``` ## Reply with a quote and a mention ```ts theme={null} client.on('message', async (event) => { if (event.key.fromMe) return const to = event.key.remoteJid const sender = event.key.participant ?? event.key.remoteJid await client.message.send( to, { type: 'text', text: 'got it 👍' }, { quote: event, mentions: sender ? [sender] : [] } ) }) ``` ## Auto-download incoming media Stream straight to disk — never buffer large files in memory: ```ts theme={null} client.on('message', async (event) => { if (!event.message?.imageMessage) return const file = `./media/${Date.now()}.jpg` await client.message.downloadToFile(event, file) console.log('saved', file) }) ``` See [Media › Downloading incoming media](/en/guides/media#downloading-incoming-media) for video/audio/documents and `maxBytes`. ## Welcome new group members The `group` event fires on membership changes. Greet everyone added (`action: 'add'`) and @-mention them: ```ts theme={null} client.on('group', async (event) => { if (event.action !== 'add' || !event.groupJid || !event.participants?.length) return const jids = event.participants.map((p) => p.jid).filter((j): j is string => Boolean(j)) const mentions = jids.map((j) => `@${j.split('@')[0]}`).join(' ') await client.message.send( event.groupJid, { type: 'text', text: `Welcome ${mentions}! 🎉` }, { mentions: jids } ) }) ``` ## Send a poll ```ts theme={null} await client.message.send(chatJid, { type: 'poll', name: 'Lunch?', options: ['Pizza', 'Sushi', 'Salad'], selectableCount: 1 }) ``` ## React to a message ```ts theme={null} client.on('message', async (event) => { if (event.key.fromMe) return // Pass the event verbatim — its key is read for you. await client.message.send(event.key.remoteJid, { type: 'reaction', emoji: '❤️', target: event }) }) ``` ## Keep the bot alive across drops `zapo` doesn't auto-reconnect — wire the `connection` event to a backoff loop. The full pattern (including when **not** to reconnect) is in [Reconnection](/en/guides/reconnection) and [Errors & disconnects](/en/guides/errors). Every snippet uses the [content union](/en/guides/sending-messages#the-content-union) — the same shapes `client.message.send` accepts everywhere. See [Sending messages](/en/guides/sending-messages) and the [message types reference](/en/reference/message-types) for the full set. # Reconnection Source: https://zapo.to/en/guides/reconnection zapo does not auto-reconnect by design — follow this pattern to detect dropped sessions, rebuild the client, and resume without duplicate connections. `WaClient` does **not** reconnect automatically. This is a deliberate design choice: reconnection policy (backoff, max retries, alerting) belongs to your application. You listen for `connection: close` and decide what to do. ## Internal recovery layers The "no auto-reconnect" rule applies to the **session lifecycle**: once a `connection: close` event fires, the client will not reopen by itself. Two lower-level retries do live inside the stack though – you will see them in logs and you do not need to handle them. * **WebSocket transport.** If the socket drops *before* a successful noise handshake, `WaComms` retries internally every `reconnectIntervalMs` (default `2000`) up to `maxReconnectAttempts`. Once the handshake completes, the counter resets and any subsequent drop surfaces as a `connection` event for your app to handle. * **Pairing transition.** Right after a QR/code pair succeeds, the client restarts the socket as a registered session. No `connection: close` event fires for this – it is invisible from the outside. The `client_too_old` (HTTP 405) recovery covered below is a third, opt-in layer. ## Connection lifecycle ```mermaid theme={null} stateDiagram-v2 [*] --> Connecting: connect() Connecting --> Open: opened Connecting --> Closed: failed Open --> Closed: connection close Closed --> Connecting: reconnect (isLogout = false) Open --> [*]: disconnect() Closed --> [*]: isLogout = true (re-pair) ``` ## The connection event `connection` is a discriminated union on `status`: ```ts theme={null} client.on('connection', (event) => { if (event.status === 'open') { console.log('connected', { isNewLogin: event.isNewLogin }) return } // status === 'close' console.log('disconnected', { reason: event.reason, code: event.code, isLogout: event.isLogout }) }) ``` On `close`: * **`isLogout: true`** — the device was unlinked (server-side logout). Do **not** reconnect; the credentials are gone and you must re-pair. * **`isLogout: false`** — a transient drop. Safe to reconnect with the stored credentials. ## A reconnection loop with backoff ```ts theme={null} const MAX_ATTEMPTS = 10 async function connectWithRetry(client: WaClient) { let attempt = 0 client.on('connection', (event) => { if (event.status === 'open') { attempt = 0 // reset backoff on a healthy connection return } if (event.isLogout) { console.error('logged out — re-pairing required') return } void reconnect() }) async function reconnect() { if (attempt >= MAX_ATTEMPTS) { console.error('giving up after', attempt, 'attempts') return } const delayMs = Math.min(30_000, 1_000 * 2 ** attempt) attempt += 1 console.log(`reconnecting in ${delayMs}ms (attempt ${attempt})`) await new Promise((r) => setTimeout(r, delayMs)) try { await client.connect() } catch (err) { console.error('reconnect failed', err) void reconnect() } } await client.connect() } ``` ## Recovering from `client_too_old` (HTTP 405) If the server starts rejecting the noise handshake with `failure_client_too_old`, the bundled WA Web version is out of date. Two options: * Set [`recoverFromClientTooOld: true`](/en/concepts/configuration#whatsapp-web-version) on `WaClient` — on every 405 the client fetches the current version from `web.whatsapp.com`, swaps it in, and reconnects. * Pass a [`version` resolver](/en/concepts/configuration#whatsapp-web-version) that returns a fresh string per connect. Both are stopgaps — upgrade zapo when a release ships with a refreshed default. ## After reconnecting Some state is **connection-scoped** and must be re-established after a successful reconnect: * **Presence subscriptions** — re-`subscribe()` to any contacts you were watching ([Presence](/en/guides/presence-status#subscribing-to-a-contact)). * **Newsletter live updates** — re-`subscribeLiveUpdates()` if you rely on them. Persisted state (credentials, Signal sessions, [app-state](/en/reference/glossary#app-state)) is restored from the [store](/en/concepts/stores) automatically — you do **not** re-pair on a normal reconnect. ## Graceful shutdown Call `disconnect()` for a clean shutdown that keeps credentials so you can resume later: ```ts theme={null} process.on('SIGINT', async () => { await client.disconnect() process.exit(0) }) ``` This flushes pending write-behind data and closes the socket without unlinking the device. # Sending messages Source: https://zapo.to/en/guides/sending-messages Send WhatsApp text, threaded replies, mentions, and rich link previews with client.message.send, the typed entry point for all outgoing content. All outgoing content goes through a single method: ```ts theme={null} client.message.send(to, content, options?): Promise ``` * **`to`** — the recipient JID (`5511999999999@s.whatsapp.net`, a group `...@g.us`, etc.). See [JID helpers](/en/reference/jid-helpers) for building these. * **`content`** — a string, a typed content object, or a raw `Proto.IMessage`. * **`options`** — quoting, mentions, forwarding, view-once, edits, and more. The promise resolves to a `WaMessagePublishResult` once the server acks: ```ts theme={null} const result = await client.message.send(jid, 'Hello!') console.log(result.id) // the message id (stanza id) ``` ## Plain text The simplest content is a string: ```ts theme={null} await client.message.send(jid, 'Hello from zapo!') ``` For more control, use the text object form — it lets you attach context info and tune link previews: ```ts theme={null} await client.message.send(jid, { type: 'text', text: 'Check this out: https://example.com', linkPreview: true // auto-fetch a preview }) ``` ## Replying (quoting) Pass the original message event (or a reference) as `options.quote`: ```ts theme={null} client.on('message', async (event) => { await client.message.send(event.key.remoteJid, 'Replying to you', { quote: event }) }) ``` The quote is rendered as a reply bubble referencing the original message. ## Mentions `options.mentions` is a list of JIDs to tag. Include the matching `@number` text in the body so WhatsApp renders the mention: ```ts theme={null} await client.message.send(groupJid, { type: 'text', text: 'Hey @5511999999999, welcome!' }, { mentions: ['5511999999999@s.whatsapp.net'] }) ``` ## Link previews Link-preview behavior is controlled per message via the text object's `linkPreview` field: | Value | Behavior | | ----------- | ------------------------------------------------------------ | | `undefined` | Follow the global `linkPreview` default. | | `false` | Disable the preview. | | `true` | Force auto-fetch of the preview. | | object | Skip the fetch and use the provided preview fields directly. | ```ts theme={null} // Provide your own preview instead of fetching await client.message.send(jid, { type: 'text', text: 'https://example.com', linkPreview: { title: 'Example', description: 'My custom preview' } }) ``` Configure the default fetcher globally with the `linkPreview` client option. ## Forwarding Set `options.forward` to mark a message as forwarded: ```ts theme={null} await client.message.send(jid, 'Forwarded text', { forward: true }) // or with a frequently-forwarded score await client.message.send(jid, content, { forward: { score: 4 } }) ``` ## Send options reference `WaSendMessageOptions` (third argument) includes: | Option | Type | Purpose | | ----------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `quote` | `WaIncomingMessageEvent \| WaQuoteRef \| WaMessageKey` | Reply to a message — pass the event verbatim, its `key`, or a `WaQuoteRef`. | | `mentions` | `string[]` | JIDs to mention. | | `forward` | `boolean \| { score }` | Mark as forwarded. | | `viewOnce` | `boolean` | Wrap image/video/audio as view-once. | | `editKey` | `WaMessageKey \| WaSendEditKey \| WaMessageRef` | Edit a previously sent message (see [interactive](/en/guides/interactive-messages#editing-a-message)). | | `expirationSeconds` | `number` | Disappearing-message TTL for this message. **Wins over** `contextInfo.expirationSeconds` and over the automatic group-ephemeral inject (the latter is short-circuited as soon as this is defined — even `0`). | | `disableGroupEphemeralAutoInject` | `boolean` | Skip the automatic ephemeral-setting injection when sending into a group with disappearing-mode on. Redundant when `expirationSeconds` is set. | | `contextInfo` | `WaSendContextInfo` | Raw context info (advanced). | | `id` | `string` | Use a specific message id. | | `ackTimeoutMs` / `maxAttempts` / `retryDelayMs` | `number` | Per-send retry tuning. | To send a single message with **no** expiration into a group with disappearing-mode on, prefer `disableGroupEphemeralAutoInject: true` over `expirationSeconds: 0` — the latter still writes `expiration=0` into the outgoing `contextInfo`. ## The content union `content` accepts any `WaSendMessageContent`. The typed variants are documented across these guides: Images, video, audio, documents, stickers. Polls, votes, reactions, pins, edits, revokes, events. You can always drop down to a raw `Proto.IMessage` for anything not covered by a typed builder: ```ts theme={null} import { proto } from 'zapo-js' await client.message.send(jid, { conversation: 'Raw protobuf message' }) ``` ### Location and contact Message types without a typed builder are sent as raw `Proto.IMessage` fields — for example **location** and **contact**: ```ts theme={null} // Location await client.message.send(jid, { locationMessage: { degreesLatitude: -23.5613, degreesLongitude: -46.6565, name: 'Av. Paulista', address: 'São Paulo, BR' } }) // Contact (vCard) const vcard = [ 'BEGIN:VCARD', 'VERSION:3.0', 'FN:Jeff Singh', 'TEL;type=CELL;type=VOICE;waid=5511999999999:+55 11 99999-9999', 'END:VCARD' ].join('\n') await client.message.send(jid, { contactMessage: { displayName: 'Jeff', vcard } }) // Multiple contacts await client.message.send(jid, { contactsArrayMessage: { displayName: '2 contacts', contacts: [{ displayName: 'Jeff', vcard }] } }) ``` The full set of recognized `Proto.IMessage` fields (location, live location, contacts, group invite, product, order, …) is listed in the [message types reference](/en/reference/message-types). # Installation Source: https://zapo.to/en/installation Install zapo-js with npm, pnpm, or yarn, choose a storage backend for credentials, and add the optional peer dependencies your features depend on. ## Requirements `zapo` requires **Node.js `>= 20.9.0`**. The package ships dual ESM/CJS builds and full TypeScript types. ## Install the core package ```bash npm theme={null} npm install zapo-js ``` ```bash pnpm theme={null} pnpm add zapo-js ``` ```bash yarn theme={null} yarn add zapo-js ``` The core package has **no mandatory runtime dependencies**. Everything else — storage, logging, and the WebSocket transport — is an opt-in peer dependency, so you only install what you use. ## The package ecosystem `zapo-js` is the only required install. Everything else is an optional `@zapo-js/*` package you add as needed: ## Add a storage backend `zapo` persists authentication and Signal state through a pluggable [store](/en/concepts/stores). Pick the backend that matches your deployment and install its package: | Package | Backend | Best for | | ------------------------- | ----------------------------- | ----------------------- | | `@zapo-js/store-sqlite` | SQLite (via `better-sqlite3`) | Local / single-process | | `@zapo-js/store-postgres` | PostgreSQL | Distributed, relational | | `@zapo-js/store-mysql` | MySQL | Distributed, relational | | `@zapo-js/store-redis` | Redis | Cache + persistence | | `@zapo-js/store-mongo` | MongoDB | Document store | ```bash SQLite theme={null} npm install @zapo-js/store-sqlite better-sqlite3 ``` ```bash PostgreSQL theme={null} npm install @zapo-js/store-postgres pg ``` ```bash MySQL theme={null} npm install @zapo-js/store-mysql mysql2 ``` ```bash Redis theme={null} npm install @zapo-js/store-redis ioredis ``` ```bash MongoDB theme={null} npm install @zapo-js/store-mongo mongodb ``` You can also run with no backend at all — the built-in **memory** store works out of the box and is great for tests. It just does not survive a process restart, so you would re-pair on every boot. ## Optional peer dependencies Install these only if you use the corresponding feature: ```bash Structured logging theme={null} npm install pino pino-pretty ``` ```bash WebSocket proxy theme={null} npm install ws ``` ```bash Mobile connections theme={null} npm install argo-codec ``` * **`pino` + `pino-pretty`** — required only if you use [`createPinoLogger`](/en/concepts/configuration#logging). Without them, the built-in `ConsoleLogger` is used. * **`ws`** — only needed to route the WebSocket through a **proxy**. The runtime's native `WebSocket` can't take an HTTP `Agent`/dispatcher, so `zapo` falls back to `ws` for the `proxy.ws` leg. Without a proxy, the built-in `WebSocket` is used and you don't need this package. * **`argo-codec`** — only needed for **mobile** connections (for now). The standard companion (QR / pairing-code) flow does not use it. ## Sending media **`@zapo-js/media-utils` is effectively required to send usable media.** Media still uploads without it, but there's no processor to generate **thumbnails/previews, image-video dimensions, or voice-note waveforms** — so it can render as a plain attachment or with no preview. Install it whenever your app sends images, video, audio, documents, or stickers. ```bash theme={null} npm install @zapo-js/media-utils ``` It shells out to `ffmpeg`/`ffprobe` and uses `sharp`, so make sure those binaries are available. See the [media guide](/en/guides/media#media-processing) for how to wire the processor into the client. `@zapo-js/media-utils` also lists [`file-type`](https://github.com/sindresorhus/file-type) (`^19`) as an **optional** peer dependency. Install it (`npm install file-type`) to enable automatic mimetype detection — without it, the [media guide's mimetype resolution](/en/guides/media#mimetype-resolution) falls back to requiring an explicit `mimetype` on each send. ## Verify your setup ```ts theme={null} import { WaClient } from 'zapo-js' console.log(typeof WaClient) // "function" ``` Next, head to the [quickstart](/en/quickstart) to connect and send your first message. # Introduction Source: https://zapo.to/en/introduction zapo is a high-performance TypeScript implementation of the WhatsApp Web protocol, built for high-scale, multi-session bot and automation workloads. Zapo `zapo` (published on npm as [`zapo-js`](https://www.npmjs.com/package/zapo-js)) is an independent, runtime implementation of the WhatsApp Web protocol written in TypeScript. It is **not** a wrapper or fork of an existing WhatsApp client library — the protocol source of truth is the deobfuscated WhatsApp Web client, and the goal is behavior parity with WhatsApp Web while improving CPU, memory, and allocation efficiency. **Stability notice.** `zapo` is pre-`1.0`. Breaking changes are expected until the first major release. Pin exact versions in long-lived deployments and validate upgrades carefully. ## Why zapo Every feature area is a focused coordinator: `client.message`, `client.group`, `client.newsletter`, `client.privacy`, and more. One `createStore` factory, per-domain provider selection, and official backends for SQLite, PostgreSQL, MySQL, Redis, and MongoDB. Every query is scoped by `sessionId`, so a single process can drive many accounts — built for multi-tenant workloads. `Uint8Array` everywhere, zero-copy in hot paths, bounded in-memory structures, async I/O, and synchronous crypto (bar elliptic-curve ops) for raw throughput. ## Design principles These principles drive every implementation decision in the codebase: * **index-first** — protocol behavior is validated against WhatsApp Web before anything is implemented. * **performance-first** — optimize for low CPU, low RAM, low allocations, and zero-copy in hot paths. * **async-first I/O** — I/O and network operations are asynchronous. Crypto, by contrast, runs **synchronously** — only elliptic-curve operations are async. Keeping the rest of crypto sync delivered a large, measurable throughput gain. ## Requirements * **Node.js** `>= 20.9.0` * A package manager (`npm`, `pnpm`, or similar) * No mandatory runtime dependencies — backends and logging are opt-in peer dependencies. ## Get started Install `zapo-js`, pick a storage backend, and wire up optional peers. Connect, scan a QR code, and reply to your first message in minutes. Understand the client, coordinators, stores, and event flow. Text, replies, mentions, media, polls, reactions, and more. ## Disclaimer This project is an independent implementation for engineering and interoperability research. It is **not** affiliated with or endorsed by WhatsApp. # Quickstart Source: https://zapo.to/en/quickstart Connect a WhatsApp session, scan the QR code or use a pairing code, and reply to your first incoming message in under five minutes with zapo. This guide builds a minimal "ping → pong" bot. It connects, prints a QR code to pair, and replies `pong` whenever it receives `ping`. ```bash theme={null} npm install zapo-js @zapo-js/store-sqlite better-sqlite3 pino pino-pretty ``` The [store](/en/concepts/stores) persists auth and Signal state. This example uses SQLite, writing to `.auth/state.sqlite`. ```ts theme={null} import { createPinoLogger, createStore, WaClient } from 'zapo-js' import { createSqliteStore } from '@zapo-js/store-sqlite' const logger = await createPinoLogger({ level: 'info', pretty: true }) const store = createStore({ backends: { sqlite: createSqliteStore({ path: '.auth/state.sqlite', driver: 'auto' }) }, providers: { auth: 'sqlite', signal: 'sqlite', preKey: 'sqlite', session: 'sqlite', identity: 'sqlite', senderKey: 'sqlite', appState: 'sqlite', privacyToken: 'sqlite', messages: 'sqlite', threads: 'sqlite', contacts: 'sqlite' } }) const client = new WaClient( { store, sessionId: 'default', connectTimeoutMs: 15_000, nodeQueryTimeoutMs: 30_000, history: { enabled: true, requireFullSync: true } }, logger ) ``` On a fresh session the client emits `auth_qr`. Render the value as a QR code (for example with the [`qrcode-terminal`](https://www.npmjs.com/package/qrcode-terminal) package) and scan it from **WhatsApp → Linked devices**. ```ts theme={null} client.on('auth_qr', ({ qr, ttlMs }) => { console.log('Scan this QR within', ttlMs, 'ms:') console.log(qr) }) client.on('auth_paired', ({ credentials }) => { console.log('Paired as', credentials.meJid) }) ``` Prefer an **8-character pairing code** instead of a QR? See [Authentication](/en/concepts/authentication#pairing-with-a-code). Listen for the `message` event and send a reply with `client.message.send`. ```ts theme={null} function extractText(message) { return ( message?.conversation ?? message?.extendedTextMessage?.text ?? undefined ) } client.on('message', async (event) => { const text = extractText(event.message) if (text?.trim().toLowerCase() !== 'ping') return await client.message.send(event.key.remoteJid, 'pong') }) ``` ```ts theme={null} await client.connect() ``` `connect()` resolves once the socket is open. The first connection drives pairing; subsequent connections reuse the stored credentials. ## Full example ```ts theme={null} import { createPinoLogger, createStore, WaClient } from 'zapo-js' import { createSqliteStore } from '@zapo-js/store-sqlite' const logger = await createPinoLogger({ level: 'info', pretty: true }) const store = createStore({ backends: { sqlite: createSqliteStore({ path: '.auth/state.sqlite', driver: 'auto' }) }, providers: { auth: 'sqlite', signal: 'sqlite', preKey: 'sqlite', session: 'sqlite', identity: 'sqlite', senderKey: 'sqlite', appState: 'sqlite', privacyToken: 'sqlite', messages: 'sqlite', threads: 'sqlite', contacts: 'sqlite' } }) const client = new WaClient({ store, sessionId: 'default' }, logger) client.on('auth_qr', ({ qr }) => console.log(qr)) client.on('connection', (event) => console.log('connection:', event.status, event.reason)) client.on('message', async (event) => { const text = event.message?.conversation ?? event.message?.extendedTextMessage?.text if (text?.trim().toLowerCase() !== 'ping') return await client.message.send(event.key.remoteJid, 'pong') }) await client.connect() ``` ## What's next Replies, mentions, link previews, and the full content union. Parse incoming events, send receipts, and decrypt addons. `zapo` does not auto-reconnect — here's the pattern to handle it. Sessions, history sync, timeouts, proxy, and more. # Chat mutations (app-state) Source: https://zapo.to/en/reference/chat-mutations Every client.chat operation — the typed convenience helpers and the generic set and remove calls over the full app-state schema surface in zapo. `client.chat` is the `WaAppStateMutationCoordinator`. It writes **app-state mutations** — the per-chat and per-account settings WhatsApp syncs across all your linked devices (mute, pin, archive, read, labels, contacts, …). There are two layers: 1. **Typed convenience helpers** for the common operations (`setChatMute`, `setChatPin`, …). 2. A **generic `set` / `remove`** that works against *any* registered app-state schema — for everything without a dedicated helper. Mutations made elsewhere arrive back as [`mutation`](/en/concepts/events#state-history--mex) events. ## Convenience helpers | Method | Signature | Effect | | --------------------- | ------------------------------------------------------------ | ------------------------------------------------- | | `setChatMute` | `(chatJid, muted, muteEndTimestampMs?) => Promise` | Mute/unmute a chat, optionally until a timestamp. | | `setChatPin` | `(chatJid, pinned) => Promise` | Pin/unpin a chat. | | `setChatArchive` | `(chatJid, archived) => Promise` | Archive/unarchive a chat. | | `setChatRead` | `(chatJid, read) => Promise` | Mark a chat read/unread. | | `setChatLock` | `(chatJid, locked) => Promise` | Lock/unlock a chat. | | `setMessageStar` | `(message: WaAppStateMessageKey, starred) => Promise` | Star/unstar a message. | | `clearChat` | `(chatJid, options?: WaClearChatOptions) => Promise` | Clear a chat's messages. | | `deleteChat` | `(chatJid, options?: WaDeleteChatOptions) => Promise` | Delete a chat. | | `deleteMessageForMe` | `(message: WaAppStateMessageKey, options?) => Promise` | Delete a message for yourself only. | | `setStatusPrivacy` | `(input: WaSetStatusPrivacyInput) => Promise` | Set who can see your status. | | `setUserStatusMute` | `(jid, muted) => Promise` | Mute/unmute a contact's status. | | `setBroadcastList` | `(input: WaSetBroadcastListInput) => Promise` | Create/update a broadcast list. | | `removeBroadcastList` | `(id) => Promise` | Delete a broadcast list. | ### Examples ```ts theme={null} // Mute for 8 hours await client.chat.setChatMute(chatJid, true, Date.now() + 8 * 3600_000) await client.chat.setChatMute(chatJid, false) // unmute await client.chat.setChatPin(chatJid, true) await client.chat.setChatArchive(chatJid, true) await client.chat.setChatRead(chatJid, true) await client.chat.setChatLock(chatJid, true) ``` ```ts theme={null} // Clear / delete a chat await client.chat.clearChat(chatJid, { deleteStarred: false, deleteMedia: true }) await client.chat.deleteChat(chatJid, { deleteMedia: true }) ``` A `WaAppStateMessageKey` identifies a single message: ```ts theme={null} interface WaAppStateMessageKey { chatJid: string id: string fromMe: boolean participantJid?: string // group sender } await client.chat.setMessageStar( { chatJid, id: stanzaId, fromMe: false, participantJid: senderJid }, true ) await client.chat.deleteMessageForMe( { chatJid, id: stanzaId, fromMe: false }, { deleteMedia: true } ) ``` ### Option shapes ```ts theme={null} interface WaClearChatOptions { deleteStarred?: boolean; deleteMedia?: boolean } interface WaDeleteChatOptions { deleteMedia?: boolean } interface WaDeleteMessageForMeOptions { deleteMedia?: boolean; messageTimestampMs?: number } ``` ### Status & broadcast lists ```ts theme={null} await client.chat.setStatusPrivacy({ mode: 'contacts', // distribution mode userJids: [], // for allow/deny modes shareToFB: false }) await client.chat.setUserStatusMute(contactJid, true) await client.chat.setBroadcastList({ id: 'list-1', listName: 'Customers', participants: [{ lidJid, pnJid }], labelIds: ['label-1'] }) await client.chat.removeBroadcastList('list-1') ``` ## Generic `set` / `remove` For schemas without a helper, use `set` (with value fields) or `remove` (index only). The input is **flat**: pick a `schema` name, then fill the schema's index fields (`id`, `chatJid`, `labelId`, …) and value fields side by side. The coordinator routes them to the correct `SyncActionValue` subfield. ```ts theme={null} set(input: WaSetMutationInput): Promise remove(input: WaRemoveMutationInput): Promise ``` ```ts theme={null} // Add a contact to the address book await client.chat.set({ schema: 'Contact', id: '5511999999999@s.whatsapp.net', contactAction: { fullName: 'Maria Silva', firstName: 'Maria' } }) // Create a chat label (color is a server-side palette index) await client.chat.set({ schema: 'LabelEdit', id: 'label-1', labelEditAction: { name: 'Pending', color: 0, isActive: true } }) // Apply that label to a chat await client.chat.set({ schema: 'LabelJid', labelId: 'label-1', chatJid: '5511999999999@s.whatsapp.net', labelAssociationAction: { labeled: true } }) // Save a business quick reply await client.chat.set({ schema: 'QuickReply', id: 'qr-greeting', quickReplyAction: { shortcut: '/hi', message: 'Hi! How can I help?' } }) ``` `remove` takes the same shape minus the value fields: ```ts theme={null} await client.chat.remove({ schema: 'Contact', id: '5511999999999@s.whatsapp.net' }) await client.chat.remove({ schema: 'LabelJid', labelId: 'label-1', chatJid }) await client.chat.remove({ schema: 'QuickReply', id: 'qr-greeting' }) ``` The value-field name (`contactAction`, `labelEditAction`, …) matches the schema's `SyncActionValue` subfield. ## All schemas Every key below is a valid `schema` for `set` / `remove`. Schemas with a ✓ also have a typed convenience helper. ### Chat actions | Schema | Helper | Purpose | | ----------------------------------------------- | ---------------------- | ------------------------------------ | | `Mute` | ✓ `setChatMute` | Mute a chat. | | `Pin` | ✓ `setChatPin` | Pin a chat. | | `Archive` | ✓ `setChatArchive` | Archive a chat. | | `Star` | ✓ `setMessageStar` | Star a message. | | `MarkChatAsRead` | ✓ `setChatRead` | Mark read/unread. | | `ClearChat` | ✓ `clearChat` | Clear messages. | | `DeleteChat` | ✓ `deleteChat` | Delete a chat. | | `DeleteMessageForMe` | ✓ `deleteMessageForMe` | Delete a message for me. | | `ChatLockSettings` / `LockChat` | ✓ `setChatLock` | Chat lock. | | `UnarchiveChatsSetting` | | Global unarchive-on-message setting. | | `ChatAssignment` / `ChatAssignmentOpenedStatus` | | Agent chat assignment. | | `Favorites` | | Favorite chats. | ### Contacts | Schema | Purpose | | ----------------------------- | ------------------------------- | | `Contact` | Address-book contact. | | `LidContact` / `OutContact` | LID / outgoing contact records. | | `PnForLidChat` / `ShareOwnPn` | Phone-number ↔ LID linkage. | ### Labels | Schema | Purpose | | ----------------- | ------------------------------------------- | | `LabelEdit` | Create/edit/delete a label definition. | | `LabelJid` | Associate/disassociate a label with a chat. | | `LabelReordering` | Reorder labels. | ### Status & calls | Schema | Helper | Purpose | | ------------------- | --------------------- | ---------------------------- | | `StatusPrivacy` | ✓ `setStatusPrivacy` | Status distribution privacy. | | `UserStatusMute` | ✓ `setUserStatusMute` | Mute a contact's status. | | `VoipRelayAllCalls` | | Relay-all-calls privacy. | | `CallLog` | | Call log entries. | ### Stickers | Schema | Purpose | | --------------------- | -------------------- | | `FavoriteSticker` | Favorite stickers. | | `RemoveRecentSticker` | Remove from recents. | ### Business & marketing | Schema | Helper | Purpose | | -------------------------------------------------------------------------- | -------------------------------------------- | ------------------------------- | | `BusinessBroadcastList` | ✓ `setBroadcastList` / `removeBroadcastList` | Broadcast lists. | | `QuickReply` | | Business quick replies. | | `BotWelcomeRequest` | | Bot welcome message. | | `BusinessBroadcastCampaign` / `BusinessBroadcastInsights` | | Broadcast campaigns & insights. | | `MarketingMessage` / `MarketingMessageBroadcast` | | Marketing messages. | | `AdsCtwaPerCustomerDataSharing` / `CustomerData` / `DetectedOutcomeStatus` | | Ads / CTWA data. | | `BizAiSettingsNudge` / `Agent` | | Business AI / agent. | ### Payments | Schema | Purpose | | ------------------------------------------------- | --------------------- | | `PaymentInfo` / `PaymentTos` | Payment info & terms. | | `CustomPaymentMethods` / `MerchantPaymentPartner` | Payment methods. | | `SubscriptionsSyncV2` | Subscriptions. | ### AI threads | Schema | Purpose | | --------------------------------------------------- | --------------------- | | `AiThreadDelete` / `AiThreadPin` / `AiThreadRename` | AI thread management. | ### Settings & system | Schema | Purpose | | -------------------------------------------------- | ------------------------------- | | `SettingPushName` / `SettingsSync` | Push name & settings. | | `TimeFormat` / `LocaleSetting` | Time format & locale. | | `DisableLinkPreviews` | Link-preview toggle. | | `PrimaryFeature` / `PrimaryVersion` | Primary-device feature/version. | | `Nux` / `NoteEdit` | New-user experience / notes. | | `DeviceCapabilities` / `AndroidUnsupportedActions` | Device capability sync. | | `InteractiveMessageAction` | Interactive message action. | | `AvatarUpdated` | Avatar update marker. | | `ExternalWebBeta` / `WaffleAccountLinkState` | Web beta / account linking. | | `NctSaltSync` / `Sentinel` | Internal sync bookkeeping. | | `Favorites` / `CustomerData` | Misc. | Several schemas (`Sentinel`, `NctSaltSync`, `PrimaryVersion`, `DeviceCapabilities`, …) are managed internally by the sync engine. They are listed for completeness because the type system accepts them, but writing them by hand can desync app-state — prefer the convenience helpers and the documented business schemas. ## Syncing | Method | Signature | Purpose | | -------------------------- | --------------------------------------------- | --------------------------------------------- | | `sync` | `(options?) => Promise` | Run an app-state sync round. | | `flushMutations` | `() => Promise` | Flush queued mutations to the server now. | | `getBlockedCollections` | `(syncResult) => readonly string[]` | Collections blocked during a sync. | | `emitEventsFromSyncResult` | `(syncResult) => void` | Re-emit `mutation` events from a sync result. | # WaClient & coordinators Source: https://zapo.to/en/reference/client Complete method reference for WaClient and every coordinator: auth, message, presence, chat, group, newsletter, profile, and more. This page lists **every** public method on `WaClient` and its coordinators, with code-grounded descriptions. Dedicated pages go deeper for [message types](/en/reference/message-types), [chat mutations](/en/reference/chat-mutations), and the [low-level API](/en/reference/low-level). ## WaClient ```ts theme={null} new WaClient(options: WaClientOptions, logger?: Logger) ``` See [Configuration](/en/concepts/configuration) for every option. | Member | Signature | Description | | --------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `connect` | `() => Promise` | Opens the socket and runs the Noise handshake; drives pairing on first run. Resolves once connected. | | `disconnect` | `() => Promise` | Flushes pending write-behind and closes the socket, keeping credentials. | | `logout` | `(reason?: WaLogoutReason) => Promise` | Unlinks this companion device server-side; then clears stored state per [`logoutStoreClear`](/en/concepts/configuration#logout-store-clearing). Throws if not authenticated. | | `getState` | `() => WaAuthState` | Current auth/connection state. | | `getCredentials` | `() => WaAuthCredentials \| null` | Current credentials, if paired. | | `getClockSkewMs` | `() => number \| null` | Estimated server clock skew (from keep-alive), or `null`. | | `on` / `once` / `off` | `(event, listener) => this` | Typed event emitter over [`WaClientEventMap`](/en/concepts/events). | ### Coordinator getters | Getter | Type | Section | | --------------- | ------------------------------- | ---------------------------------------- | | `auth` | `WaAuthClient` | [auth](#auth) | | `message` | `WaMessageCoordinator` | [message](#message) | | `presence` | `WaPresenceCoordinator` | [presence](#presence) | | `chat` | `WaAppStateMutationCoordinator` | [chat](#chat) | | `group` | `WaGroupCoordinator` | [group](#group) | | `status` | `WaStatusCoordinator` | [status](#status) | | `broadcastList` | `WaBroadcastListCoordinator` | [broadcastList](#broadcastlist) | | `newsletter` | `WaNewsletterCoordinator` | [newsletter](#newsletter) | | `privacy` | `WaPrivacyCoordinator` | [privacy](#privacy) | | `profile` | `WaProfileCoordinator` | [profile](#profile) | | `business` | `WaBusinessCoordinator` | [business](#business) | | `bot` | `WaBotCoordinator` | [bot](#bot) | | `email` | `WaEmailCoordinator` | [email](#email) | | `lowlevel` | `WaLowLevelCoordinator` | [low-level API](/en/reference/low-level) | *** ## auth `client.auth` (`WaAuthClient`). Pairing is mostly event-driven ([Authentication](/en/concepts/authentication)); these are the user-facing entry points. | Method | Signature | Description | | ---------------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `requestPairingCode` | `(phoneNumber, shouldShowPushNotification?, customCode?) => Promise` | Requests an 8-char pairing code (link-code flow). The client must already be connected — call after the `auth_pairing_required` event. `customCode` suggests a code; the server may return a different one. | | `fetchPairingCountryCodeIso` | `() => Promise` | The ISO country code the server resolved for the account. | | `getState` | `(connected?) => { connected, registered, hasQr, hasPairingCode }` | Auth readiness flags. | | `getCurrentCredentials` | `() => WaAuthCredentials \| null` | Loaded credentials, or `null`. | *** ## message `WaMessageCoordinator` — see [Sending](/en/guides/sending-messages) & [Receiving](/en/guides/receiving-messages). | Method | Signature | Description | | -------------------------- | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `send` | `(to, content: WaSendMessageContent, options?: WaSendMessageOptions) => Promise` | Sends any [content type](/en/reference/message-types); handles device fanout and per-send retries. Returns the stanza id + ack metadata. | | `sendReceipt` | `(event\|events, options?)` / `(jid, ids, options?) => Promise` | Sends a delivery/read/played/inactive receipt. Delivery is auto-acked on decrypt; use this for manual read/played. | | `requestHistorySync` | `(input: WaRequestHistorySyncInput) => Promise<{ messageId }>` | Asks the server to backfill older messages for a chat. Resolves once dispatched — the backlog arrives later as `history_sync_chunk`. See [Requesting older history](/en/guides/receiving-messages#requesting-older-history). | | `download` | `(source, options?) => Promise` | Streams decrypted media (MAC + SHA-256 verified as consumed). Cancel via `options.signal`. | | `downloadToFile` | `(source, filePath, options?) => Promise` | Streams decrypted media to a file. | | `downloadBytes` | `(source, options?) => Promise` | Buffers decrypted media into memory — small media only; cap with `options.maxBytes`. | | `tryDecryptAddon` | `(event) => Promise` | Decrypts an addon (poll vote, reaction, …) and emits `message_addon`. Auto-called by default; opt out with `addons: { autoDecrypt: false }` to call it yourself. | | `syncSignalSession` | `(jid, reasonIdentity?) => Promise` | Force-refreshes the Signal session(s) for a JID; `reasonIdentity` also reissues the trusted-contact token. | | `getReachoutTimelock` | `() => Promise` | Server-side timelock that throttles cold outreach to non-contacts. | | `getNewChatMessageCapping` | `(type?) => Promise` | Per-cycle message quota applied to new-chat threads (quota, used, cycle, status). | `source` is a `WaIncomingMessageEvent` or a raw `Proto.IMessage`. *** ## presence `WaPresenceCoordinator` — see [Presence & status](/en/guides/presence-status). | Method | Signature | Description | | --------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `send` | `(type?: 'available' \| 'unavailable') => Promise` | Broadcasts your online/offline presence. | | `sendChatstate` | `(jid, options) => Promise` | Sends a typing/recording/paused hint into a chat. | | `subscribe` | `(jid, options?) => Promise` | Subscribes to a contact's presence/chat-state. Per-jid and per-connection — **re-subscribe after reconnect**. | *** ## chat `WaAppStateMutationCoordinator` — full reference (incl. the generic `set`/`remove` and all schemas) in [Chat mutations](/en/reference/chat-mutations). | Method | Signature | | --------------------------------------------------------------- | -------------------------------------------------------- | | `setChatMute` | `(chatJid, muted, muteEndTimestampMs?) => Promise` | | `setChatPin` / `setChatArchive` / `setChatRead` / `setChatLock` | `(chatJid, boolean) => Promise` | | `setMessageStar` | `(message, starred) => Promise` | | `clearChat` / `deleteChat` | `(chatJid, options?) => Promise` | | `deleteMessageForMe` | `(message, options?) => Promise` | | `setStatusPrivacy` / `setUserStatusMute` | `(input) / (jid, muted) => Promise` | | `setBroadcastList` / `removeBroadcastList` | `(input) / (id) => Promise` | | `set` / `remove` | `(input) => Promise` | | `sync` / `flushMutations` | `(options?) / () => Promise<…>` | | `getBlockedCollections` / `emitEventsFromSyncResult` | `(syncResult) => …` | Pin and archive are mutually exclusive (pinning clears archive and vice-versa); locking clears both. `clearChat`/`deleteChat`/`deleteMessageForMe` are **local-only** (your devices) — use a [revoke](/en/guides/interactive-messages#revoking-delete-for-everyone) to delete for everyone. A mute timer doesn't auto-unmute client-side. *** ## group `WaGroupCoordinator` — see [Groups & communities](/en/guides/groups). Participant ops return one [`WaParticipantActionResult`](/en/guides/groups#managing-participants) per jid — the IQ succeeds as a whole even when some entries fail, so check each result's `status` / `code`. | Method | Signature | Description | | -------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `queryGroupMetadata` | `(groupJid) => Promise` | Full group metadata. | | `queryAllGroups` | `() => Promise` | Every group the account is in. | | `queryGroupInviteInfo` | `(code) => Promise` | Preview an invite code (subject, size, ephemeral, description, trimmed participant sample). | | `createGroup` | `(subject, participants, options?) => Promise` | Create a group (you're auto-added as admin; don't include your own JID). Returns the new group's metadata. | | `setSubject` | `(groupJid, subject) => Promise` | Rename. | | `setDescription` | `(groupJid, description\|null, prevDescId?) => Promise` | Set/clear description. | | `setSetting` | `(groupJid, setting, enabled) => Promise` | Toggle a boolean group flag (`announcement`, `restrict`, `ephemeral`, `group_history`, `allow_admin_reports`, `no_frequently_forwarded`, community flags). | | `setMemberAddMode` | `(groupJid, 'admin_add' \| 'all_member_add') => Promise` | Restrict member adds to admins (or open to all). Admin op. | | `setMemberLinkMode` | `(groupJid, 'admin_link' \| 'all_member_link') => Promise` | Restrict invite-link sharing to admins (or open to all). Admin op. | | `setMemberShareGroupHistoryMode` | `(groupJid, 'admin_share' \| 'all_member_share') => Promise` | Hide or expose prior chat history to newly added members. Admin op. | | `setEphemeralDuration` | `(groupJid, expirationSeconds, trigger?) => Promise` | Turn on disappearing messages with a specific lifetime (`86400` = 24h, `604800` = 7d, `7776000` = 90d). Use `setSetting('ephemeral', false)` to disable. Admin op. | | `addParticipants` / `removeParticipants` | `(groupJid, jids) => Promise` | Add / remove members. One result per jid; inspect `status` / `code` for partial failures. | | `promoteParticipants` / `demoteParticipants` | `(groupJid, jids) => Promise` | Grant / revoke admin. Same per-jid result shape. | | `leaveGroup` | `(groupJids) => Promise` | Leave one or more groups (batched). | | `queryInviteCode` | `(groupJid) => Promise` | Fetch the current invite code (the path segment of `chat.whatsapp.com/`) **without** rotating it. Admin op — non-admins get `403 not-authorized`. | | `revokeInvite` | `(groupJid) => Promise` | Rotate the invite code — every old `chat.whatsapp.com/` link stops working. Returns the new `code` plus any `affectedParticipants`. | | `joinGroupViaInvite` | `(code) => Promise` | Join via code. Throws if expired/revoked/full/already a member. Returns the joined group's metadata. | | `createCommunity` | `(subject, options?) => Promise` | Create a community (request-required unless `membershipApprovalMode: 'open'`). | | `deactivateCommunity` | `(communityJid) => Promise` | Delete a community. | | `linkSubGroups` / `unlinkSubGroups` | `(communityJid, jids, options?) => Promise<…>` | Link / unlink sub-groups (`removeOrphanedMembers` evicts orphaned members). | | `queryLinkedGroupsParticipants` | `(communityJid) => Promise` | Merged participants across a community. | | `fetchSubGroups` | `(communityJid) => Promise` | List sub-groups (MEX). | | `joinLinkedGroup` | `(communityJid, subGroupJid, options?) => Promise` | Join a linked sub-group. Call `queryGroupMetadata` afterward for the full metadata. | | `queryMembershipApprovalRequests` | `(groupJid) => Promise` | Pending join requests. | | `approveMembershipRequests` / `rejectMembershipRequests` | `(groupJid, jids) => Promise` | Approve / reject requests. | | `cancelMembershipRequests` | `(groupJid, jids) => Promise` | Cancel your own pending requests. | | `isInternalGroup` | `(groupJid) => Promise` | `true` for internal WhatsApp groups (MEX). | | `transferCommunityOwnership` | `(communityJid, newOwnerJid) => Promise` | Hand off community ownership (MEX). | | `fetchSubgroupSuggestions` | `(communityJid, hintSubgroupJid) => Promise` | Suggested sub-groups (MEX). | | `submitGroupSuspensionAppeal` | `(groupJid, options?) => Promise` | Appeal a suspension (MEX). | Methods marked (MEX) require an active [MEX](/en/reference/glossary#mex) transport and throw when it's unavailable. *** ## newsletter `WaNewsletterCoordinator` — see [Newsletters](/en/guides/newsletters). Composed of discovery, admin, and messaging ops. ### Discovery | Method | Signature | Description | | --------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------ | | `fetch` / `fetchByInvite` | `(jid\|code, options?) => Promise` | Metadata by JID or invite code. | | `fetchDehydrated` | `(keyOrInvite, options?) => Promise` | Lightweight metadata (no image/followers). | | `listSubscribed` | `(options?) => Promise` | Channels you follow. | | `searchDirectory` | `(options?) => Promise` | Search the public directory. | | `fetchRecommended` | `(options?) => Promise` | Recommended channels. | | `fetchSimilar` | `(jid, options?) => Promise` | Channels similar to one. | | `fetchDirectoryList` | `(options) => Promise` | Paged directory by country/category. | | `fetchDirectoryCategoriesPreview` | `(options) => Promise` | Category carousel previews. | | `fetchIsDomainPreviewable` | `(domains) => Promise>` | Which domains support link previews. | ### Admin | Method | Signature | Description | | ----------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | | `create` | `(input) => Promise` | Create a channel (auto-accepts creation TOS; `picture` uploaded inline — keep small). | | `update` | `(jid, input) => Promise` | Edit name/description/picture. | | `delete` | `(jid) => Promise` | **Irreversible** delete — followers detached, history dropped, JID burned. | | `fetchAdminInfo` | `(jid) => Promise` | Admin-only metadata view. | | `fetchAdminCapabilities` | `(jid) => Promise>` | Capabilities granted to the account. | | `fetchFollowers` | `(jid, options?) => Promise` | Paged follower list. | | `fetchInsights` | `(jid, metrics) => Promise<… \| null>` | Admin analytics. | | `fetchReports` | `() => Promise<… \| null>` | Moderation reports against owned channels. | | `fetchPendingInvites` | `(jid) => Promise` | Pending admin invite JIDs. | | `fetchEnforcements` | `(jid) => Promise<… \| null>` | Moderation enforcement state. | | `fetchPollVoters` | `(input) => Promise>` | Poll voters grouped by option. | | `fetchMessageReactionSenders` | `(input) => Promise` | Reaction senders grouped by emoji. | | `createAdminInvite` | `(input) => Promise` | Invite a user as admin. | | `acceptAdminInvite` | `(jid) => Promise` | Accept a pending admin invite (auto-accepts TOS). | | `revokeAdminInvite` | `(input) => Promise` | Revoke a sent admin invite. | | `changeOwner` | `(input) => Promise` | Transfer ownership to an invited admin. | | `demoteAdmin` | `(input) => Promise` | Demote an admin to follower. | | `queryTosState` / `acceptTos` | `(noticeIds) => Promise<…>` | Query / accept TOS notices. | | `logExposures` | `(exposures) => Promise` | Report capability exposures (telemetry). | ### Messaging | Method | Signature | Description | | --------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------- | | `send` | `(jid, content, options?) => Promise` | Publish a message (any content type). | | `editMessage` | `(jid, parentMessageId, content) => Promise` | Edit a published message. | | `react` / `revoke` / `votePoll` / `sendViewReceipt` | `(input) => Promise<{ stanzaId }>` | React / revoke / vote / view-receipt. | | `fetchMessages` / `fetchMessageUpdates` | `(input) => Promise` | Page messages / fetch edits-reactions-votes in a range. | | `subscribeLiveUpdates` | `(jid) => Promise<{ durationSeconds }>` | Subscribe to live updates (**re-subscribe after reconnect**). | | `follow` / `unfollow` | `(jid) => Promise` | Follow / unfollow. | | `mute` | `(input) => Promise` | Mute / unmute. | *** ## privacy `WaPrivacyCoordinator` — see [Privacy](/en/guides/profile-privacy#privacy). | Method | Signature | Description | | --------------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `getPrivacySettings` | `() => Promise` | Current value of every privacy category. | | `setPrivacySetting` | `(setting, value) => Promise` | Update one category. A `contact_blacklist`-style value flips the mode only — populate the list separately via the disallowed-list + app-state. | | `getDisallowedList` | `(category) => Promise` | Per-category excluded JIDs. | | `getBlocklist` | `() => Promise` | Account-wide blocklist. | | `blockUser` / `unblockUser` | `(jid) => Promise` | Block / unblock. A block stops the peer messaging/calling you and hides your last-seen/online/photo/status from them. | *** ## profile `WaProfileCoordinator` — see [Profile](/en/guides/profile-privacy#profile). | Method | Signature | Description | | --------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `getProfilePicture` | `(jid, type?, existingId?) => Promise` | Picture envelope (URL + direct path + id). | | `setProfilePicture` | `(imageBytes, targetJid?) => Promise` | Set your/a target's picture. `imageBytes` is uploaded **as-is** — pre-encode square JPEG. Returns the picture id. | | `deleteProfilePicture` | `(targetJid?) => Promise` | Remove the picture (admin op for groups). | | `getStatus` / `setStatus` | `(jid) / (text) => Promise<…>` | Get/set the legacy "About". | | `setPushName` | `(name) => Promise` | Update the display name broadcast to peers. Routed through an app-state mutation; the new name reaches peers with your next outgoing message. Empty string resets to the device default. | | `getProfiles` | `(jids) => Promise` | Batched picture id + status. | | `getDisappearingMode` | `(jids) => Promise` | Batched disappearing-mode setting. | | `setDisappearingMode` | `(durationSeconds) => Promise` | Set the account-wide default disappearing-mode duration for **new** 1:1 chats (`0`/`86400`/`604800`/`7776000`). Existing chats keep their setting. | | `getTextStatuses` | `(jids) => Promise` | Batched modern text status (emoji + text). | | `setTextStatus` | `(input) => Promise` | Set your modern text status; `text: null`/`''` clears it. | | `getUsernames` | `(jids) => Promise` | Batched username lookup. | | `getOwnUsername` | `() => Promise` | Your username record (value, state, recovery pin). | | `setUsername` | `(input) => Promise` | Reserve a username. Returns `true` only on `SUCCESS`; otherwise `false` (taken/invalid/rate-limited) without throwing. | | `deleteUsername` | `() => Promise` | Delete your username. | | `checkUsernameAvailability` | `(username) => Promise` | Availability + suggestions. | | `setUsernameKey` | `(pin) => Promise` | Set the username recovery PIN. | | `getAboutStatus` | `(jid) => Promise` | "About" text via MEX. | | `getLidsByPhoneNumbers` | `(phoneNumbers) => Promise` | Resolve [LIDs](/en/concepts/identities#mapping-a-phone-number-to-its-lid) for phone numbers. | *** ## status `WaStatusCoordinator` — see [Status broadcasts](/en/guides/presence-status#status-broadcasts). | Method | Signature | Description | | -------------- | --------------------------------------------------------------- | ------------------------------- | | `send` | `(input: WaSendStatusInput) => Promise` | Publish a status to recipients. | | `revokeStatus` | `(input) => Promise` | Revoke a published status. | | `setPrivacy` | `(input) => Promise` | Account-wide status privacy. | | `setUserMuted` | `(jid, muted) => Promise` | Mute/unmute a contact's status. | *** ## broadcastList `WaBroadcastListCoordinator`. | Method | Signature | Description | | ------------ | ----------------------------------------------------------------------------- | ----------------------------------------- | | `setList` | `(input: WaSetBroadcastListInput) => Promise` | Create/update a list (name + recipients). | | `removeList` | `(id) => Promise` | Delete a list. | | `send` | `(input: WaSendBroadcastListMessageInput) => Promise` | Send to every member. | Broadcast lists are **business-only** (backed by the `BusinessBroadcastList` app-state schema); regular accounts have the mutations rejected. *** ## business `WaBusinessCoordinator` — see [Business](/en/guides/profile-privacy#business). | Method | Signature | Description | | -------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------- | | `getBusinessProfile` | `(jids) => Promise` | Batched business profiles (about, address, hours). Works from any account. | | `getVerifiedName` / `getVerifiedNames` | `(jid) / (jids) => Promise<…>` | Verified-name lookup (single / batched). | | `editBusinessProfile` | `(input) => Promise` | Edit your business profile. **Business-only.** | | `updateCoverPhoto` | `(media) => Promise<{ id }>` | Upload/bind a cover photo. **Business-only.** | | `deleteCoverPhoto` | `(id) => Promise` | Delete the cover photo. **Business-only.** | *** ## bot `WaBotCoordinator` — see [Bots](/en/guides/bots). | Method | Signature | Description | | ----------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------- | | `listBots` | `() => Promise` | Bots available to the account, grouped by section. | | `getBotProfile` | `(jid, options?) => Promise` | A bot's profile (commands, prompts, creator). | | `sendPrompt` | `(to, content, options?) => Promise` | Prompt a bot — direct path (`to` is `@bot`) or mention path (group + `options.botJid`). | | `tryDecryptChunk` | `(event) => Promise` | Decrypt a streamed reply chunk → `message_bot_chunk`. Auto-called per incoming message. | *** ## email `WaEmailCoordinator`. | Method | Signature | Description | | ------------------------- | --------------------------------------------- | ----------------------------------------------- | | `getStatus` | `() => Promise` | Current binding (address + verified/confirmed). | | `setEmail` | `(email, context?) => Promise` | Bind/rebind an address. | | `requestVerificationCode` | `(input) => Promise` | Send a verification code to the address. | | `verifyCode` | `(code) => Promise` | Submit the emailed code. | | `confirm` | `(context?) => Promise` | Post-verification ownership confirmation. | Email binding is **mobile-only** — every method throws on a Web/companion connection. See [Mobile connections](/en/concepts/mobile). *** ## lowlevel `WaLowLevelCoordinator` — full reference in [Low-level API](/en/reference/low-level): `sendNode`, `query`, `registerIncomingHandler`, `unregisterIncomingHandler`, `registerIncomingStanzaFilter`. *** For top-level helpers exported from the package root — message inspection (`getContentType`), target normalization (`resolveMessageTarget`), JID predicates and constants — see [JIDs, helpers & constants](/en/reference/jid-helpers). For typed business hours, see [Profile, privacy & business](/en/guides/profile-privacy#typed-business-hours). # Glossary Source: https://zapo.to/en/reference/glossary Definitions of the WhatsApp protocol and zapo terms used across the docs: JID, LID, stanza, prekey, sender key, app-state, coordinator, fanout, Noise. Short definitions for the terms used across these docs. Most link to the page that covers them in depth.

addon

An encrypted follow-up attached to a message — a reaction, poll vote, or comment. Surfaced as the `message_addon` event. See [Receiving messages](/en/guides/receiving-messages#addons).

app-state

The channel that syncs per-account settings (mute, pin, archive, read, labels, contacts) across all your devices — separate from messages. Driven via [`client.chat`](/en/reference/chat-mutations).

BinaryNode

zapo's representation of a protocol [stanza](#stanza): `{ tag, attrs, content }`. The unit you work with in the [low-level API](/en/reference/low-level#binary-nodes).

broadcast list

A business-only list that sends one message to many contacts at once — each receives it as a private 1:1 chat. Distinct from a [newsletter/channel](/en/guides/newsletters). See [Broadcast lists](/en/guides/broadcast-lists).

companion device

A linked-device connection (like WhatsApp Web/Desktop) — the default mode. Contrast with [mobile connections](/en/concepts/mobile). See [Authentication](/en/concepts/authentication).

coordinator

A focused feature module reached through a getter on the client (`client.message`, `client.group`, …). See [Architecture](/en/concepts/architecture#coordinators).

fanout

Encrypting a single message once **per recipient device** and bundling the results into one stanza. See [Architecture in depth](/en/concepts/internals#outgoing-message-pipeline).

IQ

A request/response stanza (`` → `result`/`error`), correlated by id. Issue one via [`client.lowlevel.query`](/en/reference/low-level#issuing-an-iq).

JID

A WhatsApp address for a user, group, or channel — e.g. `5511999999999@s.whatsapp.net` (user), `...@g.us` (group), `...@newsletter` (channel). See [JID helpers](/en/reference/jid-helpers).

LID

A privacy-preserving identifier (`...@lid`) that represents a user **without** exposing their phone number. Prefer it when sending. See [Identities](/en/concepts/identities).

MEX

WhatsApp's GraphQL-over-IQ layer, used by newsletter and parts of business. The optional [`argo-codec`](/en/installation#optional-peer-dependencies) peer decodes some MEX responses.

Noise

The Noise-protocol handshake that authenticates the server and encrypts every frame after connect. See [The WhatsApp protocol](/en/concepts/protocol#transport--the-noise-handshake).

PN

"Phone number" — a phone-number JID (`...@s.whatsapp.net`), as opposed to a [LID](#lid). See [Identities](/en/concepts/identities).

prekey

A Signal one-time key used to bootstrap an encrypted session with a new peer. Fetched as part of session setup; an envelope that bootstraps a session is a `pkmsg`.

ratchet

The Signal Double Ratchet that encrypts 1:1 messages with forward secrecy. On the wire the envelope is `msg` (established) or `pkmsg` (session-initiating).

sender key

The group-encryption scheme (`skmsg`): each member distributes a sender key once, then encrypts group messages symmetrically under it. See [The WhatsApp protocol](/en/concepts/protocol#end-to-end-encryption-signal).

session

The Signal protocol state for an encrypted conversation with a peer device, persisted in the [store](#store). Refresh one with `client.message.syncSignalSession`.

stanza

A unit of the WhatsApp protocol — a compact binary form of an XMPP-like element. In zapo it's a [`BinaryNode`](#binarynode).

store

The pluggable persistence layer that holds auth, Signal state, app-state, and optionally messages/threads/contacts. Built with `createStore`. See [Stores](/en/concepts/stores).

view-once

Media that the recipient can open only once. Send it with the `viewOnce` option. See [Media](/en/guides/media#view-once).

write-behind

Batched, asynchronous persistence of incoming messages/threads/contacts so the hot path isn't blocked on the database. Tuned via the [`writeBehind`](/en/concepts/configuration#write-behind-persistence) option. # JIDs, helpers & constants Source: https://zapo.to/en/reference/jid-helpers Build, parse, and inspect WhatsApp JIDs, plus every WA_* protocol constant exported from the zapo-js package root for use in your own code. A **JID** (Jabber ID) is how WhatsApp addresses every entity. `zapo` exports a set of helpers to build and classify them, all from the package root: ```ts theme={null} import { parsePhoneJid, isGroupJid, splitJid } from 'zapo-js' ``` ## JID shapes | Entity | Example | | -------------------- | ------------------------------- | | User (phone) | `5511999999999@s.whatsapp.net` | | Group | `123456789-987654@g.us` | | Newsletter / channel | `120363000000000000@newsletter` | | Status broadcast | `status@broadcast` | | LID (privacy id) | `123456789@lid` | ## Building JIDs ```ts theme={null} // Phone number → user JID parsePhoneJid('5511999999999') // '5511999999999@s.whatsapp.net' // Normalize anything into a recipient JID (accepts a number or string) normalizeRecipientJid('5511999999999') // Strip a device suffix down to the base user JID toUserJid('5511999999999:12@s.whatsapp.net') // '5511999999999@s.whatsapp.net' // Build a device-scoped JID buildDeviceJid('5511999999999', 's.whatsapp.net', 12) ``` ## Parsing & splitting ```ts theme={null} splitJid('5511999999999@s.whatsapp.net') // { user, server } parseJidFull(jid) // ParsedJid with full breakdown parseSignalAddressFromJid(jid) // { user, server, device } ``` ## Classifying JIDs Boolean predicates for routing logic: | Helper | True for | | ---------------------------- | -------------------------- | | `isGroupJid(jid)` | Group (`@g.us`) | | `isGroupOrBroadcastJid(jid)` | Group or broadcast | | `isBroadcastJid(jid)` | Broadcast list | | `isStatusBroadcastJid(jid)` | `status@broadcast` | | `isNewsletterJid(jid)` | Newsletter (`@newsletter`) | | `isLidJid(jid)` | LID (`@lid`) | | `isBotJid(jid)` | A bot (`@bot`) | | `isHostedDeviceJid(jid)` | Hosted device | ```ts theme={null} client.on('message', (event) => { if (isGroupJid(event.key.remoteJid)) { console.log('a group message') } }) ``` ## Constants The full set of protocol constants is exported as frozen `WA_*` objects (the library uses these instead of TypeScript enums). The most commonly used: | Constant | Contains | | --------------------------------------------- | ------------------------------------------------------------------------- | | `WA_DEFAULTS` | Default timeouts, the status-broadcast JID, the default device browser, … | | `WA_BROWSERS` | Browser identifiers for the device fingerprint. | | `WA_PRIVACY_CATEGORIES` / `WA_PRIVACY_VALUES` | Privacy setting names and allowed values. | | `WA_DISCONNECT_REASONS` / `WA_LOGOUT_REASONS` | Reason strings on connection events. | | `WA_MESSAGE_TYPES` / `WA_MESSAGE_TAGS` | Message classification. | | `WA_NODE_TAGS` / `WA_XMLNS` | Protocol node tags and XML namespaces. | | `WA_APP_STATE_COLLECTIONS` | App-state collection names. | ```ts theme={null} import { WA_DEFAULTS, WA_LOGOUT_REASONS } from 'zapo-js' await client.logout(WA_LOGOUT_REASONS.USER_INITIATED) ``` Associated string-literal **types** are also exported for annotation: `WaPrivacyCategory`, `WaPrivacySettingName`, `WaPrivacyValue`, `WaDisconnectReason`, `WaLogoutReason`, `WaConnectionCode`, `WaStreamErrorCode`, `WaFailureReasonCode`, and `ParsedJid`. ## Message helpers Two helpers for inspecting and targeting messages, exported from the package root: | Export | Signature | Description | | ---------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `getContentType` | `(content?: Proto.IMessage) => keyof Proto.IMessage \| undefined` | Returns the populated content-type key (`'conversation'`, `'imageMessage'`, `'extendedTextMessage'`, …) of a message, or `undefined` for an empty message. Skips `senderKeyDistributionMessage` so group messages report their real payload kind. | | `resolveMessageTarget` | `(ref: WaMessageTargetInput) => WaMessageKey` | Normalizes a reply/edit/reaction/revoke/pin target into a bare `WaMessageKey`. Accepts an explicit `WaMessageKey` (returned as-is) or a received `message` event (`rawNode`-bearing reference — its `key` is returned). Throws when an event is passed without a valid `key.id`. | ```ts theme={null} import { getContentType, resolveMessageTarget } from 'zapo-js' client.on('message', (event) => { console.log(getContentType(event.message)) // e.g. 'extendedTextMessage' const key = resolveMessageTarget(event) // identical to event.key for real events }) ``` ## Other utilities | Export | Purpose | | -------------------------- | ------------------------------------------------------------------ | | `proto` | The full protobuf namespace — build raw `Proto.IMessage` payloads. | | `delay(ms)` | Promise-based sleep. | | `parseUsyncResultEnvelope` | Parse a USync IQ result envelope. | # Low-level API Source: https://zapo.to/en/reference/low-level The raw escape hatch — send protocol nodes, issue IQs to WhatsApp, and register custom incoming-node handlers and filters when the high-level API is not enough. `client.lowlevel` (`WaLowLevelCoordinator`) is the raw escape hatch beneath the typed coordinators. Use it to send protocol stanzas the high-level API doesn't cover, issue custom IQs, or intercept inbound stanzas. This is unsafe by design — you're building protocol nodes by hand. Prefer the typed coordinators when one exists; reach for `lowlevel` only for protocol surfaces zapo doesn't wrap yet. ## Binary nodes Everything here speaks `BinaryNode` — zapo's representation of a WhatsApp protocol stanza: ```ts theme={null} interface BinaryNode { tag: string attrs: Record content?: Uint8Array | string | readonly BinaryNode[] } ``` ## Sending a node `sendNode` writes a raw stanza. Failures that look like a transient receipt-send issue are buffered to the receipt queue and logged rather than thrown. ```ts theme={null} await client.lowlevel.sendNode({ tag: 'presence', attrs: { type: 'available' } }) ``` ## Issuing an IQ `query` sends an IQ stanza and awaits the matching response (within `timeoutMs`). It throws if the client isn't connected. ```ts theme={null} const result = await client.lowlevel.query( { tag: 'iq', attrs: { to: '@s.whatsapp.net', type: 'get', xmlns: 'w:profile:picture' }, content: [{ tag: 'picture', attrs: { type: 'image' } }] }, 30_000 // optional timeout (ms); defaults to WA_DEFAULTS.IQ_TIMEOUT_MS ) // result is the response BinaryNode ``` | Param | Type | Notes | | --------------------- | ------------ | --------------------------------------------------- | | `node` | `BinaryNode` | The IQ to send. | | `timeoutMs` | `number` | Response timeout. Defaults to the IQ default (60s). | | `options.useSystemId` | `boolean` | Use a system-generated stanza id. | ## Intercepting incoming nodes Register a handler for inbound nodes that match a `tag` (and optional `subtype`). The handler returns a `Promise` — return `true` when you've handled the node. `registerIncomingHandler` returns an `unregister` function. ```ts theme={null} const unregister = client.lowlevel.registerIncomingHandler({ tag: 'notification', subtype: 'server_sync', // optional prepend: false, // run before the default handlers when true handler: async (node) => { console.log('got notification', node.attrs) return false // false = let other handlers also process it } }) // later unregister() ``` `WaIncomingNodeHandlerRegistration`: ```ts theme={null} interface WaIncomingNodeHandlerRegistration { tag: string subtype?: string handler: (node: BinaryNode) => Promise prepend?: boolean } ``` You can also remove a registration explicitly: ```ts theme={null} client.lowlevel.unregisterIncomingHandler(registration) // returns boolean ``` ## Filtering inbound stanzas A **stanza filter** runs before the typed handlers. Return `true` to **drop** a stanza entirely. zapo still sends the appropriate ack for `message`/`receipt`/`notification`, so the server stops re-delivering it. ```ts theme={null} const unregister = client.lowlevel.registerIncomingStanzaFilter((node) => { // Drop everything from a noisy JID return node.attrs.from === 'spam@s.whatsapp.net' }) ``` Auth-critical `success` and `failure` stanzas **bypass filters** — you can't drop them, so the connection and pairing flow always stays intact. | Method | Signature | | ------------------------------ | ----------------------------------------------------- | | `sendNode` | `(node: BinaryNode) => Promise` | | `query` | `(node, timeoutMs?, options?) => Promise` | | `registerIncomingHandler` | `(registration) => () => void` | | `unregisterIncomingHandler` | `(registration) => boolean` | | `registerIncomingStanzaFilter` | `(filter) => () => void` | Inbound nodes are also observable read-only via the `debug_transport_node_in` / `debug_transport_node_out` [events](/en/concepts/events#debug-events) — handy for discovering stanza shapes before you write a handler. # Message types Source: https://zapo.to/en/reference/message-types Every send content variant in zapo — the typed builders discriminated by `type`, and the raw Proto.IMessage fields the library recognizes on receive. Everything you send goes through `client.message.send(to, content, options?)`. The `content` argument is a `WaSendMessageContent`: ```ts theme={null} type WaSendMessageContent = | string // shorthand for a text message | WaSendTextMessage // type: 'text' | WaSendReactionMessage // type: 'reaction' | WaSendRevokeMessage // type: 'revoke' | WaSendPinMessage // type: 'pin' | 'unpin' | WaSendKeepMessage // type: 'keep' | 'unkeep' | WaSendPollMessage // type: 'poll' | WaSendPollVoteMessage // type: 'poll-vote' | WaSendEventMessage // type: 'event' | WaSendEventResponseMessage // type: 'event-response' | WaSendMediaMessage // type: 'image' | 'video' | 'ptv' | 'audio' | 'document' | 'sticker' | 'sticker-pack' | Proto.IMessage // raw protobuf — anything not covered above ``` There are two ways to send: a **typed builder** (an object with a `type` discriminator — the library validates and fills protocol fields for you) or a **raw `Proto.IMessage`** (you build the protobuf yourself). The same `send()` accepts both. ## Shorthand A plain string is sent as a text message: ```ts theme={null} await client.message.send(jid, 'Hello!') ``` ## Typed builders Each builder is discriminated by its `type` field. Bold fields are required. ### Text & media | `type` | Type | Required fields | Guide | | -------------- | -------------------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------- | | `text` | `WaSendTextMessage` | **`text`** | [Sending messages](/en/guides/sending-messages#plain-text) | | `image` | `WaSendMediaMessage` | **`media`** (mimetype optional with a `detectMimetype` processor) | [Media](/en/guides/media#images) | | `video` | `WaSendMediaMessage` | **`media`** (mimetype optional with a `detectMimetype` processor) | [Media](/en/guides/media#video) | | `ptv` | `WaSendMediaMessage` | **`media`** (mimetype optional with a `detectMimetype` processor) | [Media](/en/guides/media#video) | | `audio` | `WaSendMediaMessage` | **`media`** (mimetype optional with a `detectMimetype` processor) | [Media](/en/guides/media#audio--voice-notes) | | `document` | `WaSendMediaMessage` | **`media`** (mimetype optional with a `detectMimetype` processor) | [Media](/en/guides/media#documents) | | `sticker` | `WaSendMediaMessage` | **`media`** (mimetype defaults to `image/webp`) | [Media](/en/guides/media#stickers) | | `sticker-pack` | `WaSendStickerPackMessage` | **`stickerPackId`**, **`name`**, **`publisher`**, **`stickers`**, **`trayIcon`** | [Media](/en/guides/media#stickers) | Media builders also accept any non-managed field of the underlying protobuf message (e.g. `caption`, `gifPlayback`, `ptt`, `fileName`) via the `UserMediaFields` mapping. Protocol-managed fields (`url`, `mediaKey`, `fileSha256`, `directPath`, …) are filled by the builder. `mimetype` is optional. The resolution order is: an explicit `mimetype` you pass wins; otherwise the builder calls `media.processor.detectMimetype` (provided by `@zapo-js/media-utils` when `file-type` is installed); otherwise it throws for `image`/`video`/`audio`/`document`/`ptv`. Stickers default to `image/webp`. ### Interactive | `type` | Type | Required fields | Guide | | ----------------- | ---------------------------- | ------------------------------------- | ------------------------------------------------------------------------ | | `reaction` | `WaSendReactionMessage` | **`emoji`**, **`target`** | [Reactions](/en/guides/interactive-messages#reactions) | | `poll` | `WaSendPollMessage` | **`name`**, **`options`** | [Polls](/en/guides/interactive-messages#polls) | | `poll-vote` | `WaSendPollVoteMessage` | **`poll`**, **`selectedOptionNames`** | [Voting](/en/guides/interactive-messages#voting-on-a-poll) | | `event` | `WaSendEventMessage` | **`name`**, **`startTime`** | [Events](/en/guides/interactive-messages#events) | | `event-response` | `WaSendEventResponseMessage` | **`event`**, **`response`** | [Event response](/en/guides/interactive-messages#responding-to-an-event) | | `pin` / `unpin` | `WaSendPinMessage` | **`target`** | [Pinning](/en/guides/interactive-messages#pinning) | | `keep` / `unkeep` | `WaSendKeepMessage` | **`target`** | [Keep-in-chat](/en/guides/interactive-messages#keep-in-chat) | | `revoke` | `WaSendRevokeMessage` | **`target`** | [Revoking](/en/guides/interactive-messages#revoking-delete-for-everyone) | `target` is a `WaMessageTargetInput` — a [`WaMessageKey`](/en/guides/interactive-messages#targeting-a-message) (`{ remoteJid, id, fromMe, participant? }`) **or** a received `message` event passed verbatim (its `key` is used). `poll`/`event` parents additionally require `authorJid` and the 32-byte `messageSecret`. For `revoke`, sender-vs-admin is auto-detected from `target.fromMe` (`false` triggers an admin revoke). There is no `subtype` option. ## Raw `Proto.IMessage` For anything without a typed builder, pass a raw protobuf message. The library inspects the populated field and **automatically resolves** the stanza attributes — message `type` (\[`resolveMessageTypeAttr`]), media `type`, `polltype`, `event_type`, `view_once`, and `edit` — so you only set the content field. ```ts theme={null} import { proto } from 'zapo-js' await client.message.send(jid, { conversation: 'A raw text message' }) ``` ### Text | Field | Notes | | --------------------- | ------------------------------------------------------------------------------------------------------ | | `conversation` | Plain text. | | `extendedTextMessage` | Text with context (links, mentions, replies). A non-empty `matchedText` makes it a link/media message. | ### Media | Field | Resolved media type | | ------------------------------------------------ | ------------------------------------- | | `imageMessage` | `image` | | `videoMessage` | `video` (or `gif` when `gifPlayback`) | | `ptvMessage` | `ptv` | | `audioMessage` | `audio` (or `ptt` when `ptt`) | | `documentMessage` / `documentWithCaptionMessage` | `document` | | `stickerMessage` | `sticker` | | `stickerPackMessage` | `sticker-pack` | Raw media fields require pre-uploaded media (the encryption keys, `directPath`, and digests must already be set). To upload from bytes/a file, use the typed media builders instead — they perform the upload for you. ### Location & contacts ```ts theme={null} // Static location await client.message.send(jid, { locationMessage: { degreesLatitude: -23.55, degreesLongitude: -46.63, name: 'HQ' } }) // Live location (resolved as `live-location`) await client.message.send(jid, { liveLocationMessage: { degreesLatitude: -23.55, degreesLongitude: -46.63 } }) // Single contact (vCard) await client.message.send(jid, { contactMessage: { displayName: 'Maria', vcard: 'BEGIN:VCARD\n...\nEND:VCARD' } }) // Multiple contacts await client.message.send(jid, { contactsArrayMessage: { displayName: 'Team', contacts: [/* IContactMessage[] */] } }) ``` | Field | Resolved media type | | ---------------------- | --------------------------------------------- | | `locationMessage` | `location` (or `live-location` when `isLive`) | | `liveLocationMessage` | `live-location` | | `contactMessage` | `vcard` | | `contactsArrayMessage` | `contact_array` | ### Interactive & business | Field | Resolved type | | ---------------------------------- | ---------------------- | | `buttonsMessage` | `button` | | `buttonsResponseMessage` | `button_response` | | `listMessage` | `list` | | `listResponseMessage` | `list_response` | | `interactiveMessage` (native flow) | interactive | | `interactiveResponseMessage` | `native_flow_response` | | `templateButtonReplyMessage` | text | | `orderMessage` | `order` | | `productMessage` | `product` | | `groupInviteMessage` | `url` | ```ts theme={null} await client.message.send(jid, { groupInviteMessage: { groupJid, inviteCode, inviteExpiration, groupName } }) ``` ### Polls & events (raw) | Field | Resolved | | --------------------------------------------- | -------------------------------- | | `pollCreationMessage` / `…V2` / `…V3` / `…V5` | `poll` (`polltype: creation`) | | `pollUpdateMessage` | `poll` (`polltype: vote`) | | `pollResultSnapshotMessage` | text | | `eventMessage` | `event` (`event_type: creation`) | | `encEventResponseMessage` | `event` (`event_type: response`) | Poll creation and event messages auto-persist their `messageSecret` so later votes/responses can be encrypted. ### Protocol, edits & system | Field | Notes | | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | `protocolMessage` | Revokes (`REVOKE`), edits (`MESSAGE_EDIT`), ephemeral sync, welcome requests. | | `editedMessage` | Edited message wrapper (`edit` attr). | | `reactionMessage` / `encReactionMessage` | Reaction (`type: reaction`); empty `text` revokes. | | `pinInChatMessage` | Pin/unpin (`edit: pin_in_chat`). | | `keepInChatMessage` | Keep-in-chat. | | `encCommentMessage` | Comment on a message. | | `requestPhoneNumberMessage` | Request a phone number. | | `newsletterAdminInviteMessage` | Newsletter admin invite. | | `secretEncryptedMessage` | Carries `secretEncType`: `EVENT_EDIT`, `POLL_EDIT`, `POLL_ADD_OPTION`, `MESSAGE_EDIT`, `MESSAGE_SCHEDULE`. | | `messageHistoryNotice` / `messageHistoryBundle` | Group history sharing. | ### Wrappers These wrap an inner message; the library unwraps them when resolving attributes: `ephemeralMessage`, `viewOnceMessage`, `viewOnceMessageV2`, `deviceSentMessage`, `groupMentionedMessage`, `botInvokeMessage`, `documentWithCaptionMessage`. For view-once specifically, prefer the [`viewOnce` send option](/en/guides/media#view-once) over hand-wrapping. The full protobuf surface is available under the exported `proto` namespace — `proto.Message`, `proto.Message.ProtocolMessage.Type`, etc. Use it to build any field above and to reference enum values. ## Raw proto cookbook Concrete `client.message.send(jid, …)` payloads for the common raw kinds. Import `proto` for enum values: ```ts theme={null} import { proto } from 'zapo-js' ``` ### Plain text ```ts theme={null} await client.message.send(jid, { conversation: 'Hello' }) ``` ### Text with mentions + reply ```ts theme={null} await client.message.send(jid, { extendedTextMessage: { text: 'Hey @5511999999999 👆', contextInfo: { mentionedJid: ['5511999999999@s.whatsapp.net'], // quote/reply: stanzaId: originalStanzaId, participant: originalSenderJid, quotedMessage: { conversation: 'the quoted text' } } } }) ``` ### Text with a manual link preview ```ts theme={null} await client.message.send(jid, { extendedTextMessage: { text: 'https://example.com', matchedText: 'https://example.com', title: 'Example', description: 'Example domain' } }) ``` ### Static location ```ts theme={null} await client.message.send(jid, { locationMessage: { degreesLatitude: -23.5505, degreesLongitude: -46.6333, name: 'São Paulo', address: 'SP, Brazil' } }) ``` ### Live location ```ts theme={null} await client.message.send(jid, { liveLocationMessage: { degreesLatitude: -23.5505, degreesLongitude: -46.6333, accuracyInMeters: 50, speedInMps: 0, caption: 'On my way', sequenceNumber: 1 } }) ``` ### Contact card (vCard) ```ts theme={null} await client.message.send(jid, { contactMessage: { displayName: 'Maria Silva', vcard: [ 'BEGIN:VCARD', 'VERSION:3.0', 'FN:Maria Silva', 'TEL;type=CELL;waid=5511999999999:+55 11 99999-9999', 'END:VCARD' ].join('\n') } }) ``` ### Multiple contacts ```ts theme={null} await client.message.send(jid, { contactsArrayMessage: { displayName: 'My contacts', contacts: [ { displayName: 'Maria', vcard: 'BEGIN:VCARD…END:VCARD' }, { displayName: 'João', vcard: 'BEGIN:VCARD…END:VCARD' } ] } }) ``` ### Group invite ```ts theme={null} await client.message.send(jid, { groupInviteMessage: { groupJid: '123456789-987654@g.us', inviteCode: 'AbCdEf123', inviteExpiration: Math.floor(Date.now() / 1000) + 86_400, groupName: 'My group', caption: 'Join us!' } }) ``` ### Reaction (raw) ```ts theme={null} await client.message.send(jid, { reactionMessage: { key: { remoteJid: jid, fromMe: false, id: targetStanzaId, participant: senderJid }, text: '🔥', // empty string removes the reaction senderTimestampMs: Date.now() } }) ``` ### Pin / unpin (raw) ```ts theme={null} await client.message.send(jid, { pinInChatMessage: { key: { remoteJid: jid, fromMe: false, id: targetStanzaId }, type: proto.Message.PinInChatMessage.Type.PIN_FOR_ALL, // or UNPIN_FOR_ALL senderTimestampMs: Date.now() } }) ``` ### Keep / un-keep in chat (raw) ```ts theme={null} await client.message.send(jid, { keepInChatMessage: { key: { remoteJid: jid, fromMe: false, id: targetStanzaId }, keepType: proto.KeepType.KEEP_FOR_ALL, // or UNDO_KEEP_FOR_ALL timestampMs: Date.now() } }) ``` ### Revoke / delete-for-everyone (protocolMessage) ```ts theme={null} await client.message.send(jid, { protocolMessage: { key: { remoteJid: jid, fromMe: true, id: targetStanzaId }, type: proto.Message.ProtocolMessage.Type.REVOKE } }) ``` ### Toggle disappearing messages (ephemeral setting) ```ts theme={null} await client.message.send(jid, { protocolMessage: { type: proto.Message.ProtocolMessage.Type.EPHEMERAL_SETTING, ephemeralExpiration: 7 * 24 * 3600 // seconds; 0 disables } }) ``` ### Poll (raw) ```ts theme={null} await client.message.send(jid, { pollCreationMessage: { name: 'Lunch?', options: [{ optionName: 'Pizza' }, { optionName: 'Sushi' }], selectableOptionsCount: 1 } }) ``` ### Request a phone number ```ts theme={null} await client.message.send(jid, { requestPhoneNumberMessage: {} }) ``` ### Disappearing wrapper (ephemeralMessage) Wrap any message so it inherits the chat's ephemeral timer: ```ts theme={null} await client.message.send(jid, { ephemeralMessage: { message: { conversation: 'This disappears' } } }) ``` ### View-once wrapper ```ts theme={null} await client.message.send(jid, { viewOnceMessageV2: { message: { imageMessage: { /* pre-uploaded image fields */ } } } }) ``` Snippets that quote a message use a `key` of `{ remoteJid, fromMe, id, participant? }` (a `proto.IMessageKey`). For the typed equivalents (reaction, revoke, pin, keep, poll) prefer the [builders](#interactive) — they manage the message secret and key wiring for you. # Stores reference Source: https://zapo.to/en/reference/stores Configuration reference for the SQLite, PostgreSQL, MySQL, Redis, and MongoDB store backend packages, including fields, defaults, and examples. Each backend package exports a `create*Store` factory you pass into a `backends` entry of [`createStore`](/en/concepts/stores). All backends implement the same per-domain store contracts, so switching backends is a config change, not a code change. ## SQLite `@zapo-js/store-sqlite` — `createSqliteStore(config)`. ```ts theme={null} import { createSqliteStore } from '@zapo-js/store-sqlite' const sqlite = createSqliteStore({ path: '.auth/state.sqlite', driver: 'auto' }) ``` | Field | Type | Description | | ------------ | ----------------------------------------------------------------- | ------------------------------------------------------------------------ | | `path` | `string` | Database file path. Mutually exclusive with `connection`. | | `connection` | `WaSqliteConnection` | Pre-opened connection to reuse. Mutually exclusive with `path`. | | `driver` | `WaSqliteDriver` | Native driver selection (`'auto'`, …). Ignored when `connection` is set. | | `pragmas` | `Record` | SQLite pragmas. Ignored when `connection` is set. | | `tableNames` | `WaSqliteTableNameOverrides` | Override table names. Ignored when `connection` is set. | | `batchSizes` | `WaSqliteBatchSizeSelection` | Tune batch sizes. | | `cacheTtlMs` | `{ retryMs?, groupMetadataMs?, deviceListMs?, messageSecretMs? }` | Cache TTLs. | Provide exactly one of `path` or `connection`. With `path`, the library opens (and ref-counts) its own connection and closes it on `store.destroy()`. With `connection`, you own the lifecycle — `store.destroy()` leaves it open so you can keep using it elsewhere. ### Bring your own connection Share a single SQLite handle with the rest of your application by opening it yourself with `openSqliteConnection` and passing it through `connection`: ```ts theme={null} import { createStore } from 'zapo-js' import { createSqliteStore, openSqliteConnection } from '@zapo-js/store-sqlite' const connection = await openSqliteConnection({ path: 'app.sqlite', sessionId: 'shared', pragmas: { journal_mode: 'WAL', synchronous: 'NORMAL' } }) const store = createStore({ backends: { sqlite: createSqliteStore({ connection }) }, providers: { auth: 'sqlite', signal: 'sqlite', preKey: 'sqlite', session: 'sqlite', identity: 'sqlite', senderKey: 'sqlite', appState: 'sqlite', privacyToken: 'sqlite', messages: 'none', threads: 'none', contacts: 'none' } }) // ... use connection elsewhere in your app ... await store.destroy() connection.close() // you opened it, you close it ``` Requires the `better-sqlite3` peer dependency. ## PostgreSQL `@zapo-js/store-postgres` — `createPostgresStore(config)`. ```ts theme={null} import { createPostgresStore } from '@zapo-js/store-postgres' const postgres = createPostgresStore({ pool: { connectionString: process.env.DATABASE_URL }, tablePrefix: 'wa_' }) ``` | Field | Type | Description | | ---------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `pool` | `Pool \| PoolConfig` | An existing `pg` pool or a pool config. **Required.** | | `tablePrefix` | `string` | Prefix for created tables. | | `cacheTtlMs` | object | Cache TTLs (same shape as SQLite). | | `cleanup` | `{ intervalMs?, onError? }` | Background cleanup poller. | | `batchInsertChunkSize` | `number` | Upper bound on rows per multi-row `INSERT` in batch writes. Default `500`. See [Batch insert chunking](#batch-insert-chunking). | Also exports `createPgPool` and `ensurePgMigrations`. Requires the `pg` peer dependency. ## MySQL `@zapo-js/store-mysql` — `createMysqlStore(config)`. ```ts theme={null} import { createMysqlStore } from '@zapo-js/store-mysql' const mysql = createMysqlStore({ pool: { uri: process.env.MYSQL_URL }, tablePrefix: 'wa_' }) ``` | Field | Type | Description | | ---------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `pool` | `Pool \| PoolOptions` | A `mysql2` pool or options. **Required.** | | `tablePrefix` | `string` | Prefix for created tables. | | `cacheTtlMs` | object | Cache TTLs. | | `cleanup` | `{ enabled?, intervalMs?, onError? }` | Background cleanup poller. | | `batchInsertChunkSize` | `number` | Upper bound on rows per multi-row `INSERT` in batch writes. Default `500`. See [Batch insert chunking](#batch-insert-chunking). | Also exports `createMysqlPool` and `ensureMysqlMigrations`. Requires the `mysql2` peer dependency. ### Batch insert chunking `batchInsertChunkSize` caps how many rows the PostgreSQL and MySQL backends fold into a single multi-row `INSERT` for batch writes — Signal sessions, remote identities, sender-key distributions, prekey generation, and the message/thread/contact `upsertBatch` paths used by [write-behind persistence](/en/concepts/configuration#write-behind-persistence). The value is rounded **down to the nearest power of two** internally (`500 → 256`, `1000 → 512`, …), and each batch is decomposed into power-of-two sub-chunks. That keeps the set of distinct prepared statements per connection bounded at `log2(chunkSize) + 1` regardless of how `N` varies between calls — important for staying under the mysql2 client-side cache and MySQL's `max_prepared_stmt_count` quota, and for keeping `pg`'s named statement cache stable. Leave the default unless benchmarks for your workload show it helps. Raise it for steady high-fanout group sends; lower it if your database limits are tight. ```ts theme={null} createPostgresStore({ pool: { connectionString: process.env.DATABASE_URL }, batchInsertChunkSize: 2000 // → effective 1024 }) ``` ## Redis `@zapo-js/store-redis` — `createRedisStore(config)`. ```ts theme={null} import { createRedisStore } from '@zapo-js/store-redis' const redis = createRedisStore({ redis: { host: '127.0.0.1', port: 6379 }, keyPrefix: 'wa:' }) ``` | Field | Type | Description | | ------------ | ----------------------- | ----------------------------------------------- | | `redis` | `Redis \| RedisOptions` | An `ioredis` instance or options. **Required.** | | `keyPrefix` | `string` | Prefix for all keys. | | `cacheTtlMs` | object | Cache TTLs. | Requires the `ioredis` peer dependency. ## MongoDB `@zapo-js/store-mongo` — `createMongoStore(config)`. ```ts theme={null} import { createMongoStore } from '@zapo-js/store-mongo' const mongo = createMongoStore({ db: { uri: process.env.MONGO_URL, database: 'zapo' }, collectionPrefix: 'wa_' }) ``` | Field | Type | Description | | ------------------ | ----------------------------------- | -------------------------------------------------- | | `db` | `Db \| { uri, database, options? }` | A `mongodb` `Db` or connection info. **Required.** | | `collectionPrefix` | `string` | Prefix for created collections. | | `cacheTtlMs` | object | Cache TTLs. | Requires the `mongodb` peer dependency. Bulk writes use `{ ordered: false }` so independent upserts run in parallel — a per-document failure does not abort the rest of the batch. ## Cache expiry and cleanup The four cache domains (`retry`, `groupMetadata`, `deviceList`, `messageSecret`) carry a TTL set through `cacheTtlMs`. How expired entries are *evicted* differs per backend: | Backend | Mechanism | Action required | | ---------- | -------------------------------------------------------------------------- | ---------------------------------- | | `memory` | Periodic in-process sweep (interval `min(60s, ttl/2)`, `unref()`-ed timer) | None — automatic | | `sqlite` | Filter on read; expired rows are skipped and overwritten on next upsert | None | | `postgres` | Filter on read **+** background poller deletes expired rows | **Call `startCleanup(sessionId)`** | | `mysql` | Filter on read **+** background poller deletes expired rows | **Call `startCleanup(sessionId)`** | | `redis` | Native key `EXPIRE` | None | | `mongo` | TTL index (server-side monitor, \~60s sweep latency) | None | For PostgreSQL and MySQL, without `startCleanup` your cache tables grow monotonically. Reads still ignore expired rows so stale data is never served, but disk usage climbs forever. Start one poller **per session id**. ```ts theme={null} const result = createPostgresStore({ pool: { connectionString: process.env.DATABASE_URL }, cleanup: { intervalMs: 60_000, onError: (e) => log.warn('cache cleanup failed', e) } }) const store = createStore({ backends: { pg: result }, providers: { /* ... */ } }) const client = new WaClient({ store, sessionId: 'default' }, logger) const poller = result.startCleanup('default') process.on('SIGTERM', async () => { await client.disconnect() await result.destroy() // also stops every poller it tracks }) ``` `cleanup.intervalMs` defaults to `60_000` (60s). `result.destroy()` stops every poller started through it, so calling `poller.stop()` yourself is only useful if you want to halt cleanup before tearing down the backend. For MongoDB, the TTL monitor's \~60s latency means cache entries can linger past the configured TTL. Acceptable for `groupMetadata`/`deviceList`; switch to Redis if you need tighter eviction. ## Mixing backends `createStore` lets each domain choose a backend by name, so you can combine them: ```ts theme={null} createStore({ backends: { redis, postgres }, providers: { auth: 'redis', signal: 'redis', preKey: 'redis', session: 'redis', identity: 'redis', senderKey: 'redis', appState: 'redis', privacyToken: 'redis', messages: 'postgres', threads: 'postgres', contacts: 'postgres' } }) ``` Every persistence domain must be listed once `backends` is set — see [Stores](/en/concepts/stores#providers-are-required-when-you-set-backends) for the rule and the accepted values (`''`, `'memory'`, `'none'`). # Troubleshooting & FAQ Source: https://zapo.to/en/troubleshooting Answers to the most common questions and pitfalls when running zapo: pairing failures, disconnects, missing events, history sync, and store corruption. You're almost certainly running on the **in-memory store**, which loses credentials when the process exits. Use a durable [backend](/en/reference/stores) (SQLite, Postgres, …) for the `auth` domain, and keep a **stable `sessionId`** across runs — changing it orphans the previous credentials. See [Stores](/en/concepts/stores). By design — `zapo` does **not** auto-reconnect. Listen for the `connection` event with `status: 'close'` and call `connect()` again (skip it when `isLogout` is true). See the [reconnection pattern](/en/guides/reconnection). Media still uploads **without** `@zapo-js/media-utils` — but without it there's no processor to generate **thumbnails/previews, image-video dimensions, or voice-note waveforms**, so it can render as a plain attachment or with no preview. For proper media, install it (`npm i @zapo-js/media-utils`, plus `ffmpeg`/`ffprobe`) and wire a processor through the `media` option. See [Media](/en/guides/media#media-processing). Pass a **file path** (`string`) or a `Readable` stream to `media`, not a `Buffer` — `zapo` streams the bytes through the pipeline so memory stays flat for large files. On download, prefer `downloadToFile`/`download` over `downloadBytes`. The `proxy.ws` leg needs the **`ws`** package (the runtime's native `WebSocket` can't take an HTTP `Agent`). Media/link-preview legs use an undici dispatcher. See the [proxy examples](/en/concepts/configuration#proxy) for SOCKS/HTTP/HTTPS and IPv4/IPv6. Always reply to **`event.key.remoteJid`** (the group JID), never a participant's JID. When you have a peer's LID, **prefer the LID** — it's the privacy-preserving, forward-compatible identity. See [Identities (PN vs LID)](/en/concepts/identities). That's multi-device sync — your own sends come back on the `message` event flagged `key.fromMe === true`. Filter them out if you only want inbound traffic. See [Receiving messages](/en/guides/receiving-messages). Import the event type from the package root — all coordinator and event types are exported: ```ts theme={null} import type { WaIncomingMessageEvent, WaGroupCoordinator } from 'zapo-js' client.on('message', (event: WaIncomingMessageEvent) => { /* ... */ }) const groups: WaGroupCoordinator = client.group ``` No. Mobile connections are stable, but `zapo` intentionally does **not** provide a registration API — registering a number is complex and requires a physical phone. You connect with already-registered credentials. See [Mobile connections](/en/concepts/mobile). Both work. QR is the default (`auth_qr` event). For an 8-character code, call `client.auth.requestPairingCode(phone)` after the `auth_pairing_required` event. See [Authentication](/en/concepts/authentication). `disconnect()` closes the socket but **keeps** credentials so you can resume later. `logout()` **unlinks** the device server-side and clears stored state (per `logoutStoreClear`). See [Authentication](/en/concepts/authentication#disconnect-vs-logout). WhatsApp rejected the bundled WA Web version. Upgrade `zapo` when possible. As a stopgap, set `recoverFromClientTooOld: true` to auto-fetch the current version and retry, or pass a `version` resolver that returns a fresh string per connect. See [WhatsApp Web version](/en/concepts/configuration#whatsapp-web-version). Some operations are gated: `editBusinessProfile`, cover-photo ops, and broadcast lists are **business-only**; email binding is **mobile-only**; several community/newsletter ops require an active **MEX** transport. The [coordinator reference](/en/reference/client) flags each. ## Still stuck? Understand the layers to debug at the protocol level. Inspect raw stanzas with the debug events and `lowlevel`.