# 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` (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`.