# Zaileys — Full Documentation > Type-safe, batteries-included WhatsApp bot framework for Node.js and TypeScript built on Baileys. This file concatenates the full documentation for LLM ingestion. Source: https://zeative.github.io/zaileys --- # Zaileys **Zaileys** is a type-safe, batteries-included WhatsApp framework built on top of [Baileys](https://github.com/WhiskeySockets/Baileys). Create a `Client`, listen for fully-typed events, and send anything — from plain text to interactive buttons, carousels, and Meta-AI-style rich responses — through a single chainable builder. Authentication, reconnection, media processing, and storage are handled for you so you can focus on what your bot actually does. ## Hello world This is a complete, working bot. It connects, prints a QR for you to scan, and echoes every incoming text message back to the sender. ```typescript const client = new Client() client.on('qr', ({ qrString }) => console.log('Scan this QR:', qrString)) client.on('connect', ({ me }) => console.log('Connected as', me.id)) client.on('text', async (msg) => { const quoted = await msg.replied() if (quoted) console.log('In reply to:', quoted.senderId, '|', quoted.text) await msg.reply(`Hello ${msg.senderName ?? ''}! You said: ${msg.text}`) }) ``` Scan the printed QR from **WhatsApp → Linked Devices**, and every text message gets a reply. That is the whole bot — no boilerplate, no manual decoding, no `any`. By default the `Client` connects on construction (`autoConnect: true`), so handlers registered synchronously after `new Client()` are wired up before the first event arrives. You can opt out with `new Client({ autoConnect: false })` and call `await client.connect()` yourself. See [Configuration](/configuration) and the [Client](/client) reference. Prefer a pairing code over scanning a QR? Provide your number: ```typescript const client = new Client({ authType: 'pairing', phoneNumber: '6281234567890' }) client.on('pairing-code', ({ code }) => console.log('Enter this code on your phone:', code)) ``` ## Why Zaileys - **Typed events** — `client.on('text' | 'image' | 'reaction' | 'button-click' | 'group-update' | …)`, each with a fully-typed payload and IntelliSense. No raw Baileys decoding, no `any`. See [Events](/events). - **One chainable builder** — `client.send(jid).text(…).reply(quoted).mentions([…])` resolves to the sent message key when awaited. See [Sending Messages](/sending-messages). - **Auto lifecycle** — QR or pairing-code login, auto-reconnect with backoff, clean logout, and an optional `ignoreMe` filter so the bot never replies to itself. See [Configuration](/configuration). - **Rich & interactive out of the box** — native buttons, lists, and carousels, plus Meta-AI-style rich responses written as plain markdown. See [Interactive Messages](/interactive) and [Rich Responses](/rich-responses). - **Command framework** — register `client.command('ping', …)` routers with alias support and composable middleware. See [Commands](/commands). - **Automation** — rate-limited `broadcast()` to many recipients and `scheduleAt()` for timed sends. See [Automation](/automation). - **Pluggable storage** — independent auth and message stores with `file`, `memory`, `sqlite`, `redis`, `postgres`, and `convex` adapters. See [Storage Adapters](/storage). - **Media processing** — lazy image / video / audio / sticker handling, with an optional `sharp` accelerator that falls back to a pure-JS path automatically. See [Media](/media). - **Runs everywhere** — dual ESM/CJS build with `.d.ts` + `.d.cts` types; verified on Node, Bun, Deno, and Termux. See [Runtimes](/runtimes). - **Modern foundation** — Baileys `7.0.0-rc13` (includes the CVE-2026-48063 spoofing patch), built and type-checked with the native TypeScript 7 compiler. ## Install ```bash npm i zaileys ``` ```bash pnpm add zaileys ``` ```bash yarn add zaileys ``` ```bash bun add zaileys ``` Requires **Node.js v20+**. The `file` auth store is the zero-config default — nothing else to install to get started. Other storage backends and the `sharp` media accelerator are optional peer dependencies; install only the ones you use. Full details on the [Installation](/installation) page. ## What you can build ### Send anything ```typescript await client.send(jid).text('Hello there') await client.send(jid).image('https://example.com/photo.jpg', { caption: 'Nice shot' }) await client.send(jid).poll('Pick one', ['Red', 'Green', 'Blue']) await client.send(jid).album([ { type: 'image', src: './a.jpg' }, { type: 'image', src: './b.jpg' }, ]) ``` A JID is a WhatsApp address: `628xxxxxxxxxx@s.whatsapp.net` for a person, `xxxxxxxxxx@g.us` for a group. Inside an event handler you usually just call `msg.reply(…)` or `client.send(msg.senderId)`. ### Interactive messages Reply, URL, copy, and call buttons — plus lists and carousels — render natively on personal accounts. Taps come back as typed `button-click` / `list-select` events. ```typescript await client.send(jid).buttons( [ { id: 'yes', text: 'Yes' }, { type: 'url', text: 'Open docs', url: 'https://github.com/zeative/zaileys' }, { type: 'copy', text: 'Copy code', code: 'ZAILEYS-2026' }, ], { title: 'Pick one', text: 'Tap a button below' }, ) client.on('button-click', (ctx) => console.log('tapped:', ctx.buttonId)) ``` See [Interactive Messages](/interactive) for lists, carousels, and every button variant. ### Rich responses, written as markdown Send ordinary markdown — headings, fenced code, tables, and images — and Zaileys renders it as a clean Meta-AI-style response. ```typescript await client.send(jid).text( ['*Daily brief* ☕', '', '```ts', "console.log('shipped')", '```'].join('\n'), { rich: true }, ) ``` See [Rich Responses](/rich-responses) for the full directive set. ### A command router with middleware ```typescript const client = new Client({ commandPrefix: ['/', '!'] }) const logging: Middleware = async (ctx, next) => { console.log(`[command] ${ctx.command} from ${ctx.senderId}`) await next() } client.use(logging) client.command('ping', async (ctx) => ctx.reply('pong')) client.command('weather', async (ctx) => { const city = ctx.args[0] await ctx.reply(city ? `Weather in ${city}: sunny, 28°` : 'Usage: /weather ') }) ``` See [Commands](/commands) for aliases, prefixes, and middleware composition. ### Broadcast and schedule ```typescript await client.broadcast( ['6281111111111@s.whatsapp.net', '6282222222222@s.whatsapp.net'], (b) => b.text('Maintenance tonight at 22:00.'), { rateLimitPerSec: 5, onProgress: (done, total) => console.log(`${done}/${total}`) }, ) await client.scheduleAt(new Date('2026-12-31T23:59:00'), (b) => b.text('Happy New Year! 🎉'), ) ``` See [Automation](/automation) for rate limiting, progress reporting, and persistent schedules. ## Build your first bot ### Install zaileys Add the package with your favourite manager (see [Install](#install) above) on Node.js v20+. ### Create a client and register handlers `new Client()` connects automatically. Register your `qr`, `connect`, and message handlers immediately after construction. ### Authenticate Run the script and scan the printed QR from **WhatsApp → Linked Devices**, or pass `{ authType: 'pairing', phoneNumber }` and enter the `pairing-code`. ### Send and receive Use `msg.reply(…)` or `client.send(jid)` to respond. Add commands, buttons, and storage as you grow. The [Getting Started](/getting-started) guide walks through each step in detail. ## Feature matrix | Capability | What you get | Guide | | ------------------------ | ---------------------------------------------------------------------------- | -------------------------------------- | | Connection lifecycle | Auto-connect, QR + pairing-code login, reconnect with backoff, clean logout | [Client](/client) · [Config](/configuration) | | Typed events | `text`, `image`, `reaction`, `button-click`, `group-update`, and more | [Events](/events) | | Send builder | Chainable `text` / `image` / `video` / `audio` / `document` / `poll` / `album` · `reply` · `mentions` | [Sending Messages](/sending-messages) | | Media | Lazy image/video/audio/sticker processing, optional `sharp` accelerator | [Media](/media) | | Interactive UI | Buttons (reply/url/copy/call), lists, carousels | [Interactive](/interactive) | | Rich responses | Markdown → Meta-AI-style messages (code, tables, images, directives) | [Rich Responses](/rich-responses) | | Command framework | `client.command()` routers, aliases, configurable prefixes, middleware | [Commands](/commands) | | Automation | Rate-limited `broadcast()`, `scheduleAt()`, presence control | [Automation](/automation) | | Storage adapters | `file`, `memory`, `sqlite`, `redis`, `postgres`, `convex` for auth & messages | [Storage](/storage) | | Cross-runtime | Dual ESM/CJS, full types; Node, Bun, Deno, Termux | [Runtimes](/runtimes) | ## Next steps - [Installation](/installation) — requirements, package managers, and optional peer dependencies. - [Getting Started](/getting-started) — install, authenticate, and run your first bot end-to-end. - [Configuration](/configuration) — every `ClientOptions` field and its default. - [Events](/events) — the full event catalogue and payload shapes. - [Sending Messages](/sending-messages) — the chainable send builder. - [Interactive Messages](/interactive) and [Rich Responses](/rich-responses). - [Storage Adapters](/storage) — persist sessions and history. Looking for runnable code? Every feature has a matching script in the [`examples/`](https://github.com/zeative/zaileys/tree/main/examples) folder. --- # Installation Zaileys is published to npm as a single package, `zaileys`. It ships dual **ESM** and **CommonJS** bundles with type declarations for both module systems, so it works the same whether you write TypeScript, ESM JavaScript, or CommonJS JavaScript. ## Install the package ```bash npm i zaileys ``` ```bash pnpm add zaileys ``` ```bash yarn add zaileys ``` ```bash bun add zaileys ``` That single install is everything you need for a working bot. The default `file` auth store is zero-config and needs nothing extra — every storage backend besides `file` and the native image accelerator are **optional** (see [below](#optional-peer-dependencies)). ## Runtime requirements Zaileys targets Node.js **v20 or newer** (`engines.node >= 20.0.0`). It is also verified to run on Bun, Deno, and Termux. | Runtime | Minimum | Notes | | ------- | ------- | ----- | | Node.js | `>=20` | Primary target. Both `import` and `require` work. | | Bun | latest | Runs both bundles directly — `bun add zaileys`. | | Deno | latest | Run with `deno run --node-modules-dir` so npm deps resolve. | | Termux (Android) | — | Install with `npm install zaileys --legacy-peer-deps` (skips the native `sharp` peer). | For per-runtime caveats (the `node:` protocol on Deno, ffmpeg fallback on Termux, etc.) see [Runtime Support](/runtimes). On **Termux/Android** (and some Alpine/musl images) a plain `npm install zaileys` fails while building `sharp` from source — `sharp` is a peer dependency of Baileys and has no prebuilt binary there. Install with `npm install zaileys --legacy-peer-deps` to skip it; image processing falls back to the bundled `jimp` path. `pnpm add zaileys` and `yarn add zaileys` are unaffected. See [Runtimes → Termux](/runtimes#termux-android). The published package bundles `ffmpeg` and `ffprobe` binaries for media conversion, so you do **not** need a system ffmpeg install on most platforms. Termux falls back to a `ffmpeg` on `PATH` (`pkg install ffmpeg`). ## ESM-only project setup Zaileys' own package is `"type": "module"`, but it ships a CommonJS build too, so it loads in **both** ESM and CJS projects. The snippet you copy must match *your* project's module system. In an ESM project (your `package.json` has `"type": "module"`, or the file is `.mjs` / `.ts` compiled to ESM), import normally: ```typescript const client = new Client() ``` In a CommonJS project (no `"type": "module"`, or a `.cjs` file), use `require`: ```javascript const { Client } = require('zaileys') const client = new Client() ``` ## TypeScript setup Zaileys ships its own type declarations (`dist/index.d.ts`), so there is nothing to install from `@types`. For the best experience use a modern module-resolution mode in your `tsconfig.json`: ```json { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "esModuleInterop": true, "skipLibCheck": true } } ``` `module: "NodeNext"` (or `"Node16"` / `"Bundler"`) lets TypeScript pick the right entry point from the package `exports` map automatically. `strict` is recommended — Zaileys is fully typed and the event payloads are richest under strict mode. A common quick way to run a TypeScript bot during development without a separate build step: ```bash npm i -D tsx npx tsx bot.ts ``` ```bash pnpm add -D tsx pnpm tsx bot.ts ``` ```bash yarn add -D tsx yarn tsx bot.ts ``` ```bash # Bun runs TypeScript natively — no extra tooling bun run bot.ts ``` ## Optional peer dependencies Zaileys declares its storage drivers as **optional peer dependencies**. They are not installed by default, and a missing one never breaks `npm install` — Zaileys loads each driver lazily and only throws (`STORE_NOT_AVAILABLE`) if you actually construct an adapter whose driver is absent. Install only the ones you use. | Package | Install when you use… | Adapter classes | | ------- | --------------------- | --------------- | | `better-sqlite3` | the SQLite storage adapter | `SqliteAuthStore`, `SqliteMessageStore` | | `pg` | the PostgreSQL storage adapter | `PostgresAuthStore`, `PostgresMessageStore` | | `redis` | the Redis storage adapter | `RedisAuthStore`, `RedisMessageStore` | | `convex` | the Convex storage adapter | `ConvexAuthStore`, `ConvexMessageStore` | The Redis adapter uses the official [`redis`](https://www.npmjs.com/package/redis) client (v4+), **not** `ioredis`. The default `file` auth store and the in-process `memory` stores need no peer dependencies at all. ```bash npm i better-sqlite3 # SQLite adapters npm i pg # PostgreSQL adapters npm i redis # Redis adapters npm i convex # Convex adapters ``` ```bash pnpm add better-sqlite3 # SQLite adapters pnpm add pg # PostgreSQL adapters pnpm add redis # Redis adapters pnpm add convex # Convex adapters ``` ```bash yarn add better-sqlite3 # SQLite adapters yarn add pg # PostgreSQL adapters yarn add redis # Redis adapters yarn add convex # Convex adapters ``` ```bash bun add better-sqlite3 # SQLite adapters bun add pg # PostgreSQL adapters bun add redis # Redis adapters bun add convex # Convex adapters ``` `better-sqlite3` is a native module that compiles on install. With pnpm you may need to allow its build script (`pnpm approve-builds` / the `onlyBuiltDependencies` allowlist). See [Storage Adapters](/storage) for how to wire each backend into the client. For TypeScript projects also add the matching type packages as dev dependencies when needed, e.g. `npm i -D @types/better-sqlite3 @types/pg`. The `redis` and `convex` packages ship their own types. ### Native image acceleration — `sharp` `sharp` is an **optional** accelerator for image and sticker processing. It is *not* a declared dependency: Zaileys loads it opportunistically (in both ESM and CJS) and falls back to the bundled pure-JS `jimp` path when it is absent, so everything works without it — just slower for heavy media workloads. ```bash npm i sharp ``` ```bash pnpm add sharp ``` ```bash yarn add sharp ``` ```bash bun add sharp ``` ## Verify the install After installing, confirm the package imports and constructs cleanly. By default the client connects on construction (`autoConnect` is `true`), so for a no-side-effects smoke test pass `autoConnect: false` and call [`connect()`](/client) yourself when ready. ### Create a smoke-test file Save this as `verify.ts` (or `verify.mjs` for plain ESM JavaScript): ```typescript const client = new Client({ autoConnect: false }) console.log('zaileys loaded:', typeof Client === 'function') console.log('client constructed:', client instanceof Client) // When you are ready to go online, start the connection explicitly: // await client.connect() // client.on('qr', ({ qrString }) => console.log(qrString)) ``` ### Run it ```bash npx tsx verify.ts ``` You should see both lines log `true` and the process should exit without errors. If you instead construct `new Client()` with no options, it auto-connects and prints a QR — scan it with **WhatsApp → Linked Devices**. Installed and importing cleanly? Continue with [Getting Started](/getting-started) to build your first bot, [Configuration](/configuration) for every client option, [Storage Adapters](/storage) to persist auth and messages, and [Runtime Support](/runtimes) for Bun, Deno, and Termux. --- # Getting Started This guide walks you from an empty folder to a running WhatsApp bot that authenticates, listens for messages, and replies. By the end you will understand QR vs pairing-code login, where the session is stored, and how the connection lifecycle works. Zaileys requires **Node.js v20+** (it also runs on Bun and Deno — see [Runtime Support](/runtimes)). The default `file` auth store is zero-config and needs no extra dependencies. ## Build your first bot ### Install zaileys ```bash npm install zaileys ``` ```bash pnpm add zaileys ``` ```bash yarn add zaileys ``` ```bash bun add zaileys ``` Storage backends other than `file` are **optional peer dependencies** — install only what you use. `sharp` is an optional accelerator for media/sticker processing; without it Zaileys falls back to a pure-JS path automatically. See [Storage Adapters](/storage) for details. ### Create the client Create a file (for example `bot.ts`) and construct a `Client`. With the default options, the client begins connecting **immediately on construction** (see [`autoConnect`](#autoconnect-vs-manual-connect) below). ```typescript const client = new Client() ``` ### Register your handlers Register event listeners **synchronously, right after construction**. Because connection is kicked off in a microtask, any listener you attach in the same tick is wired up before the first event fires. ```typescript const client = new Client() client.on('qr', ({ qrString }) => console.log('Scan QR:', qrString)) client.on('connect', ({ me }) => console.log('Connected as', me.id)) client.on('text', async (msg) => { await msg.reply(`Echo: ${msg.text}`) }) ``` ### Run it and authenticate Run the file with your runtime of choice, then link the device from your phone. ```bash bun run bot.ts ``` ```bash npx tsx bot.ts ``` ```bash npx ts-node bot.ts ``` By default a QR code is rendered directly in your terminal. Open **WhatsApp → Settings → Linked Devices → Link a Device** and scan it. Once linked you will see `Connected as 628...@s.whatsapp.net` and the bot will start echoing text messages. ## A complete echo bot Here is the full, runnable program. It handles the connection lifecycle and replies to every incoming text message. ```typescript const client = new Client({ sessionId: 'echo-bot', // names the auth folder + log line }) client.on('qr', ({ qrString }) => console.log('Scan QR:', qrString)) client.on('connect', ({ me }) => { console.log('Connected as', me.id) }) client.on('disconnect', ({ reason, willReconnect }) => { console.log('Disconnected:', reason, willReconnect ? '(reconnecting)' : '') }) client.on('text', async (msg) => { console.log('Received from', msg.senderId, '|', msg.text) await msg.reply(`Hello ${msg.senderName ?? ''}! You said: ${msg.text}`) }) ``` `msg.reply(text)` answers in the same chat (and quotes the incoming message). The message context also exposes `msg.senderId`, `msg.senderName`, `msg.text`, `msg.roomId` (the group JID, or `null` for a 1:1 chat), `msg.react(emoji)`, and `await msg.replied()` to fetch the quoted message. See [Events](/events) for the full context shape. ## Authentication The login method is selected with the `authType` option. The two valid values are `'qr'` (the default) and `'pairing'`. ### QR login (default) No configuration is needed. By default Zaileys prints a scannable QR straight into your terminal via the `qrTerminal` option (default `true`), and also emits a `qr` event carrying the raw `qrString` (useful for rendering the code elsewhere, e.g. a web dashboard). ```typescript const client = new Client() // authType defaults to 'qr' client.on('qr', ({ qrString, expiresAt }) => { console.log('Scan this QR string:', qrString) console.log('Expires at:', new Date(expiresAt).toISOString()) }) ``` To suppress the auto-printed terminal QR and render it yourself (e.g. as an image), disable `qrTerminal` and handle the `qr` event: ```typescript const client = new Client({ qrTerminal: false }) client.on('qr', ({ qrString }) => { // render qrString into an / image file yourself }) ``` ### Pairing-code login Instead of scanning a QR, you can request an 8-character pairing code and type it into WhatsApp. Set `authType: 'pairing'` and provide your own `phoneNumber` in E.164 format (country code, digits only — no `+`, spaces, or dashes). ```typescript const client = new Client({ authType: 'pairing', phoneNumber: '6281234567890', // your number, E.164 without '+' }) client.on('pairing-code', ({ code, expiresAt }) => { console.log('Enter this code in WhatsApp:', code) console.log('Expires at:', new Date(expiresAt).toISOString()) }) client.on('connect', ({ me }) => console.log('Connected as', me.id)) ``` On your phone, open **WhatsApp → Linked Devices → Link a Device → Link with phone number instead**, then enter the code. When `authType` is `'pairing'`, `phoneNumber` is **required** — otherwise `connect()` rejects with `phoneNumber is required when authType is "pairing"`. The number must be E.164 with a country code, between 8 and 15 digits. Separators (`+`, spaces, `-`, parentheses) are stripped automatically, but the result must be all digits. ## Where the session is stored & re-scanning After a successful login, Zaileys persists the credentials so you do not have to scan again on every restart. With the default `file` auth store, the session lives under: ```text ./.zaileys/auth// ``` `sessionId` defaults to `'default'`, so the default path is `./.zaileys/auth/default/`. Setting a distinct `sessionId` lets you run multiple independent accounts side by side, each in its own folder. ```typescript const client = new Client({ sessionId: 'support-desk' }) // → session stored in ./.zaileys/auth/support-desk/ ``` Add `.zaileys` to your `.gitignore`. The folder contains live login credentials — anyone with it can act as your WhatsApp account. To force a fresh login, delete the session folder and run again; you will be prompted to scan a new QR / request a new pairing code. Zaileys also clears the session automatically on a logged-out / fatal disconnect (e.g. you removed the linked device from the phone). For persisting sessions in SQLite, Redis, Postgres, or Convex instead of the filesystem, see [Storage Adapters](/storage). ## `autoConnect` vs manual connect By default `autoConnect` is `true`: the client schedules `connect()` in a microtask during construction, so you do not call `connect()` yourself. This is why registering handlers synchronously after `new Client()` is enough. If you set `autoConnect: false`, **nothing happens until you call `client.connect()`**. This is useful when you want to register listeners, wire up commands, or run async setup before the socket opens. `connect()` returns a `Promise` that resolves once the connection is **open** (the first `connect` event) and rejects if the connection closes before opening. ```typescript const client = new Client({ autoConnect: false }) client.on('qr', ({ qrString }) => console.log('Scan QR:', qrString)) client.on('text', async (msg) => { await msg.reply(`Echo: ${msg.text}`) }) // connect when you're ready; await resolves on the first successful open await client.connect() console.log('Socket is open and listening') ``` You can inspect the lifecycle at any time via the read-only `client.state` getter. It cycles through `idle → connecting → qr-pending` / `pairing-pending → connected`, and on a drop `reconnecting` or `disconnecting → disconnected`. Calling `connect()` while already `connecting` or `connected` is a safe no-op. ## Handling connect / qr / disconnect These connection events let you react to the full lifecycle. Reconnection is automatic by default (with exponential backoff) — the `disconnect` event's `willReconnect` flag tells you whether Zaileys will retry, and a `reconnecting` event fires for each attempt. ```typescript const client = new Client() client.on('qr', ({ qrString, expiresAt }) => { console.log('QR ready, expires', new Date(expiresAt).toLocaleTimeString()) }) client.on('connect', ({ sessionId, me }) => { console.log(`[${sessionId}] online as ${me.id} (${me.name ?? 'unknown'})`) }) client.on('reconnecting', ({ attempt, delayMs, reason }) => { console.log(`Reconnecting (attempt ${attempt}) in ${delayMs}ms — ${reason}`) }) client.on('disconnect', ({ reason, willReconnect }) => { if (willReconnect) console.log('Lost connection, retrying:', reason) else console.log('Disconnected for good:', reason) }) client.on('error', ({ error }) => { console.error('Client error:', error.message) }) ``` The connection-related events are `qr`, `pairing-code`, `connect`, `reconnecting`, `disconnect`, and `error`. There is no `connecting` event — observe `client.state` for that intermediate phase. The full event catalogue (including message events like `text`, `image`, `reaction`, and more) is documented in [Events](/events). Zaileys prints concise human-readable status lines to `stderr` by default (`[zaileys] Connecting...`, `Scan the QR code above...`, `Connected as ...`). Set `statusLog: false` to silence them and rely solely on the events above. ## First-run gotchas **The QR expires quickly.** Each QR is valid for ~60 seconds, after which a new one is emitted. Scan promptly, or watch for the next `qr` event. **"Connection keeps closing before it authenticates."** If the saved session is corrupted or invalid, the client reconnects in a loop without ever reaching `connected`. Zaileys detects this and hints you to delete the auth folder (default `./.zaileys`). Remove it and re-authenticate with a fresh QR / pairing code. **`client not connected` when sending.** Methods like `client.send(...)` require an open socket. If you call them before the `connect` event fires (or after a disconnect), they throw. Send from inside a `connect` or message handler, or `await client.connect()` first when `autoConnect` is disabled. **Pairing code with a wrong number format.** The `phoneNumber` must be E.164 digits with a country code (e.g. `6281234567890`, not `081234567890` and not `+62 812-3456-7890`). An invalid number throws `phoneNumber must be E.164 with country code`. **Messages from the bot itself are ignored** by default (`ignoreMe: true`), so your handlers will not echo your own outgoing messages back into a loop. Set `ignoreMe: false` only if you specifically need to process your own messages. ## Next steps - [Configuration](/configuration) — every `Client` option, defaults, and tuning. - [Events](/events) — the complete event list and message context API. - [Sending Messages](/sending-messages) — text, media, replies, reactions, and the fluent `send()` builder. - [Troubleshooting](/troubleshooting) — connection problems, session resets, and common errors. --- # Configuration Every zaileys bot starts by constructing a `Client` with a `ClientOptions` object. This page is the exhaustive reference for **every** option the constructor accepts, the exact default it falls back to, and how each one changes the client's behavior. All defaults below are read straight from the `Client` constructor source, not guessed. ```typescript const client = new Client({ sessionId: 'default' }) ``` `ClientOptions` is fully optional — `new Client()` works and applies every default in the table below. The most common reason to pass options is to pick a login method ([`authType`](#authtype--phonenumber)), enable [commands](#commandprefix) via `commandPrefix`, or swap in a persistent [storage adapter](/storage) via `auth` / `store`. ## All options at a glance | Option | Type | Default | Description | | ------ | ---- | ------- | ----------- | | `sessionId` | `string` | `'default'` | Identifies this session. Used as the default auth folder name and as the `sessionId` field on every emitted event. | | `authType` | `'qr' \| 'pairing'` | `'qr'` | Login method: scan a QR code (`'qr'`) or request an 8-digit pairing code (`'pairing'`, requires `phoneNumber`). | | `phoneNumber` | `string` | `undefined` | Phone number in international digits (e.g. `'628xxx'`). **Required** when `authType` is `'pairing'`; ignored for `'qr'`. | | `auth` | `AuthStoreBundle` | `FileAuthStore` at `./.zaileys/auth/` | Where Baileys credentials and signal keys are persisted. See [Storage Adapters](/storage). | | `store` | `MessageStore` | `MemoryMessageStore` | Where messages, chats, contacts, presence, and scheduled jobs are stored. See [Storage Adapters](/storage). | | `logger` | `Logger \| Partial` | Pino logger, level from `ZAILEYS_DEBUG` (default `silent`) | Structured logger with `debug` / `info` / `warn` / `error` / `fatal`. A partial object is padded with no-ops. | | `commandPrefix` | `string \| string[]` | `undefined` (no prefixes → commands disabled) | Trigger prefix(es) that activate the [command framework](/commands), e.g. `'/'` or `['/', '!']`. | | `ignoreMe` | `boolean` | `true` | When `true`, inbound messages sent by your own account are dropped before reaching handlers. | | `citation` | `CitationConfig` | `undefined` | Configures the per-message `citation.authors()` / `citation.banned()` predicates available in message context. | | `reconnect` | `ReconnectOptions` | `{}` (strategy defaults below) | Tunes the auto-reconnect backoff strategy. | | `authGuard` | `AuthGuardOptions` | on (bounds QR/pairing regeneration) | Caps how many QR codes / pairing codes the client regenerates, with an escalating pairing cooldown, so a stuck auth loop can't spam WhatsApp into a restriction. | | `operationGuard` | `OperationGuardOptions` | on (spaces group/community/newsletter ops) | Serializes and rate-limits sensitive group / community / newsletter operations per category so rapid bulk actions don't trip a ban. | | `presence` | `PresenceThrottleOptions` | on (drops duplicate presence) | Drops repeated presence updates (typing / recording / online) for the same chat within a short window. | | `scheduleRateLimitPerSec` | `number` | `1` (`0` disables) | Max scheduled messages dispatched per second, smoothing out a backlog of overdue jobs so they don't all fire at once. | | `autoConnect` | `boolean` | `true` | When `true`, the constructor calls `connect()` on the next microtask. Set `false` to connect manually. | | `qrTerminal` | `boolean` | `true` | When `true`, prints the QR code to the terminal in addition to emitting the `qr` event. | | `statusLog` | `boolean` | `true` | When `true`, writes human-readable connection status lines to `stderr` and suppresses noisy libsignal logs. | | `cacheSignal` | `boolean` | `true` | When `true`, wraps the auth store with an in-memory signal-key cache for faster reads. | | `baileys` | `Partial` | `{}` | Extra Baileys socket config merged into the internal config (after the internal defaults `markOnlineOnConnect: false` / `syncFullHistory: false` / `qrTimeout: 60000`, before the managed `auth` / `logger`). | `auth` and `logger` are **forced** internally and override anything you pass through `baileys`. The safety defaults `markOnlineOnConnect: false`, `syncFullHistory: false`, and `qrTimeout` are set **before** your `baileys` spread, so those three you *can* override. Use the top-level `auth` / `store` / `logger` options rather than reaching into `baileys` for those concerns. ## `sessionId` A label for this connection. It has two effects: it becomes the default auth folder (`./.zaileys/auth/`) when you do not pass a custom `auth`, and it is included as the `sessionId` field on **every** emitted event so you can tell sessions apart in a multi-account process. ```typescript const primary = new Client({ sessionId: 'account-a' }) const secondary = new Client({ sessionId: 'account-b' }) primary.on('connect', ({ sessionId, me }) => console.log(sessionId, 'ready as', me.id)) secondary.on('connect', ({ sessionId, me }) => console.log(sessionId, 'ready as', me.id)) ``` Two clients sharing the same `sessionId` would also share the same default auth folder and clash. Give each account a unique `sessionId`. ## `authType` & `phoneNumber` `authType` selects how you log in. With the default `'qr'`, a QR string is emitted (and printed to the terminal unless [`qrTerminal`](#qrterminal) is `false`). With `'pairing'`, the client requests an 8-digit code for the given `phoneNumber` and emits it via the `pairing-code` event. ```typescript const client = new Client({ authType: 'qr' }) client.on('qr', ({ qrString, expiresAt }) => { console.log('Scan this QR:', qrString, 'expires at', new Date(expiresAt)) }) ``` ```typescript const client = new Client({ authType: 'pairing', phoneNumber: '628xxxxxxxxxx', }) client.on('pairing-code', ({ code, expiresAt }) => { console.log('Enter this code in WhatsApp:', code, 'expires at', new Date(expiresAt)) }) ``` When `authType` is `'pairing'` and `phoneNumber` is missing, `connect()` rejects with `phoneNumber is required when authType is "pairing"`. With [`autoConnect`](#autoconnect) on (the default), that rejection surfaces through the `error` event. ## `commandPrefix` Setting `commandPrefix` activates the [command framework](/commands). Pass a single prefix or an array; empty strings are filtered out. When no prefix is configured, `client.command(...)` handlers never fire (the dispatcher only attaches once there is at least one prefix, at least one registered command, and a live socket). ```typescript const client = new Client({ commandPrefix: ['/', '!'] }) const logging: Middleware = async (ctx, next) => { console.log(`[command] ${ctx.command} from ${ctx.senderId}`) await next() } client.use(logging) client.command('ping', async (ctx) => { await ctx.reply('pong') }) ``` See [Commands](/commands) for spec syntax, args/flags parsing, and middleware. ## `ignoreMe` With the default `true`, any inbound message whose sender is your own logged-in account is dropped before reaching `text` / message handlers — handy so an echo bot does not respond to itself. Set it to `false` when you intentionally want to react to your own messages (for example an owner-triggered command from your own number). ```typescript // React to messages from your own account too (e.g. owner-only triggers). const client = new Client({ ignoreMe: false }) client.on('text', async (msg) => { if (msg.text === '.ping') await msg.reply('pong') }) ``` ## `auth` The credential/signal-key store. Defaults to a `FileAuthStore` rooted at `./.zaileys/auth/`. Pass any `AuthStoreBundle` to persist auth elsewhere (SQLite, Postgres, Redis, Convex, or your own implementation). The bundle has two halves: ```typescript interface AuthStoreBundle { readonly creds: AuthCredsStore // readCreds / writeCreds / deleteCreds readonly signal: AuthStore // read / write / delete / clear / close } ``` ```typescript const client = new Client({ sessionId: 'main', auth: new SqliteAuthStore({ database: './zaileys.db' }), }) ``` Unless you disable [`cacheSignal`](#cachesignal), the client wraps your `auth` in an in-memory signal-key cache on first connect. See [Storage Adapters](/storage) for every bundled adapter and its constructor options. ## `store` The message/chat/contact/presence store, also used to persist scheduled broadcast jobs. Defaults to `MemoryMessageStore` (lost on restart). Swap in a persistent `MessageStore` for durability — the store is bound to the socket on connect and powers quoted-message lookups and the [scheduler](/automation). ```typescript const client = new Client({ store: new SqliteMessageStore({ database: './zaileys.db' }), }) ``` See [Storage Adapters](/storage) for the full `MessageStore` interface and adapter options. ## `logger` A structured logger with `debug`, `info`, `warn`, `error`, and `fatal` methods. When omitted, zaileys builds a Pino logger whose level comes from the `ZAILEYS_DEBUG` environment variable: | `ZAILEYS_DEBUG` | Resulting level | | --------------- | --------------- | | unset | `silent` | | `1` | `info` | | `silent` / `fatal` / `error` / `warn` / `info` / `debug` / `trace` | that exact level | | anything else | `silent` | You may pass a **partial** logger — any missing method is replaced with a no-op, so providing just `error` and `warn` is valid. ```typescript const client = new Client({ logger: { debug: () => {}, info: (...a) => console.log('[info]', ...a), warn: (...a) => console.warn('[warn]', ...a), error: (...a) => console.error('[error]', ...a), fatal: (...a) => console.error('[fatal]', ...a), }, }) ``` The quickest way to see internal logs without writing a custom logger is `ZAILEYS_DEBUG=1` (or `ZAILEYS_DEBUG=debug`) in your environment. ## `citation` Supplies the predicates behind the per-message `citation` object in [message context](/events). Both fields accept either a list of JIDs or an async predicate function. ```typescript const client = new Client({ citation: { authors: ['628xxx@s.whatsapp.net'], // or (jid) => jid.endsWith('@s.whatsapp.net') banned: (jid) => jid.startsWith('62800'), }, }) client.on('text', async (msg) => { if (await msg.citation.banned()) return if (await msg.citation.authors()) await msg.reply('Hello, author!') }) ``` ## `reconnect` Tunes the exponential-backoff reconnect strategy. The default `{}` uses the values below; override only the fields you care about. | Field | Type | Default | Description | | ----- | ---- | ------- | ----------- | | `enabled` | `boolean` | `true` | Whether to reconnect at all after a non-fatal disconnect. | | `maxAttempts` | `number` | `Infinity` | Maximum reconnect attempts before giving up. | | `initialDelayMs` | `number` | `3000` | Base delay for the first attempt; doubles each subsequent attempt. (Default raised from `1000` — instant reconnect storms are a ban trigger.) | | `maxDelayMs` | `number` | `60000` | Upper bound on the (jittered) delay between attempts. | | `jitterFactor` | `number` | `0.2` | Randomization applied to the delay (±20% by default) to avoid thundering herds. | | `rateLimitedDelayMs` | `number` | `300000` | Fixed backoff used when the disconnect reason is `rate-limited` (HTTP 429), instead of the exponential ladder. | ```typescript const client = new Client({ reconnect: { maxAttempts: 10, initialDelayMs: 2000, maxDelayMs: 30000, jitterFactor: 0.3, }, }) ``` Fatal disconnects (such as logged-out) never trigger a reconnect regardless of these settings; zaileys clears the auth folder so you can re-authenticate. See [Events](/events) for the `disconnect` / `reconnecting` payloads. A `rate-limited` (HTTP 429) disconnect is **non-fatal** — zaileys still reconnects, but it uses the fixed `rateLimitedDelayMs` (default 5 minutes) instead of the exponential ladder, so it backs off hard rather than hammering WhatsApp while you are being throttled. ## `authGuard` `authGuard` bounds how many times the client regenerates a login QR or requests a pairing code. Every reconnect creates a fresh socket that re-emits a QR / requests a new pairing code; with no cap and no cooldown, an auth flow that never completes turns into a loop that spams WhatsApp — which answers with HTTP 429 (`rate-overlimit`) and the dreaded *"Your account is restricted right now"*. The guard puts a ceiling on that. | Field | Type | Default | Description | | ----- | ---- | ------- | ----------- | | `enabled` | `boolean` | `true` | Set `{ enabled: false }` to restore the old unlimited behavior. | | `maxQrAttempts` | `number` | `5` | Total QR codes emitted before the client gives up. | | `maxPairingAttempts` | `number` | `3` | Total pairing-code requests before giving up. | | `pairingCooldownMs` | `number` | `60000` | Base cooldown between pairing requests; it escalates per attempt (60s, then 120s, then 180s…) capped at `300000` (5 min). | When the budget is exhausted the client **stops** (it does not keep looping), emits an [`auth-exhausted`](/events) event, and tears down. Calling `client.connect()` again resets the budget and retries; the budget also resets automatically on a successful connection. ```typescript const client = new Client({ authType: 'pairing', phoneNumber: '628xxxxxxxxxx', authGuard: { maxPairingAttempts: 3, pairingCooldownMs: 60_000, }, }) client.on('auth-exhausted', ({ kind, attempts, max }) => { console.error(`auth gave up after ${attempts}/${max} ${kind} attempts — fix auth, then connect() again`) }) ``` Keep `authGuard` on. It is the main protection against spamming WhatsApp into an account restriction during a stuck QR / pairing loop. Only set `{ enabled: false }` if you have your own external throttling. See [Account restricted / banned](/troubleshooting) if you have already hit the limit. ## `operationGuard` `operationGuard` serializes and spaces out sensitive group / community / newsletter operations. Rapidly joining or creating groups and mass-adding members is one of the top ban triggers, so each operation **category** has a minimum interval — a rapid second call to the same category simply waits until the interval elapses. | Field | Type | Default | Description | | ----- | ---- | ------- | ----------- | | `enabled` | `boolean` | `true` | Set `{ enabled: false }` to disable spacing entirely. | | `intervalsMs` | `Partial>` | `{}` | Per-category minimum-interval overrides (ms). | Default minimum interval per category: | Category | Default interval (ms) | | -------- | --------------------- | | `group.create` | `60000` | | `group.join` | `30000` | | `group.participants` | `10000` | | `group.update` | `3000` | | `community.create` | `120000` | | `community.join` | `30000` | | `community.update` | `3000` | | `newsletter.create` | `120000` | | `newsletter.follow` | `2000` | | `newsletter.update` | `3000` | The affected methods are `client.group.create` / `addMember` / `removeMember` / `promote` / `demote` / `acceptInvite`, `client.community.create` / `createGroup` / `acceptInvite`, and `client.newsletter.create` / `follow` / `unfollow`. ```typescript const client = new Client({ operationGuard: { intervalsMs: { 'group.participants': 15_000, // slow member adds down even further }, }, }) ``` Mass group joins / creates / member-adds on a fresh number are a fast path to a ban. Leaving `operationGuard` on (the default) keeps those calls spaced out automatically. Disable with `{ enabled: false }` only if you handle pacing yourself. ## `presence` `presence` drops duplicate presence updates. A repeated `client.presence.typing(jid)` / `recording(jid)` / `online()` for the **same** `(type, chat)` within `minIntervalMs` is silently dropped (no socket call); different chats are independent. | Field | Type | Default | Description | | ----- | ---- | ------- | ----------- | | `enabled` | `boolean` | `true` | Set `{ enabled: false }` to send every presence update. | | `minIntervalMs` | `number` | `1000` | Window within which a repeated presence update for the same chat is dropped. | ```typescript const client = new Client({ presence: { minIntervalMs: 1000 }, }) ``` Spamming presence updates (e.g. emitting "typing…" on every keystroke) is needless socket traffic that contributes to looking abusive. Throttling is on by default; disable it with `{ enabled: false }` if you need every update through. ## `scheduleRateLimitPerSec` Caps how many scheduled messages are dispatched per second (default `1`; set `0` to disable). If many overdue scheduled jobs come due at the same instant — for example right after `loadPending()` replays a backlog — they no longer all fire simultaneously, which would otherwise look like a burst of automated sends. ```typescript const client = new Client({ scheduleRateLimitPerSec: 1, // at most one scheduled message per second }) ``` Smoothing the scheduler's output spreads a backlog over time instead of blasting it at once. Set `scheduleRateLimitPerSec: 0` only if you explicitly want every due job to fire immediately. ## `autoConnect` With the default `true`, the constructor schedules `connect()` on the next microtask, so simply constructing a `Client` starts connecting. Set it to `false` to register listeners first (or do setup work) and connect explicitly. ```typescript const client = new Client({ autoConnect: false }) client.on('qr', ({ qrString }) => console.log('Scan:', qrString)) client.on('connect', ({ me }) => console.log('Ready as', me.id)) await client.connect() ``` Under auto-connect, an early connection failure is reported through the `error` event (only if you have an `error` listener). With manual connect, the rejected `connect()` promise is yours to catch. ## `qrTerminal` Controls whether the QR code is rendered to the terminal. The `qr` event still fires either way, so disable this when you render the QR yourself (e.g. in a web UI) and do not want terminal output. ```typescript // Emit the QR event only; do not draw it in the terminal. const client = new Client({ qrTerminal: false }) client.on('qr', ({ qrString }) => renderInBrowser(qrString)) ``` ## `statusLog` When `true` (default), zaileys writes concise connection status lines (connecting, qr, pairing-code, connected, reconnecting, disconnect) to `stderr`, and suppresses noisy libsignal log output. Set it to `false` for completely silent operation when you handle status via events instead. ```typescript const client = new Client({ statusLog: false }) // no stderr status lines ``` ## `cacheSignal` When `true` (default), the client wraps your `auth` store in an in-memory cache for signal keys on the first `connect()`, reducing repeated reads from disk/DB. Disable it if your adapter already caches or you need every read to hit the backing store. ```typescript const client = new Client({ cacheSignal: false }) ``` ## `baileys` Escape hatch for advanced Baileys socket configuration. Your object is spread into the internal config **after** the safety defaults `markOnlineOnConnect: false`, `syncFullHistory: false`, and `qrTimeout: 60000` — so you can override those three — and **before** the managed `auth` and `logger`, which you cannot. ```typescript const client = new Client({ baileys: { browser: ['zaileys', 'Chrome', '1.0.0'], syncFullHistory: false, }, }) ``` Do not set `auth` or `logger` here — the client forces them and your values are ignored. Use the top-level `auth` / `store` / `logger` options instead. `markOnlineOnConnect`, `syncFullHistory`, and `qrTimeout` are merely internal defaults you *can* override here if you really need to. ## Fully-configured example A `Client` exercising the full surface of `ClientOptions`: ```typescript const client = new Client({ sessionId: 'production-bot', authType: 'pairing', phoneNumber: '628xxxxxxxxxx', auth: new SqliteAuthStore({ database: './zaileys.db' }), store: new SqliteMessageStore({ database: './zaileys.db' }), commandPrefix: ['/', '!'], ignoreMe: true, citation: { authors: ['628xxx@s.whatsapp.net'], banned: (jid) => jid.startsWith('62800'), }, reconnect: { maxAttempts: 20, initialDelayMs: 3000, maxDelayMs: 30000, jitterFactor: 0.25, rateLimitedDelayMs: 300_000, }, authGuard: { maxQrAttempts: 5, maxPairingAttempts: 3 }, operationGuard: { enabled: true }, presence: { minIntervalMs: 1000 }, scheduleRateLimitPerSec: 1, autoConnect: true, qrTerminal: false, statusLog: true, cacheSignal: true, logger: { info: (...a) => console.log('[info]', ...a), warn: (...a) => console.warn('[warn]', ...a), error: (...a) => console.error('[error]', ...a), }, baileys: { browser: ['zaileys', 'Chrome', '1.0.0'], }, }) client.on('pairing-code', ({ code }) => console.log('Pairing code:', code)) client.on('connect', ({ me }) => console.log('Connected as', me.id)) client.command('ping', async (ctx) => { await ctx.reply('pong') }) ``` ## Which options unlock which features | Goal | Option(s) | | ---- | --------- | | Enable the [command router](/commands) | `commandPrefix` | | Persist auth/messages across restarts | `auth`, `store` → [Storage Adapters](/storage) | | Log in without scanning a QR | `authType: 'pairing'` + `phoneNumber` | | Render the QR in your own UI | `qrTerminal: false` + listen for the [`qr` event](/events) | | Run multiple accounts in one process | one `Client` per unique `sessionId` | | Quiet operation | `statusLog: false`, default `silent` logger | | Connect on your own schedule | `autoConnect: false` + `await client.connect()` | | Per-message author/ban checks | `citation` → [Events](/events) | | Avoid WhatsApp spam restriction / ban | `authGuard`, `operationGuard`, `presence` (on by default) | ## See also - [Client & Lifecycle](/client) — connecting, events, sending, domain namespaces - [Events](/events) — full event payloads and message context - [Commands](/commands) — the framework `commandPrefix` enables - [Storage Adapters](/storage) — every `auth` / `store` adapter and its options --- # Client & Lifecycle The `Client` is the single entry point to zaileys. You construct one per WhatsApp session, it manages the connection to WhatsApp Web, emits [events](/events) for everything that happens, and exposes helpers for sending and mutating messages plus high-level domain operations (groups, presence, privacy, newsletters, communities). ```typescript const client = new Client({ sessionId: 'default' }) client.on('connect', ({ me }) => console.log('ready as', me.id)) ``` By default the constructor **connects automatically** (`autoConnect: true`). You can also drive the lifecycle yourself by passing `autoConnect: false` and calling `client.connect()` when you are ready. See [Connection lifecycle](#connection-lifecycle). ## Constructing the client `new Client(options?)` accepts a single optional options object. Every field has a sensible default, so `new Client()` is valid and produces a QR-login session stored under `./.zaileys/auth/default`. | Option | Type | Default | Description | | ------ | ---- | ------- | ----------- | | `sessionId` | `string` | `'default'` | Logical session name. Used as the default auth folder (`./.zaileys/auth/`) and reported on every event payload. Run multiple clients with distinct ids for multi-account. | | `authType` | `'qr' \| 'pairing'` | `'qr'` | Login method. `'pairing'` requires `phoneNumber`. | | `phoneNumber` | `string` | — | Phone number in international format (digits only, e.g. `628123456789`). Required when `authType` is `'pairing'`. | | `autoConnect` | `boolean` | `true` | Connect automatically on construction. Set `false` to call `connect()` manually. | | `cacheSignal` | `boolean` | `true` | Wrap the auth store in an in-memory signal-key cache for faster session reads. | | `qrTerminal` | `boolean` | `true` | Print the QR code to the terminal on `'qr'` (QR login only). | | `statusLog` | `boolean` | `true` | Write human-readable connection status lines to `stderr` and suppress noisy libsignal logs. | | `commandPrefix` | `string \| string[]` | — | Prefix(es) that enable the [command framework](/commands) (e.g. `'/'` or `['/', '!']`). | | `ignoreMe` | `boolean` | `true` | Drop inbound messages sent by the bot's own account so handlers don't react to themselves. | | `citation` | `CitationConfig` | — | Configure citation/quoted-message enrichment on inbound events. See [Events](/events). | | `reconnect` | `ReconnectOptions` | see below | Auto-reconnect backoff tuning. | | `auth` | `AuthStoreBundle` | `FileAuthStore` | Credential/signal store. Defaults to file storage. See [Storage Adapters](/storage). | | `store` | `MessageStore` | `MemoryMessageStore` | Message store used for `forward()`, quoted lookups and scheduling. See [Storage Adapters](/storage). | | `logger` | `Logger` (Pino-compatible) | quiet | Structured logger. Must implement `debug/info/warn/error/fatal`. | | `baileys` | `Partial` | `{}` | Escape hatch merged into the underlying Baileys socket config. | For the full breakdown of `authType`, storage adapters, and logging, see the dedicated [Configuration](/configuration) page. This page focuses on the lifecycle and the methods the client exposes. ### `reconnect` (ReconnectOptions) | Option | Type | Default | Description | | ------ | ---- | ------- | ----------- | | `enabled` | `boolean` | `true` | Whether to auto-reconnect after a non-fatal disconnect. | | `maxAttempts` | `number` | `Infinity` | Max consecutive reconnect attempts before giving up. | | `initialDelayMs` | `number` | `1000` | Delay before the first reconnect attempt. | | `maxDelayMs` | `number` | `60000` | Upper bound for the exponential backoff delay. | | `jitterFactor` | `number` | `0.2` | Random jitter (±20% by default) applied to each delay to avoid thundering-herd reconnects. | ```typescript const client = new Client({ sessionId: 'support-bot', authType: 'qr', ignoreMe: true, reconnect: { maxAttempts: 10, initialDelayMs: 2000, maxDelayMs: 30_000 }, }) ``` ### Pairing-code login When `authType` is `'pairing'` you must supply `phoneNumber`. zaileys emits a `pairing-code` event with the code to enter on your phone (Linked Devices → Link with phone number) instead of a QR. ```typescript const client = new Client({ authType: 'pairing', phoneNumber: '628123456789', }) client.on('pairing-code', ({ code, expiresAt }) => { console.log('Enter this code on WhatsApp:', code, '(expires', new Date(expiresAt), ')') }) ``` `connect()` rejects immediately with `phoneNumber is required when authType is "pairing"` if you choose pairing without a phone number. ## Connection lifecycle Internally the client runs a small state machine. The current state is readable via `client.state`: ```typescript type ConnectionState = | 'idle' | 'connecting' | 'qr-pending' | 'pairing-pending' | 'connected' | 'reconnecting' | 'disconnecting' | 'disconnected' ``` The typical happy path is `idle → connecting → qr-pending/pairing-pending → connected`. A drop goes `connected → reconnecting → connecting → connected` (with backoff), and a clean shutdown goes `connected → disconnecting → disconnected`. ### `connect()` ```typescript connect(): Promise ``` Opens the WhatsApp socket and resolves once the connection is **open** (`'connect'` event fired). If the session has no stored credentials, this is when the QR or pairing code is produced. ### Construct with `autoConnect: false` ```typescript const client = new Client({ sessionId: 'manual', autoConnect: false }) ``` ### Wire up your listeners first ```typescript client.on('qr', ({ qrString }) => console.log('Scan QR:', qrString)) client.on('connect', ({ me }) => console.log('connected as', me.id)) ``` ### Call `connect()` and await it ```typescript await client.connect() console.log('state is now', client.state) // 'connected' ``` `connect()` is idempotent. Calling it while `connecting` or `connected` resolves immediately without opening a second socket; calling it from `disconnected`/`reconnecting` re-opens the session. ### Auto-connect With the default `autoConnect: true`, the constructor schedules `connect()` on the next microtask. This means you can attach listeners synchronously right after construction and they will be in place before the socket opens: ```typescript const client = new Client() // connects automatically // These run on the same tick, before the queued connect() fires client.on('qr', ({ qrString }) => console.log(qrString)) client.on('message', (ctx) => console.log(ctx.text)) ``` If auto-connect fails, the client logs the error and emits an `'error'` event **only if** you have an `'error'` listener attached, so an unhandled rejection never crashes your process silently. ### Reconnection After a non-fatal disconnect (network blip, `restart-required`, etc.) zaileys reconnects automatically using exponential backoff with jitter, governed by the `reconnect` options. Each retry emits a `reconnecting` event: ```typescript client.on('reconnecting', ({ attempt, delayMs, reason }) => { console.log(`reconnect #${attempt} in ${delayMs}ms (reason: ${reason})`) }) ``` Fatal reasons (`logged-out`, `forbidden`, `connection-replaced`, `bad-session`, …) do **not** trigger a reconnect; for credential-invalidating reasons the stored auth is cleared automatically so the next `connect()` starts a fresh login. If you keep seeing `reconnecting` without ever reaching `connect`, your stored session is likely corrupt. zaileys hints at this in the status log after a couple of failed attempts — delete the session's auth folder (`./.zaileys/auth/`) and re-scan. See [Troubleshooting](/troubleshooting). ### `disconnect()` ```typescript disconnect(): Promise ``` Gracefully tears down the session: cancels any pending reconnect timer, detaches the inbound pipeline and command dispatcher, disposes the scheduler, ends the socket, and closes the auth and message stores. The session credentials are **kept**, so a later `connect()` resumes without re-scanning. ```typescript await client.disconnect() console.log(client.state) // 'disconnected' ``` ### `logout()` ```typescript logout(): Promise ``` Like `disconnect()`, but also logs out on WhatsApp's side and **deletes** the stored credentials and signal keys. The next `connect()` requires a fresh QR/pairing login. ```typescript await client.logout() // unlinks the device and clears local auth ``` ### Read-only accessors | Accessor | Type | Description | | -------- | ---- | ----------- | | `client.sessionId` | `string` | The resolved session id. | | `client.state` | `ConnectionState` | Current lifecycle state. | | `client.socket` | `BaileysSocket \| undefined` | The underlying Baileys socket (advanced escape hatch). | | `client.store` | `MessageStore` | The active message store. | | `client.auth` | `AuthStoreBundle` | The active auth store bundle. | ## Listening to events: `on` / `off` `Client` is a typed event emitter. Connection events (`connect`, `disconnect`, `qr`, `pairing-code`, `reconnecting`, `error`) and inbound message events (`message`, `text`, …) share the same fully-typed API. See [Events](/events) for the complete catalog. ```typescript client.on('connect', ({ me }) => console.log('me:', me.id)) const onText = (ctx) => console.log(ctx.text) client.on('text', onText) client.off('text', onText) // detach when done ``` ## Commands & middleware: `command` / `use` When you set `commandPrefix`, the client activates the [command framework](/commands). Register handlers with `command(spec, handler)` and global middleware with `use(middleware)`. Both return `this`, so they chain. ```typescript const client = new Client({ commandPrefix: ['/', '!'] }) const logging: Middleware = async (ctx, next) => { console.log(`[command] ${ctx.command} from ${ctx.senderId}`) await next() } client .use(logging) .command('ping', async (ctx) => { await ctx.reply('pong') }) .command('help|h|?', async (ctx) => { await ctx.reply('Commands: /ping, /help') }) ``` The dispatcher attaches itself once the client is connected and at least one command is registered. Registering commands before `connect()` is fine — they wire up automatically on open. Full details on `ctx`, argument parsing, and flags live in [Commands](/commands). ## Sending & mutating messages `client.send(jid)` opens a fluent [message builder](/sending-messages). Awaiting a terminal builder call resolves to a Baileys `WAMessageKey`, which the mutation helpers below consume to act on that exact message. The recipient may be a full JID (`628123456789@s.whatsapp.net`, group `xxx@g.us`) or a bare phone-number/username that zaileys resolves to a JID for you. ```typescript const key = await client.send('628123456789@s.whatsapp.net').text('Original') ``` | Method | Signature | Description | | ------ | --------- | ----------- | | `send` | `send(to: string): MessageBuilder` | Open a builder for a recipient JID or resolvable handle. See [Sending Messages](/sending-messages). | | `edit` | `edit(key: WAMessageKey): EditBuilder` | Edit a previously-sent message. Returns a builder; await its terminal call. | | `react` | `react(key, emoji: string): Promise` | React to a message. Pass an empty string `''` to remove the reaction. | | `delete` | `delete(key, opts?: { forEveryone?: boolean }): Promise` | Delete a message. `forEveryone` defaults to `true`. | | `forward` | `forward(key, to: string): Promise` | Forward a stored message to another recipient. | | `broadcast` | `broadcast(jids, build, opts?): Promise` | Send one built message to many recipients. See [Automation](/automation). | | `scheduleAt` | `scheduleAt(date, build): Promise` | Schedule a message for a future time. See [Automation](/automation). | ```typescript const client = new Client() const jid = '628123456789@s.whatsapp.net' const otherJid = '628987654321@s.whatsapp.net' const key = await client.send(jid).text('Original') await client.edit(key).text('Edited text') // edit in place await client.react(key, '👍') // add reaction await client.react(key, '') // remove reaction await client.delete(key, { forEveryone: true }) // unsend for everyone await client.forward(key, otherJid) // forward to another chat ``` **`delete` semantics:** with `forEveryone: true` (the default) the message is unsent for all participants. With `forEveryone: false` only your own copy is deleted (the helper forces `fromMe: true` on the key). **`forward` requires the message store:** the source message is looked up by key in `client.store`. If it isn't there (e.g. an old message that was never stored), `forward` throws `MESSAGE_NOT_FOUND`. Use a persistent [store](/storage) if you forward older messages. ## Domain namespaces Higher-level WhatsApp operations are grouped under lazy namespaces on the client. Each is created on first access and proxies to the live socket. | Namespace | Purpose | | --------- | ------- | | `client.group.*` | Create/leave groups, manage participants, subject/description, invites, settings. | | `client.presence.*` | Typing/recording indicators and online/offline presence. | | `client.privacy.*` | Privacy settings, block list, disappearing-messages default. | | `client.newsletter.*` | WhatsApp Channels (newsletters): create/follow/manage. | | `client.community.*` | Communities: create, link/unlink groups, invites. | **NOT_CONNECTED guard.** Every domain method (and `presence`) calls an internal `requireSocket()`. If the client is not connected, the call throws a `ZaileysDomainError`/`ZaileysAutomationError` with code `NOT_CONNECTED` and message `client not connected`. Always wait for the `'connect'` event (or `await client.connect()`) before using these namespaces. The mutation helpers (`send`/`edit`/…) use the same guard but throw a `ZaileysBuilderError` with code `INVALID_OPTIONS` (`client not connected`). See [Error Handling](/error-handling). ### `client.group` | Method | Signature | Description | | ------ | --------- | ----------- | | `create` | `create(subject, participants: string[]): Promise` | Create a group. | | `metadata` | `metadata(groupId): Promise` | Fetch group metadata (subject, participants, …). | | `addMember` | `addMember(groupId, jids: string[]): Promise` | Add participants. | | `removeMember` | `removeMember(groupId, jids: string[]): Promise` | Remove participants. | | `promote` | `promote(groupId, jids: string[]): Promise` | Promote to admin. | | `demote` | `demote(groupId, jids: string[]): Promise` | Demote from admin. | | `updateSubject` | `updateSubject(groupId, subject): Promise` | Change the group name. | | `updateDescription` | `updateDescription(groupId, description?): Promise` | Change/clear the description. | | `leave` | `leave(groupId): Promise` | Leave the group. | | `tagMember` | `tagMember(groupId, jid, label): Promise` | Apply a member label. | | `inviteCode` | `inviteCode(groupId): Promise` | Get the current invite code. | | `revokeInvite` | `revokeInvite(groupId): Promise` | Revoke and regenerate the invite code. | | `acceptInvite` | `acceptInvite(code): Promise` | Join via invite code; returns the group JID. | | `toggleEphemeral` | `toggleEphemeral(groupId, seconds): Promise` | Set disappearing-message timer (0 disables). | | `setting` | `setting(groupId, 'announcement' \| 'not_announcement' \| 'locked' \| 'unlocked'): Promise` | Restrict who can send (`announcement`) or edit group info (`locked`). | ```typescript const groupJid = '120363000000000000@g.us' const meta = await client.group.metadata(groupJid) console.log(meta.subject, meta.participants.length) await client.group.addMember(groupJid, ['628111111111@s.whatsapp.net']) await client.group.promote(groupJid, ['628111111111@s.whatsapp.net']) await client.group.setting(groupJid, 'announcement') // only admins can send const code = await client.group.inviteCode(groupJid) console.log('Join link: https://chat.whatsapp.com/' + code) ``` ### `client.presence` | Method | Signature | Description | | ------ | --------- | ----------- | | `online` | `online(): Promise` | Mark yourself available. | | `offline` | `offline(): Promise` | Mark yourself unavailable. | | `typing` | `typing(jid, ms?): Promise` | Show "typing…" in a chat. If `ms` is given, auto-clears after that many ms. | | `recording` | `recording(jid, ms?): Promise` | Show "recording audio…". Auto-clears after `ms` if given. | ```typescript const jid = '628123456789@s.whatsapp.net' await client.presence.online() await client.presence.typing(jid, 3000) // typing… auto-clears after 3s await client.send(jid).text('Done thinking!') ``` A failed presence update throws a `ZaileysAutomationError` with code `PRESENCE_FAILED`. ### `client.privacy` | Method | Signature | Description | | ------ | --------- | ----------- | | `set` | `set(config: PrivacyConfig & { readReceipts?: WAReadReceiptsValue \| boolean }): Promise` | Update one or more privacy settings. Only provided keys are changed. | | `get` | `get(): Promise` | Fetch current privacy settings. | | `block` | `block(jid): Promise` | Block a contact. | | `unblock` | `unblock(jid): Promise` | Unblock a contact. | | `blocklist` | `blocklist(): Promise` | List blocked JIDs. | | `disappearingMode` | `disappearingMode(seconds): Promise` | Set the default disappearing-messages timer for new chats. | `PrivacyConfig` keys: `lastSeen`, `online`, `profile`, `status` (`WAPrivacyValue`), `groupAdd` (`WAPrivacyGroupAddValue`), and `readReceipts` (a `WAReadReceiptsValue`, or a `boolean` which maps to `'all'`/`'none'`). ```typescript await client.privacy.set({ lastSeen: 'contacts', online: 'match_last_seen', readReceipts: false, // → 'none' groupAdd: 'contacts', }) const settings = await client.privacy.get() console.log(settings) await client.privacy.block('628999999999@s.whatsapp.net') console.log(await client.privacy.blocklist()) ``` ### `client.newsletter` WhatsApp Channels. A newsletter JID looks like `xxxxxxxxxxxx@newsletter`. | Method | Signature | Description | | ------ | --------- | ----------- | | `create` | `create(name, opts?: { description?, picture?: Buffer }): Promise` | Create a channel; optionally set description and picture. | | `metadata` | `metadata(jid): Promise` | Fetch channel metadata (throws `NEWSLETTER_NOT_FOUND` if missing). | | `follow` | `follow(jid): Promise` | Follow a channel. | | `unfollow` | `unfollow(jid): Promise` | Unfollow a channel. | | `updateName` | `updateName(jid, name): Promise` | Rename the channel. | | `updateDescription` | `updateDescription(jid, description): Promise` | Update the description. | | `updatePicture` | `updatePicture(jid, picture: Buffer): Promise` | Update the channel picture. | | `mute` | `mute(jid): Promise` | Mute notifications. | | `unmute` | `unmute(jid): Promise` | Unmute notifications. | | `delete` | `delete(jid): Promise` | Delete the channel. | ```typescript const channel = await client.newsletter.create('zaileys updates', { description: 'Release notes and tips', }) console.log('created channel', channel.id) await client.newsletter.updateDescription(channel.id, 'Now with scheduling!') await client.newsletter.follow('120363111111111111@newsletter') ``` ### `client.community` Communities group multiple chats under one umbrella. A community is identified by a group-style JID. | Method | Signature | Description | | ------ | --------- | ----------- | | `create` | `create(subject, body): Promise` | Create a community with name and announcement body. | | `createGroup` | `createGroup(subject, participants: string[], communityId): Promise` | Create a group directly inside a community. | | `linkGroup` | `linkGroup(communityId, groupId): Promise` | Link an existing group into the community. | | `unlinkGroup` | `unlinkGroup(communityId, groupId): Promise` | Unlink a group. | | `subGroups` | `subGroups(communityId): Promise` | List the community's linked groups. | | `leave` | `leave(communityId): Promise` | Leave the community. | | `updateSubject` | `updateSubject(communityId, subject): Promise` | Rename the community. | | `updateDescription` | `updateDescription(communityId, description?): Promise` | Update the description. | | `inviteCode` | `inviteCode(communityId): Promise` | Get the invite code. | | `revokeInvite` | `revokeInvite(communityId): Promise` | Revoke and regenerate the invite code. | | `acceptInvite` | `acceptInvite(code): Promise` | Join via invite code. | ```typescript const community = await client.community.create('Devs', 'Welcome to the dev community') await client.community.linkGroup(community.id, '120363000000000000@g.us') const groups = await client.community.subGroups(community.id) console.log('linked groups:', groups.map((g) => g.subject)) ``` ## A complete lifecycle example ```typescript const client = new Client({ sessionId: 'demo', autoConnect: false }) client.on('qr', ({ qrString }) => console.log('Scan QR:', qrString)) client.on('reconnecting', ({ attempt, delayMs }) => console.log(`reconnecting #${attempt} in ${delayMs}ms`), ) client.on('error', ({ error }) => console.error('client error:', error.message)) client.on('connect', async ({ me }) => { console.log('connected as', me.id) await client.presence.online() }) client.on('text', async (ctx) => { if (ctx.text?.toLowerCase() === 'ping') { const key = await client.send(ctx.roomId).text('pong') await client.react(key, '🏓') } }) await client.connect() // later, on shutdown: process.on('SIGINT', async () => { await client.disconnect() process.exit(0) }) ``` ## Next steps - [Configuration](/configuration) — auth types, storage adapters, logging, and the `baileys` escape hatch. - [Sending Messages](/sending-messages) — the full message builder API behind `client.send()`. - [Events](/events) — every event the client emits and the rich message context object. - [Commands](/commands) — the command framework enabled by `commandPrefix`. - [Automation](/automation) — `broadcast()` and `scheduleAt()` in depth. --- # Events Everything that happens on your WhatsApp connection — new messages, button taps, group changes, calls, the QR code — is delivered to your code as a typed **event**. You subscribe with `client.on(name, handler)`, and zaileys hands your handler a fully-typed payload for that specific event. ```typescript const client = new Client() client.on('qr', ({ qrString }) => console.log('Scan QR:', qrString)) client.on('connect', ({ me }) => console.log('Connected as', me.id)) client.on('text', async (msg) => { console.log(msg.senderId, '|', msg.text) await msg.reply(`You said: ${msg.text}`) }) ``` Every handler is fully type-safe. The payload type is inferred from the event name — TypeScript knows that `'text'` gives you a [message context](#the-message-context) while `'button-click'` gives you a `ButtonClickPayload`. No casting required. ## Subscribing & unsubscribing `client.on` returns an **unsubscribe function**. Call it to stop listening. There is also `client.off(name, handler)` if you kept a reference to the original handler. ```typescript // on() returns a disposer const stop = client.on('text', (msg) => console.log(msg.text)) stop() // remove this listener later // or remove by reference const handler = (msg: MessageContext) => console.log(msg.text) client.on('text', handler) client.off('text', handler) ``` A handler that throws is caught and logged by zaileys — one bad listener will not crash the connection or block other listeners for the same event. Still, prefer `try/catch` inside async handlers so you control the failure. The `Client` is a typed event emitter (`TypedEventEmitter`), where `ClientEventMap` is the union of connection lifecycle events and inbound message events. See [Client](/client) for the full constructor and `connect()` / `disconnect()` API. ## Event catalog ### Connection lifecycle These fire as your session connects, authenticates, and (occasionally) drops. Every payload carries the `sessionId` of the client. | Event | Payload | When | | --- | --- | --- | | `qr` | `{ sessionId, qrString, expiresAt }` | A QR code is ready to scan (only in `authType: 'qr'`). `qrString` is the raw string to render; `expiresAt` is an epoch ms timestamp. | | `pairing-code` | `{ sessionId, code, expiresAt }` | A pairing code is ready (only in `authType: 'pairing'`). Enter `code` on your phone under *Link with phone number*. | | `connect` | `{ sessionId, me }` | The socket is fully open and authenticated. `me` is `{ id, lid?, name? }` — your own account. | | `reconnecting` | `{ sessionId, attempt, delayMs, reason }` | A reconnect is scheduled after a recoverable drop. `attempt` is the 1-based try number, `delayMs` is the backoff wait, `reason` is a `DisconnectReasonDomain`. | | `disconnect` | `{ sessionId, reason, willReconnect }` | The connection closed. `reason` is a `DisconnectReasonDomain`; `willReconnect` tells you whether zaileys will retry. | | `auth-exhausted` | `{ sessionId, kind, attempts, max }` | The [`authGuard`](/configuration#authguard) budget ran out — too many QR / pairing regenerations. `kind` is `'qr' \| 'pairing'`. The client stops and will not auto-retry until you call `connect()` again. | | `error` | `{ sessionId, error }` | An internal connection error surfaced as an `Error`. | ### Inbound messages These deliver a rich [message context](#the-message-context) object — the same shape across every message type. The `media` accessor is populated only for media kinds. | Event | Payload | When | | --- | --- | --- | | `text` | `MessageContext` | A plain text (or extended text) message arrives. | | `image` | `MessageContext` (with `media`) | An image message arrives. `msg.text` holds the caption. | | `video` | `MessageContext` (with `media`) | A video message arrives. `msg.text` holds the caption. | | `audio` | `MessageContext` (with `media`) | An audio message / voice note arrives. | | `document` | `MessageContext` (with `media`) | A document/file message arrives. | | `sticker` | `MessageContext` (with `media`) | A sticker arrives. | | `mention` | `MentionContext` | An incoming message mentions someone. Adds `mentionedJids` and `selfJid`. | | `mention-all` | `MentionAllContext` | A message tags everyone (`@all` / hidetags). Adds `isMentionAll`, `selfJid`, `members?`. | ### Message mutations | Event | Payload | When | | --- | --- | --- | | `edit` | `EditPayload` | A previously sent message was edited. `{ key, newContent, editedAt, sender }`. | | `delete` | `DeletePayload` | A message was deleted. `{ key, deletedFor: 'everyone' \| 'me', sender, timestamp }`. | | `reaction` | `ReactionPayload` | Someone reacted to (or un-reacted from) a message. `{ key, emoji, sender, timestamp }`. `emoji` is `null` when the reaction is removed. | | `poll-vote` | `PollVotePayload` | A poll vote was cast/changed. `{ pollKey, selectedOptions, voter, timestamp }`. | ### Interactive replies | Event | Payload | When | | --- | --- | --- | | `button-click` | `ButtonClickPayload` | A user tapped a reply button. `{ key, buttonId, buttonText?, sender, timestamp }`. | | `list-select` | `ListSelectPayload` | A user picked a row from a list message. `{ key, rowId, title?, sender, timestamp }`. | See [Interactive Messages](/interactive) for how to send buttons, lists, templates, and carousels. ### Groups | Event | Payload | When | | --- | --- | --- | | `group-update` | `GroupUpdatePayload` | Group metadata changed. `{ groupId, update, timestamp }` where `update` is a partial of `{ subject, description, announce, restrict, ephemeralDuration }`. | | `group-join` | `GroupJoinPayload` | Participants joined. `{ groupId, participants, action: 'add' \| 'invite' \| 'invite-link', by?, timestamp }`. | | `group-leave` | `GroupLeavePayload` | Participants left/removed. `{ groupId, participants, action: 'remove' \| 'leave', by?, timestamp }`. | | `member-tag` | `MemberTagPayload` | A member was tagged with a label. `{ groupId, participant, participantAlt?, label, timestamp }`. | ### Calls, presence, lifecycle | Event | Payload | When | | --- | --- | --- | | `call-incoming` | `CallPayload` (`kind: 'incoming'`) | An incoming call. `{ callId, from, isGroup, isVideo, timestamp, status?, kind }`. | | `call-ended` | `CallPayload` (`kind: 'ended'`) | A call ended. Same shape as above with `kind: 'ended'`. | | `presence` | `PresencePayload` | A contact's presence changed. `{ jid, participant?, status }` where `status` is `available \| unavailable \| composing \| recording \| paused`. | | `history-sync` | `HistorySyncPayload` | A history sync batch progressed. `{ syncType, status: 'complete' \| 'paused', explicit }`. | | `newsletter` | `NewsletterPayload` | A newsletter/channel event (reaction, view, participants, settings). `{ newsletterId, timestamp, action, ... }`. | | `limited` | `LimitedPayload` | WhatsApp rate-limited the session. Either `{ reason: 'reachout-timelock', retryAt }` or `{ reason: 'chat-limit-reached', usedQuota?, totalQuota? }`. | --- ## Connection events in depth ### `qr` — render the login QR Fires whenever a fresh QR is available. The simplest integration prints the raw string; for a scannable terminal QR, enable `qrTerminal: true` in [client options](/configuration) or render `qrString` with your own QR library. ```typescript client.on('qr', ({ qrString, expiresAt }) => { console.log('Scan this QR:', qrString) console.log('Expires at:', new Date(expiresAt).toLocaleTimeString()) }) ``` ### `connect` — you are online Fires once the socket is authenticated and ready. This is the right place to kick off work that needs an active connection (sending a startup message, syncing state, etc.). ```typescript client.on('connect', async ({ sessionId, me }) => { console.log(`[${sessionId}] connected as ${me.id}${me.name ? ` (${me.name})` : ''}`) }) ``` ### `disconnect` — the connection closed `reason` is a normalized `DisconnectReasonDomain` (one of `logged-out`, `connection-replaced`, `forbidden`, `restart-required`, `bad-session`, `connection-closed`, `connection-lost`, `multi-device-mismatch`, `unavailable-service`, `rate-limited`, `unknown`). `willReconnect` tells you whether zaileys is going to retry automatically. `rate-limited` maps from a WhatsApp HTTP 429 and is **non-fatal** — zaileys reconnects, but with the long fixed [`rateLimitedDelayMs`](/configuration#reconnect) backoff (default 5 minutes) instead of the exponential ladder. ```typescript client.on('disconnect', ({ reason, willReconnect }) => { console.log('Disconnected:', reason, willReconnect ? '(reconnecting…)' : '(stopped)') }) ``` When `reason` is `logged-out`, `connection-replaced`, or `forbidden`, the session is fatal — zaileys will **not** reconnect and the stored auth is cleared. You will need to scan/pair again. See [Error Handling](/error-handling) for reconnect tuning. For pairing-code login instead of QR, listen for `pairing-code`: ```typescript const client = new Client({ authType: 'pairing', phoneNumber: '628xxxxxxxxxx' }) client.on('pairing-code', ({ code }) => console.log('Enter on phone:', code)) ``` The `reconnecting` event lets you observe backoff before the retry actually happens: ```typescript client.on('reconnecting', ({ attempt, delayMs, reason }) => { console.log(`Reconnect #${attempt} in ${delayMs}ms (was: ${reason})`) }) ``` The `auth-exhausted` event fires once the [`authGuard`](/configuration#authguard) budget is spent — the client has emitted `maxQrAttempts` QR codes (default 5) or made `maxPairingAttempts` pairing requests (default 3) without completing login. At that point zaileys **stops**: it tears the socket down and will **not** auto-retry. This is deliberate — endlessly regenerating a QR / pairing code is exactly what spams WhatsApp into restricting the account. To retry, call `connect()` again (which resets the budget); but first investigate *why* auth never completed (wrong phone number, QR never scanned, network never staying up): ```typescript client.on('auth-exhausted', ({ kind, attempts, max }) => { console.error(`auth-exhausted: gave up after ${attempts}/${max} ${kind} attempt(s)`) // fix the underlying cause, then re-arm the budget: // await client.connect() }) ``` Do **not** wrap `connect()` in a tight retry loop on `auth-exhausted` — that defeats the guard and re-creates the very loop that gets accounts restricted. See [Account restricted / banned](/troubleshooting) for guidance. --- ## The message context Every message event (`text`, `image`, `video`, `audio`, `document`, `sticker`, `mention`, `mention-all`) hands your handler a **`MessageContext`** — a single object with all the metadata plus action methods (`reply`, `react`, `replied`, …). This is the workhorse object of zaileys. ```typescript client.on('text', async (msg) => { console.log(msg.senderId) // "628xxxxxxxxxx" console.log(msg.senderName) // "Andi" | null console.log(msg.text) // "hello" console.log(msg.isGroup) // false await msg.reply('Hi!') // quoted reply await msg.react('👍') // react to the message }) ``` ### Identity & routing fields | Property | Type | Description | | --- | --- | --- | | `uniqueId` | `string` | Stable 8-char hash of the message key (`remoteJid \| id \| fromMe`). Handy as a dedupe key. | | `channelId` | `string` | The configured `sessionId` / channel this message came through. | | `chatId` | `string` | The raw WhatsApp message id (`key.id`). | | `chatType` | `ChatType` | `'text' \| 'image' \| 'video' \| 'audio' \| 'document' \| 'sticker' \| 'unknown'`. | | `receiverId` | `string` | Your own account id (the receiver of this inbound message). | | `roomId` | `string \| null` | The group JID (`xxx@g.us`) when in a group, otherwise `null`. | | `senderId` | `string` | The sender's phone-number JID (e.g. `628xxxxxxxxxx`). | | `senderLid` | `string \| null` | The sender's LID (linked-device identifier), if known. | | `senderName` | `string \| null` | The sender's WhatsApp push name. | | `senderDevice` | `SenderDevice` | `'android' \| 'ios' \| 'web' \| 'desktop' \| 'unknown'`. | | `timestamp` | `number` | Message time in epoch **milliseconds**. | ### Content fields | Property | Type | Description | | --- | --- | --- | | `text` | `string` | The message body. For media this is the caption (empty string if none). | | `mentions` | `string[]` | JIDs explicitly mentioned in the message. | | `links` | `string[]` | URLs auto-extracted from `text` (trailing punctuation stripped). | ### Boolean flags All of these are plain `boolean` fields you can branch on directly. | Flag | Meaning | | --- | --- | | `isFromMe` | The message was sent by your own account. | | `isGroup` | The chat is a group. | | `isNewsletter` | The chat is a newsletter/channel. | | `isBroadcast` | A broadcast-list message. | | `isViewOnce` | A view-once message. | | `isEphemeral` | Sent in a disappearing-messages chat. | | `isForwarded` | The message was forwarded. | | `isQuestion` | `text` ends with `?`. | | `isPrefix` | `text` starts with one of your configured command prefixes. | | `isTagMe` | You (`receiverId`) are among the `mentions`. | | `isEdited` | Message is an edit. | | `isDeleted` | Message was deleted. | | `isPinned` / `isUnPinned` | Pin / unpin state. | | `isBot` | Detected as a bot message. | | `isSpam` | Flagged as spam. | | `isHideTags` | A hidetag (silent @all) message. | | `isStatusMention` / `isGroupStatusMention` | Status-mention variants. | | `isStory` | A status/story message. | ### Methods | Method | Returns | Description | | --- | --- | --- | | `reply(content, opts?)` | `Promise` | Send a text reply quoting this message. `opts` is the same `TextOptions` accepted by `send().text()` — including `rich`, `title`, etc. | | `react(emoji)` | `Promise` | React to this message with an emoji. | | `replied()` | `Promise` | Resolve the quoted message (the one this message replied to) as a full context, or `null` if none. | | `roomName()` | `Promise` | The group subject when in a group, else `null`. Cached per room. | | `receiverName()` | `Promise` | Your own account's display name. | | `message()` | `WAMessage` | The raw underlying Baileys message object (escape hatch). | | `media?` | `ContextMedia` | Present only on media events — `{ buffer(), stream() }` to download the attachment. See [Media](/media). | | `citation` | `CitationPredicates` | `{ authors(), banned() }` — async predicates resolving whether the sender is in your configured `authors` / `banned` lists. | `reply`, `react`, `replied`, `roomName`, and `receiverName` are all **async** (they return Promises). Always `await` them inside your handler. #### `reply()` — quoted text reply ```typescript client.on('text', async (msg) => { // plain quoted reply await msg.reply(`Echo: ${msg.text}`) // rich reply (markdown + suggestions) — see /rich-responses await msg.reply( ['*Rich reply* ✨', '', '```ts', 'const x = 1', '```'].join('\n'), { rich: true, title: '🤖 zaileys' }, ) }) ``` #### `react()` — emoji reaction ```typescript client.on('text', async (msg) => { await msg.react('👀') // acknowledge receipt }) ``` #### `replied()` — look up the quoted message Resolves the message this one was a reply to, as a full `MessageContext`. Great for context-aware bots ("reply to my message to translate it"). ```typescript client.on('text', async (msg) => { const quoted = await msg.replied() if (quoted) { console.log('In reply to:', quoted.senderId, '|', quoted.text) await msg.reply(`You quoted: "${quoted.text}"`) } }) ``` #### `media` — download attachments On `image` / `video` / `audio` / `document` / `sticker` events, `msg.media` exposes a `buffer()` and a `stream()`: ```typescript client.on('image', async (msg) => { console.log('caption:', msg.text) if (!msg.media) return const buf = await msg.media.buffer() await writeFile('received.jpg', buf) }) ``` See [Media](/media) for streaming, MIME handling, and re-uploading. #### `citation` — author / banned checks If you configured `citation` in [client options](/configuration), these predicates tell you whether the sender qualifies. ```typescript client.on('text', async (msg) => { if (await msg.citation.banned()) return // ignore banned users if (!(await msg.citation.authors())) return // owner-only command await msg.reply('Welcome, author!') }) ``` ### Mention context The `mention` event extends `MessageContext` with the JIDs mentioned and your own JID; `mention-all` additionally flags `isMentionAll` and may include group `members`. ```typescript client.on('mention', async (msg) => { if (msg.mentionedJids.includes(msg.selfJid)) { await msg.reply('You tagged me!') } }) client.on('mention-all', async (msg) => { console.log('Tagged everyone in', msg.roomId, 'members:', msg.members?.length) }) ``` --- ## Interactive events in depth When you send buttons or lists (see [Interactive Messages](/interactive)), taps come back as `button-click` and `list-select`. These carry the original message `key`, the selected id, and the `sender` — not a full message context. ```typescript client.on('button-click', (ctx) => { console.log('button:', ctx.buttonId, '|', ctx.buttonText, '| from', ctx.sender.jid) if (ctx.buttonId === 'yes') { client.send(ctx.sender.jid).text('You tapped Yes ✅') } }) client.on('list-select', (ctx) => { console.log('row:', ctx.rowId, '|', ctx.title, '| from', ctx.sender.jid) }) ``` `sender` here is a `SenderInfo` (`{ jid, lid?, pn?, username?, pushName?, isMe? }`). Use `ctx.sender.jid` as the recipient when you want to respond via `client.send(...)`. --- ## Type-safe handlers Because `on` is generic over the event name, payloads are inferred automatically — but you can also import the payload types for standalone handler functions. ```typescript import type { MessageContext, ButtonClickPayload, } from 'zaileys' const onText = (msg: MessageContext): void => { console.log(msg.senderId, msg.text) } const onButton = (ctx: ButtonClickPayload): void => { console.log(ctx.buttonId) } const client = new Client() client.on('text', onText) client.on('button-click', onButton) ``` The payload interfaces (`MessageContext`, `MentionContext`, `MentionAllContext`, `ButtonClickPayload`, `ListSelectPayload`, `ReactionPayload`, `EditPayload`, `DeletePayload`, `PollVotePayload`, `GroupJoinPayload`, `GroupLeavePayload`, `GroupUpdatePayload`, `MemberTagPayload`, `CallPayload`, `PresencePayload`, `HistorySyncPayload`, `NewsletterPayload`, `LimitedPayload`, `SenderInfo`) are exported from `zaileys` as `type` exports. --- ## Filtering inbound messages zaileys does not expose middleware on raw events, but two mechanisms control what you receive: **`ignoreMe`** — drop your own outgoing messages before they reach handlers. Set it in [client options](/configuration). ```typescript const client = new Client({ ignoreMe: true }) client.on('text', (msg) => { // msg.isFromMe is never true here when ignoreMe is set }) ``` **Branch inside the handler** — the flags and `citation` predicates on the context are your filter toolkit. ```typescript const OWNER = '628xxxxxxxxxx' client.on('text', async (msg) => { if (msg.isFromMe) return // ignore self if (msg.isGroup) return // private chats only if (msg.senderId.replace(/\D/g, '') !== OWNER) return // owner only await msg.react('👀') await msg.reply(`Echo: ${msg.text}`) }) ``` zaileys also internally drops spoofed self-only protocol messages (history-sync key shares, LID migration, peer-data responses, etc.) so they never reach your handlers — you only see real conversational events. For prefix-based command routing instead of manual branching, use `client.command(...)` — see [Commands](/commands) and [Automation](/automation). --- ## Tips & gotchas `timestamp` is in **milliseconds** (already multiplied from WhatsApp's seconds). Pass it straight into `new Date(timestamp)`. Media is **lazy** — nothing is downloaded until you call `msg.media.buffer()` or `msg.media.stream()`. Skip the call to skip the download. A single incoming message can fan out to multiple events (e.g. a group image that mentions you fires `image` and `mention`). Use `uniqueId` if you must dedupe across handlers. ## Related - [Client](/client) — constructor, options, `connect()` / `disconnect()` - [Interactive Messages](/interactive) — sending the buttons and lists that produce `button-click` / `list-select` - [Commands](/commands) — prefix-based routing on top of the `text` event - [Media](/media) — downloading and re-sending attachments - [Configuration](/configuration) — `ignoreMe`, `citation`, `qrTerminal`, `authType`, reconnect tuning - [Error Handling](/error-handling) — disconnect reasons and reconnection behavior --- # Sending Messages `client.send(recipient)` returns a chainable, lazy message builder. You add **exactly one** content method (`.text`, `.image`, `.poll`, …), optionally chain modifiers (`.reply`, `.mentions`, `.disappearing`, …), then `await` the builder to actually send. Every successful send resolves to the new message's `WAMessageKey`, which you can later pass to [`client.edit`/`client.delete`/`client.forward`](/client). ```typescript const client = new Client({ authType: 'qr' }) client.on('connect', async () => { const key = await client.send('6281234567890@s.whatsapp.net').text('Hello there') console.log('sent', key.id) }) ``` The builder is a *thenable*: nothing is sent until you `await` it (or call `.then`). Build the message fully, then await once. There is no separate `.send()` call at the end. ## The recipient: JID vs username `client.send(to)` accepts either a fully-qualified **JID** or a plain **phone number / username**. - A **JID** is used as-is. Recognised suffixes: `@s.whatsapp.net` (DM), `@g.us` (group), plus `@lid`, `@newsletter`, `@broadcast`, `@c.us`. - Anything **without** a JID suffix is treated as a username/number and is resolved to a JID via WhatsApp's `onWhatsApp` lookup **at await time** (the lookup is cached per client and de-duplicated for concurrent sends). If the number is not on WhatsApp, the send rejects with a `USERNAME_NOT_FOUND` error. ```typescript await client.send('6281234567890@s.whatsapp.net').text('explicit JID') await client.send('120363012345678901@g.us').text('to a group') await client.send('6281234567890').text('bare number — resolved automatically') ``` Username resolution happens lazily when the builder is awaited, so an invalid number surfaces as a rejected promise from the `await client.send(...)` call, not from `client.send(...)` itself. ## Media sources Every media method (`image`, `video`, `audio`, `document`, `sticker`, and album items) accepts a `MediaSource`, which is one of: | Source | Type | How it's loaded | | --- | --- | --- | | Remote URL | `string` starting with `http://` / `https://` | `fetch`ed (30s timeout) | | `URL` object | `URL` | `http(s):` is fetched; `file:` is read from disk | | Local path | `string` | read from the filesystem | | In-memory bytes | `Buffer` | used directly | ```typescript await client.send(to).image('https://example.com/photo.jpg') // remote URL await client.send(to).image('./assets/photo.jpg') // local path await client.send(to).image(new URL('file:///tmp/photo.jpg')) // URL object await client.send(to).image(myBuffer) // Buffer ``` The MIME type is auto-detected from the bytes. `video()` rejects with `INVALID_OPTIONS` if the detected MIME is not `video/*`; `audio()` and `sticker()` transcode the source automatically (see [Media Processing](/media)). Failed loads (bad URL, missing file, non-2xx response) reject with a `MEDIA_LOAD_FAILED` error. --- ## Text The simplest message. Pass a string. ```typescript await client.send(to).text('Hello, world!') ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `rich` | `boolean` | `false` | Render the string as an AIRich card with markdown formatting instead of a plain text message. See [Rich Responses](/rich-responses). | ```typescript await client.send(to).text('**bold** and _italic_ rendered as a card', { rich: true }) ``` `rich: true` switches to the AIRich renderer (markdown, LaTeX, headers). For ordinary chat text, omit it. Full options for rich text live in [Rich Responses](/rich-responses). ## Image ```typescript await client.send(to).image('./photo.jpg', { caption: 'A nice view' }) ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `caption` | `string` | — | Text shown beneath the image. | | `viewOnce` | `boolean` | `false` | Send as a view-once (disappears after the recipient opens it). | ## Video ```typescript await client.send(to).video('./clip.mp4', { caption: 'Watch this' }) ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `caption` | `string` | — | Text shown beneath the video. | | `gifPlayback` | `boolean` | `false` | Loop silently like a GIF (see below). | | `viewOnce` | `boolean` | `false` | Send as a view-once video. | The source must decode to a `video/*` MIME type, otherwise the send rejects with `INVALID_OPTIONS`. ## GIF WhatsApp has no real GIF message type — animated "GIFs" are videos played in a silent loop. Use `video()` with `gifPlayback: true`. The source should be an MP4 (convert real `.gif` files first; see [Media Processing](/media)). ```typescript await client.send(to).video('./animation.mp4', { gifPlayback: true, caption: 'lol' }) ``` ## Audio A regular audio file (music player UI). The source is transcoded to Opus automatically. ```typescript await client.send(to).audio('./song.mp3') ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `ptt` | `boolean` | `true` | Send as a voice note (push-to-talk) rather than a music file. | | `seconds` | `number` | auto | Reported duration. When omitted and `ptt` is on, it's derived from the computed waveform. | `ptt` defaults to **`true`** — a bare `.audio(src)` is sent as a **voice note**. Pass `{ ptt: false }` for a regular audio/music message. ## Voice note (PTT) A voice note is just `audio()` with `ptt: true` (the default). When sent as a voice note, zaileys computes a waveform and duration so the chat shows the speech-bubble player. ```typescript await client.send(to).audio('./voice.ogg', { ptt: true }) // explicit await client.send(to).audio('./voice.ogg') // ptt:true is the default ``` ## Document Send any file as a downloadable attachment. `fileName` is **required**. ```typescript await client.send(to).document('./report.pdf', { fileName: 'Q3-report.pdf' }) ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `fileName` | `string` | **required** | Display name (and extension) shown to the recipient. Must be non-empty. | | `mimetype` | `string` | auto-detected | Override the auto-detected MIME type. | | `caption` | `string` | — | Text shown beneath the document. | ```typescript await client.send(to).document(docBuffer, { fileName: 'notes.txt', mimetype: 'text/plain', caption: 'meeting notes', }) ``` Omitting `fileName` (or passing an empty string) rejects with `INVALID_OPTIONS`. ## Sticker The source (image or animated webp/gif/video) is converted to a WhatsApp sticker automatically. ```typescript await client.send(to).sticker('./logo.png') ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `animated` | `boolean` | `false` | Mark the sticker as animated. Use for animated/looping sources. | ## Location ```typescript await client.send(to).location(-6.2, 106.816666, { name: 'Jakarta', address: 'Indonesia' }) ``` The first two arguments are **latitude** then **longitude**. | Argument / Option | Type | Default | Description | | --- | --- | --- | --- | | `lat` (arg 1) | `number` | **required** | Latitude, must be within `-90..90`. | | `lon` (arg 2) | `number` | **required** | Longitude, must be within `-180..180`. | | `name` | `string` | — | Place name shown on the location card. | | `address` | `string` | — | Street address shown beneath the name. | Out-of-range coordinates reject with `INVALID_OPTIONS`. ## Contact (vCard) Send a contact card. Pass a raw vCard string — it must start with `BEGIN:VCARD`. ```typescript const vcard = [ 'BEGIN:VCARD', 'VERSION:3.0', 'FN:Zaileys Bot', 'TEL;type=CELL;type=VOICE;waid=6281234567890:+62 812-3456-7890', 'END:VCARD', ].join('\n') await client.send(to).contact(vcard) ``` `contact()` takes the vCard string directly (no options object). A string that does not begin with `BEGIN:VCARD` rejects with `INVALID_OPTIONS`. ## Poll ```typescript await client.send(to).poll('Pick a colour', ['Red', 'Green', 'Blue']) ``` Signature: `poll(question, options, opts?)`. | Argument / Option | Type | Default | Description | | --- | --- | --- | --- | | `question` (arg 1) | `string` | **required** | Poll title. Must be non-empty. | | `options` (arg 2) | `string[]` | **required** | 2–12 unique, non-empty choices. | | `multipleChoice` | `boolean` | `false` | Allow selecting more than one option. When `false`, voters pick exactly one. | ```typescript await client.send(to).poll('Toppings?', ['Cheese', 'Pepperoni', 'Mushroom'], { multipleChoice: true, }) ``` Fewer than 2 or more than 12 options, empty strings, or duplicate options each reject with `INVALID_OPTIONS` (an empty question rejects with `EMPTY_CONTENT`). ## Album Send multiple images and/or videos as a single grouped album. Internally zaileys sends one parent message followed by each child, and resolves to the **parent** `WAMessageKey`. ```typescript await client.send(to).album([ { type: 'image', src: './a.jpg', caption: 'first' }, { type: 'video', src: './b.mp4' }, { type: 'image', src: imageBuffer }, ]) ``` Each item is an `AlbumItem`: | Field | Type | Default | Description | | --- | --- | --- | --- | | `type` | `'image' \| 'video'` | **required** | Media kind for this item. | | `src` | `MediaSource` | **required** | URL, path, `URL`, or `Buffer`. | | `caption` | `string` | — | Per-item caption. | An album requires **2–30** items. Anything outside that range, or an item with a `type` other than `image`/`video`, rejects with `INVALID_OPTIONS`. --- ## Modifiers (chaining) Modifiers are orthogonal and can be chained on top of any content method, in any order, before the `await`. ```typescript await client .send(to) .text('Heads up, team!') .reply(quotedKeyOrMessage) // quote a message .mentions(['6281234567890@s.whatsapp.net']) // tag specific JIDs .disappearing(86400) // ephemeral, in seconds ``` ### `.reply(quoted)` Quote an earlier message. Accepts either a full `WAMessage` (best — shows the quoted body) or a bare `WAMessageKey`. Passing `null`/`undefined` rejects with `INVALID_OPTIONS`. ```typescript const key = await client.send(to).text('Original') await client.send(to).text('A reply').reply(key) // inside a handler, you typically already have the full message: client.on('message', async (msg) => { await client.send(msg.chat.id).text('got it').reply(msg) }) ``` ### `.mentions(jids)` Tag specific participants. Each entry must be a JID-shaped string containing `@`. Repeated calls merge and de-duplicate; an empty array rejects with `INVALID_OPTIONS`. ```typescript await client .send(groupJid) .text('@alice @bob standup in 5') .mentions(['6281111111111@s.whatsapp.net', '6282222222222@s.whatsapp.net']) ``` `.mentions()` only sets the *mention metadata*. To make the names render as tappable links in the bubble, also include the matching `@number` text in your message body. ### `.mentionAll()` Tag every member of a group (no arguments). ```typescript await client.send(groupJid).text('Important: read this').mentionAll() ``` ### `.disappearing(seconds)` Send as an ephemeral/disappearing message. Takes a **positive integer** number of seconds (e.g. `86400` = 24h, `604800` = 7 days). Non-positive or non-integer values reject with `INVALID_OPTIONS`. ```typescript await client.send(to).text('self-destructs in a day').disappearing(86400) ``` ### `.to(recipient)` Reassign the recipient on an `init`-state builder before adding content (rarely needed since `client.send(to)` already sets it). ```typescript await client.send('placeholder').to('6281234567890@s.whatsapp.net').text('redirected') ``` --- ## The return value Every successful send resolves to a `WAMessageKey`: ```typescript const key: WAMessageKey = await client.send(to).text('keep this key') // key.id, key.remoteJid, key.fromMe … await client.edit(key).text('edited text') await client.delete(key, { forEveryone: true }) await client.forward(key, anotherJid) ``` For albums, the returned key is the **parent** message's key. See [Client](/client) for `edit`, `delete`, `forward`, and `react`. ## Error handling The builder throws typed `ZaileysBuilderError`s. Common codes: | Code | When | | --- | --- | | `EMPTY_CONTENT` | Awaited a builder with no content method set (or an empty poll question). | | `INVALID_OPTIONS` | Bad arguments — missing `fileName`, out-of-range coords, bad poll/album size, empty mentions, etc. | | `MEDIA_LOAD_FAILED` | A media source failed to load or transcode. | | `USERNAME_NOT_FOUND` | A bare number/username could not be resolved to a WhatsApp account. | | `SEND_FAILED` | The underlying socket rejected the send or returned no key. | ```typescript try { await client.send(to).document(buf, { fileName: 'x.pdf' }) } catch (err) { console.error('send failed:', err) } ``` See [Error Handling](/error-handling) for the full taxonomy. ## What's next This page covers the plain content types. For richer experiences: - [Interactive Messages](/interactive) — `.buttons()`, `.list()`, `.carousel()`, `.template()`. - [Rich Responses](/rich-responses) — `.text(..., { rich: true })` AIRich cards (markdown, LaTeX). - [Media Processing](/media) — converting, resizing, and inspecting media before sending. - [Client](/client) — `edit`, `delete`, `forward`, `react`, and broadcast helpers. --- # Media Processing Zaileys ships a standalone `Media` class for converting and transforming audio, video, images, stickers, and documents before you send them. It wraps `ffmpeg` plus an image backend (`sharp` if available, otherwise `jimp`) behind a small, namespaced facade, and every method returns a plain `Buffer` (or base64 string / metadata object) that feeds straight into the [send builder](/sending-messages). ## Constructing a `Media` Import `Media` from `zaileys` and pass it your source once — every namespace below operates on that same input. ```typescript const media = new Media(input) ``` The constructor accepts a single `MediaInput`, which is one of: | Input type | Example | Notes | | ------------- | -------------------------------------- | ------------------------------------------------------------ | | `string` URL | `'https://example.com/song.mp3'` | Fetched over HTTP(S) with the global `fetch`. | | `string` path | `'./assets/clip.mp4'` | Read from disk if the path points to an existing file. | | `string` b64 | `'iVBORw0KGgo...'` | Falls back to base64 decoding when it is neither URL nor file. | | `Buffer` | `await fs.readFile('./img.png')` | Used as-is. | | `ArrayBuffer` | `await res.arrayBuffer()` | Wrapped into a `Buffer`. | ```typescript const fromUrl = new Media('https://example.com/voice.mp3') const fromPath = new Media('./assets/photo.jpg') const fromBuffer = new Media(await fs.readFile('./assets/clip.mp4')) ``` Resolution is lazy. Nothing is downloaded or read until you call a processing method, so constructing a `Media` is cheap. The same instance can be reused across namespaces — for example call both `media.thumbnail.get()` and `media.image.toJpeg()` on one `Media`. ## Requirements: ffmpeg, sharp, and jimp **ffmpeg / ffprobe** power all audio, video, and animated-sticker work. The published package bundles `ffmpeg` and `ffprobe` binaries (via `@ffmpeg-installer/ffmpeg` and `@ffprobe-installer/ffprobe`), so most platforms need no system install — they are added to `PATH` automatically on first use. On platforms without a prebuilt binary (e.g. Termux), install a system ffmpeg (`pkg install ffmpeg`). See [Installation](/installation) for details. **Image backend** — `image.toJpeg`, `image.thumbnail`, `image.resize`, and `sticker.create` (for non-animated stickers) need an image processor. `sharp` is used when installed (fast, native). If `sharp` is absent, Zaileys transparently falls back to the pure-JS `jimp` path and prints a one-line warning suggesting `npm install sharp`. Everything works either way — `sharp` is purely an accelerator. ```bash npm i sharp ``` ```bash pnpm add sharp ``` ```bash yarn add sharp ``` ```bash bun add sharp ``` ## `media.audio` Convert any audio (or the audio track of a video) into WhatsApp-friendly formats. Each method first validates that the source is `audio/*`. | Method | Returns | Description | | ----------------- | --------------------------------------------- | ------------------------------------------------------------------- | | `toOpus()` | `Promise` | Opus in an Ogg container — the format WhatsApp voice notes use. Mono, 48 kHz, 48 kbps. | | `toMp3()` | `Promise` | MP3 (libmp3lame). Stereo, 128 kbps. | | `convert(type?)` | `Promise` | Convert to `'opus'` (default) or `'mp3'`. `toOpus`/`toMp3` are thin wrappers around this. | | `waveform()` | `Promise<{ waveform: Uint8Array; seconds: number }>` | A 64-sample amplitude envelope (values 0–100) plus duration in seconds, for voice-note waveform display. | ```typescript const client = new Client({ /* ... */ }) const media = new Media('./assets/recording.m4a') const opus = await media.audio.toOpus() await client.send('6281234567890@s.whatsapp.net').audio(opus, { ptt: true }) const mp3 = await media.audio.toMp3() // or, equivalently: await media.audio.convert('mp3') ``` ```typescript const { waveform, seconds } = await new Media('./assets/voice.ogg').audio.waveform() console.log(seconds) // e.g. 7 console.log(waveform.length) // 64 ``` ## `media.video` Re-encode video for reliable WhatsApp playback or grab a poster frame. Both methods validate that the source is `video/*`. | Method | Returns | Description | | ------------- | ------------------- | --------------------------------------------------------------------------- | | `toMp4()` | `Promise` | H.264/AAC MP4 with `+faststart`, `yuv420p`, even dimensions, `crf 28`, `ultrafast` preset. | | `thumbnail()` | `Promise` | A 100×100 JPEG poster frame, taken ~10% into the clip, returned base64-encoded. | ```typescript const client = new Client({ /* ... */ }) const mp4 = await new Media('https://example.com/clip.mov').video.toMp4() await client.send('6281234567890@s.whatsapp.net').video(mp4, { caption: 'Re-encoded' }) ``` ```typescript const poster = await new Media('./assets/movie.mp4').video.thumbnail() // `poster` is a base64 JPEG string (no data: prefix) ``` ## `media.image` | Method | Returns | Description | | ----------------------- | ------------------- | --------------------------------------------------------------------------------------------- | | `toJpeg()` | `Promise` | Convert PNG/WebP to JPEG. Other formats are returned unchanged. | | `thumbnail()` | `Promise` | A 100×100 (cover-fit) JPEG, base64-encoded, quality 50. Animated GIFs use the first frame. | | `resize(width, height)` | `Promise` | Cover-fit resize to the given pixel dimensions. Output is PNG. | ```typescript const client = new Client({ /* ... */ }) const media = new Media('./assets/banner.png') const jpeg = await media.image.toJpeg() await client.send('6281234567890@s.whatsapp.net').image(jpeg, { caption: 'Compressed' }) const small = await media.image.resize(640, 640) const thumb = await media.image.thumbnail() // base64 JPEG string ``` ## `media.sticker` Turn an image, GIF, or video into a WhatsApp WebP sticker. Animated sources (GIF/video) produce animated stickers (capped at 6 s, 10 fps, 512×512); static images become static stickers. Already-WebP inputs are passed through. EXIF pack metadata is embedded automatically. ```typescript create(metadata?: StickerMetadataType): Promise ``` `StickerMetadataType` options (all optional): | Option | Type | Default | Description | | ------------- | ------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------- | | `packageName` | `string` | `'Zaileys Library'` | Sticker pack name stored in EXIF. | | `authorName` | `string` | `'https://github.com/zeative/zaileys'` | Sticker pack publisher stored in EXIF. | | `quality` | `number` | `60` | WebP quality, clamped to 1–100. | | `shape` | `'circle' \| 'rounded' \| 'oval' \| 'default'` | `'default'` | Crops static stickers to a shape mask. (Static only.) | ```typescript const client = new Client({ /* ... */ }) const sticker = await new Media('./assets/cat.png').sticker.create({ packageName: 'My Pack', authorName: 'Me', quality: 80, shape: 'rounded', }) await client.send('6281234567890@s.whatsapp.net').sticker(sticker) ``` ```typescript // Animated sticker from a GIF or short video const animated = await new Media('./assets/loop.gif').sticker.create({ quality: 70 }) await client.send('6281234567890@s.whatsapp.net').sticker(animated) ``` `shape` only affects static stickers. Animated stickers are always padded to a square. Both static and animated paths require their image backend / ffmpeg — see the requirements callout above. ## `media.document` Wrap any file as a sendable document, auto-detecting its MIME type, extension, and a thumbnail (video poster frame or image thumbnail, empty for non-media files). ```typescript create(): Promise<{ document: Buffer mimetype: string ext: string fileName: string jpegThumbnail: string }> ``` | Field | Type | Description | | --------------- | -------- | -------------------------------------------------------------------- | | `document` | `Buffer` | The raw file bytes. | | `mimetype` | `string` | Detected MIME type (e.g. `application/pdf`). | | `ext` | `string` | Detected file extension (e.g. `pdf`). | | `fileName` | `string` | An auto-generated unique id (override it when sending). | | `jpegThumbnail` | `string` | Base64 JPEG thumbnail for media files; empty string otherwise. | ```typescript const client = new Client({ /* ... */ }) const doc = await new Media('./assets/report.pdf').document.create() await client.send('6281234567890@s.whatsapp.net').document(doc.document, { fileName: 'report.pdf', mimetype: doc.mimetype, }) ``` ## `media.thumbnail` A convenience that detects the source type and produces a 100×100 base64 JPEG thumbnail — routing to the video poster-frame path for `video/*` and the image path for `image/*`. ```typescript get(): Promise ``` ```typescript // Works for both images and videos const thumb = await new Media('./assets/whatever.mp4').thumbnail.get() ``` `thumbnail.get()` throws `Invalid media type: expected image or video` when the source is not an image or video (e.g. a PDF), or when the file type cannot be detected. Wrap it in a `try/catch` if the input is untrusted. ## `media.toBuffer()` Resolve the original source to a raw `Buffer` without any processing — handy for downloading a URL or reading a file once and reusing the bytes. ```typescript const buffer = await new Media('https://example.com/file.bin').toBuffer() ``` ## Feeding results into the send builder Every processing method returns a `Buffer` (or a metadata object whose `document` field is a `Buffer`), and the [send builder](/sending-messages) accepts a `Buffer` anywhere a `MediaSource` is expected. So the pattern is always: process → pass the buffer to the matching content method. ```typescript const client = new Client({ /* ... */ }) const jid = '6281234567890@s.whatsapp.net' // Image → sticker const sticker = await new Media('./assets/photo.jpg').sticker.create() await client.send(jid).sticker(sticker) // Any audio → voice note const opus = await new Media('./assets/song.mp3').audio.toOpus() await client.send(jid).audio(opus, { ptt: true }) // Re-encode then send video const mp4 = await new Media('./assets/clip.mov').video.toMp4() await client.send(jid).video(mp4) ``` You only need `Media` when you want to **transform** bytes (convert formats, build stickers, generate thumbnails). To send a file as-is, hand the URL, path, or `Buffer` directly to the send builder — it handles plain media uploads on its own. ## Tips and gotchas - **Type validation throws.** `audio.*` requires `audio/*`, `video.*` requires `video/*`. Passing a mismatched file rejects with an `Invalid file type` error. - **Thumbnails are base64 strings**, not buffers. `video.thumbnail`, `image.thumbnail`, and `thumbnail.get` all return base64-encoded JPEG text (no `data:` prefix). - **Sticker `quality` is clamped** to 1–100; out-of-range values are coerced. - **Reuse the instance.** Building one `Media` and calling several namespaces avoids re-reading the source repeatedly when you process the same file in multiple ways. See also: [Sending Messages](/sending-messages) and [Installation](/installation). --- # Interactive Messages Interactive messages are the rich, tappable UI of WhatsApp: reply buttons, link/copy/call call-to-action buttons, single-select lists, product carousels, reminder and location request buttons, and more. Zaileys renders them natively — including on **personal** (non-business) accounts — because it relays each interactive message through WhatsApp's `native_flow` node for you. There is no separate "business API" requirement. Every interactive message is sent through the same fluent builder you use for [Sending Messages](/sending-messages): `client.send(jid).(...)`. Taps and selections come back as the [`button-click`](/events) and [`list-select`](/events) events. All builder methods are chainable and awaited. `client.send(jid)` returns a builder; calling a content method (`.buttons()`, `.list()`, `.carousel()`, `.template()`) sets the content, and awaiting the builder sends it and resolves to the sent message's `WAMessageKey`. ## At a glance | Method | Signature | Sends | | --- | --- | --- | | `.buttons()` | `buttons(buttons, opts?)` | Reply + CTA buttons (up to 10) with optional header/footer/overflow | | `.template()` | `template(opts)` | Header/body/footer shortcut + up to 3 reply buttons | | `.list()` | `list(opts)` | A `single_select` list grouped into sections (up to 10 rows total) | | `.carousel()` | `carousel(cards, opts?)` | Horizontal product cards (up to 10), each with its own media + buttons | --- ## Buttons `buttons(buttons, opts?)` accepts an array of buttons (1–10) of mixed types, plus an options object for the header, footer, and overflow behavior. Reply taps come back on the [`button-click`](/events) event; CTA buttons (url/copy/call) are handled by the WhatsApp client itself and do not emit an event. ### Reply buttons (simplest) A reply button is the default. It needs a unique `id` and a `text` label. When tapped, it fires `button-click` with that `id`. ```typescript const client = new Client() const jid = '628xxxxxxxxxx@s.whatsapp.net' await client.send(jid).buttons( [ { id: 'yes', text: 'Yes' }, { id: 'no', text: 'No' }, ], { text: 'Pick one', footer: 'tap a button' }, ) client.on('button-click', (ctx) => { console.log(ctx.buttonId, ctx.buttonText) // "yes" "Yes" }) ``` The full reply-button type is `{ type?: 'reply'; id: string; text: string }`. The `type` is optional because `reply` is the default — `{ id, text }` and `{ type: 'reply', id, text }` are equivalent. Reply button ids must be unique within a single message and non-empty — a duplicate or empty id throws `INVALID_OPTIONS`. ### CTA buttons (url / copy / call) CTA (call-to-action) buttons perform a client-side action when tapped. They have **no `id`** (you do not receive an event for them) and are distinguished by their `type`. Field names matter — verify against the table below. ```typescript await client.send(jid).buttons( [ { type: 'url', text: 'Open GitHub', url: 'https://github.com/zeative/zaileys', webview: true }, { type: 'copy', text: 'Copy code', code: 'ZAILEYS-2026' }, { type: 'call', text: 'Call us', phone: '6287833764462' }, ], { text: 'CTA buttons: link / copy / call', footer: 'tap any' }, ) ``` | Type | Shape | Action | | --- | --- | --- | | `url` | `{ type: 'url'; text: string; url: string; webview?: boolean }` | Opens `url`. `webview: true` opens it inside an in-app webview instead of the external browser (default `false`). | | `copy` | `{ type: 'copy'; text: string; code: string }` | Copies `code` to the device clipboard. | | `call` | `{ type: 'call'; text: string; phone: string }` | Dials `phone` (use full international format, digits only, e.g. `6287833764462`). | CTA fields are required: a `url` button without `url`, a `copy` without `code`, or a `call` without `phone` throws `INVALID_OPTIONS`. The `text` label is required on every button type. ### Mixing reply and CTA buttons You can freely combine reply and CTA buttons in a single message — only the reply taps emit `button-click`. ```typescript await client.send(jid).buttons( [ { id: 'yes', text: 'Yes' }, { type: 'url', text: 'Docs', url: 'https://github.com/zeative/zaileys' }, { type: 'copy', text: 'Copy ID', code: 'ABC123' }, ], { title: 'Mixed buttons', text: 'reply + url + copy in one message' }, ) ``` ### Reminder buttons `reminder` sets a WhatsApp reminder for the message; `cancel-reminder` cancels one. Both take a `text` label and an optional `id` — when `id` is omitted, the button `text` is used as the id. ```typescript await client.send(jid).buttons( [ { type: 'reminder', text: 'Remind me', id: 'remind_1' }, { type: 'cancel-reminder', text: 'Cancel' }, ], { title: '⏰ Reminder', text: 'Set or cancel a WhatsApp reminder' }, ) ``` | Type | Shape | | --- | --- | | `reminder` | `{ type: 'reminder'; text: string; id?: string }` | | `cancel-reminder` | `{ type: 'cancel-reminder'; text: string; id?: string }` | ### Location & address request buttons `location` asks the user to share their current location; `address` opens the address form (useful for checkout / delivery flows). For `location` the `text` label is optional (a default is used when omitted); for `address` the `text` is required and `id` is optional. ```typescript await client.send(jid).buttons( [ { type: 'location', text: 'Share location' }, { type: 'address', text: 'Add address', id: 'addr_1' }, ], { title: '📍 Checkout', text: 'Share your location or delivery address' }, ) ``` | Type | Shape | | --- | --- | | `location` | `{ type: 'location'; text?: string }` | | `address` | `{ type: 'address'; text: string; id?: string }` | ### Header, footer & subtitle options The second argument to `buttons()` (`ButtonsContentOptions`) controls the body text, footer, and header. A text header appears when you supply `title` and/or `subtitle`; a media header appears when you supply `image` or `video`. ```typescript await client.send(jid).buttons( [ { id: 'go', text: 'Go' }, { id: 'skip', text: 'Skip' }, ], { title: '💡 Tip & Suggest', subtitle: 'header subtitle line', text: 'body text under the header', footer: 'footer', }, ) ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `text` | `string` | `' '` (single space) | Body text shown above the buttons. | | `footer` | `string` | none | Small footer text below the body. | | `title` | `string` | none | Header title line (renders a text header). | | `subtitle` | `string` | none | Header subtitle line (renders a text header). | | `image` | `string \| Buffer \| URL` | none | Image media header (file path, URL, or Buffer). | | `video` | `string \| Buffer \| URL` | none | Video media header. | | `bottomSheet` | `BottomSheetOptions` | none | Collapse overflow buttons into a bottom sheet — see below. | | `limitedTimeOffer` | `LimitedTimeOfferOptions` | none | Render a countdown / limited-time-offer banner — see below. | ### Media header Pass `image` or `video` to attach a media header. The source can be a local path, a URL, or a `Buffer` (the same `MediaSource` accepted everywhere — see [Media](/media)). Zaileys uploads the media to WhatsApp and attaches it to the header automatically. ```typescript await client.send(jid).buttons( [ { id: 'ok', text: 'OK' }, { id: 'no', text: 'No' }, ], { image: readFileSync('./header.png'), // or './header.png' or a URL title: '🖼️ Image header', text: 'interactive message with an image header', footer: 'zaileys', }, ) ``` Provide either `image` **or** `video`, not both — if both are present, `image` wins. Media headers require the socket to support relaying with upload; this is wired up automatically when you send through `client.send()`. ### Overflow: `bottomSheet` When you have many buttons, `bottomSheet` collapses the overflow into a tap-to-open sheet so the chat stays tidy. Only the first `buttonsLimit` buttons show inline; the rest move into the sheet. ```typescript await client.send(jid).buttons( [ { id: 's1', text: 'Option 1' }, { id: 's2', text: 'Option 2' }, { id: 's3', text: 'Option 3' }, { id: 's4', text: 'Option 4' }, { id: 's5', text: 'Option 5' }, ], { text: 'Many options — grouped into a bottom sheet', bottomSheet: { listTitle: 'All options', buttonTitle: 'View 5 options', buttonsLimit: 2 }, }, ) ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `listTitle` | `string` | none | Title shown at the top of the opened sheet. | | `buttonTitle` | `string` | none | Label of the inline button that opens the sheet. | | `buttonsLimit` | `number` | none | Number of buttons to keep inline before collapsing the rest into the sheet. | | `dividers` | `number[]` | none | Indices at which to draw dividers between grouped buttons. | ### `limitedTimeOffer` (countdown banner) Renders a limited-time-offer banner with a countdown above the buttons — ideal for flash sales paired with a `url` or `copy` CTA. ```typescript await client.send(jid).buttons( [ { type: 'url', text: 'Grab the deal', url: 'https://github.com/zeative/zaileys' }, { type: 'copy', text: 'Copy code', code: 'FLASH50' }, ], { title: '⚡ Flash Sale', text: '50% off — ending soon!', limitedTimeOffer: { text: 'Offer ends in', copyCode: 'FLASH50', expiresAt: Math.floor(Date.now() / 1000) + 3600, // unix seconds }, }, ) ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `text` | `string` | none | Banner label (e.g. "Offer ends in"). | | `url` | `string` | none | URL associated with the offer. | | `copyCode` | `string` | none | Promo code surfaced in the banner. | | `expiresAt` | `number` | none | Expiry as a **Unix timestamp in seconds** (the countdown ends here). | `expiresAt` is in **seconds**, not milliseconds — use `Math.floor(Date.now() / 1000) + secondsFromNow`. Passing `Date.now()` directly (milliseconds) will produce a wildly future expiry. A message accepts at most **10 buttons** (`buttons()` throws `INVALID_OPTIONS` beyond that) and requires at least one. An empty button array also throws. --- ## Template `template(opts)` is a streamlined header / body / footer shortcut for reply buttons. It is built on top of `buttons()` — the `header` is bolded and prepended to the body, and buttons are limited to **3**. Use it when you just want a titled message with a couple of quick replies. ```typescript await client.send(jid).template({ header: 'Zaileys', body: 'Template message body', footer: 'template footer', buttons: [ { id: 't1', text: 'Action 1' }, { id: 't2', text: 'Action 2' }, ], }) ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `header` | `string` | none | Bolded line prepended to the body. | | `body` | `string` | **required** | Main message text. Must be non-empty. | | `footer` | `string` | none | Footer text. | | `buttons` | `ButtonDef[]` | **required** | 1–3 reply buttons, each `{ id, text }`. | `template()` buttons are plain reply buttons (`{ id, text }`) and emit `button-click` on tap. For CTA/url/copy/call buttons, use `buttons()` instead. A missing/empty `body`, an empty `buttons` array, or more than 3 buttons throws `INVALID_OPTIONS`. --- ## List (single_select) `list(opts)` builds a `single_select` list: a button that opens a sheet of rows grouped into sections. The user picks exactly one row, which comes back on the [`list-select`](/events) event with its `id`. ```typescript await client.send(jid).list({ title: '🍔 Menu', description: 'Pick your order', buttonText: 'View menu', footerText: 'zaileys', sections: [ { title: 'Food', rows: [ { id: 'pizza', title: 'Pizza', description: '$6' }, { id: 'ramen', title: 'Ramen', description: '$5' }, ], }, { title: 'Drinks', rows: [ { id: 'coffee', title: 'Coffee', description: '$2' }, { id: 'tea', title: 'Tea', description: '$1' }, ], }, ], }) client.on('list-select', (ctx) => { console.log('selected:', ctx.rowId, ctx.title) // "pizza" "View menu" }) ``` ### `ListOptions` | Option | Type | Default | Description | | --- | --- | --- | --- | | `buttonText` | `string` | **required** | Label of the button that opens the list. Must be non-empty. | | `title` | `string` | none | Header title shown above the body. | | `description` | `string` | `' '` | Body text shown above the open-list button. | | `footerText` | `string` | none | Footer text. | | `sections` | `ListSection[]` | **required** | One or more sections — see below. | ### `ListSection` and rows ```typescript type ListSection = { title: string rows: Array<{ id: string; title: string; description?: string }> } ``` | Field | Type | Default | Description | | --- | --- | --- | --- | | `section.title` | `string` | — | Section heading inside the list. | | `row.id` | `string` | **required** | Unique id returned as `ctx.rowId` on selection. Must be non-empty and unique across all sections. | | `row.title` | `string` | **required** | Row label. Must be non-empty. | | `row.description` | `string` | none | Secondary line under the row title. | A list accepts at most **10 rows total** across all sections, requires at least one section, and each section requires at least one row. Row ids must be unique across the whole list — a duplicate id throws `INVALID_OPTIONS`. --- ## Carousel `carousel(cards, opts?)` sends a horizontally swipeable set of product cards (1–10). Each card has its own optional media header, title/subtitle, body, footer, and buttons. Card buttons behave exactly like `buttons()` — reply buttons emit `button-click`, CTA buttons act client-side. ```typescript const pizza = readFileSync('./pizza.png') const ramen = readFileSync('./ramen.png') await client.send(jid).carousel( [ { title: 'Pizza Mozzarella', body: '$6', footer: 'zaileys', image: pizza, buttons: [ { id: 'buy_pizza', text: 'Order' }, { type: 'url', text: 'Detail', url: 'https://github.com/zeative/zaileys' }, ], }, { title: 'Ramen Kaldu', body: '$5', footer: 'zaileys', image: ramen, buttons: [ { id: 'buy_ramen', text: 'Order' }, { type: 'copy', text: 'Promo', code: 'RAMEN5' }, ], }, ], { text: '🛍️ Product Carousel' }, ) ``` ### `CarouselCard` | Field | Type | Default | Description | | --- | --- | --- | --- | | `title` | `string` | none | Card header title. | | `subtitle` | `string` | none | Card header subtitle. | | `body` | `string` | `' '` | Card body text. | | `footer` | `string` | none | Card footer text. | | `image` | `string \| Buffer \| URL` | none | Image media header for this card. | | `video` | `string \| Buffer \| URL` | none | Video media header for this card. | | `buttons` | `Array` | none | Buttons for this card — same shapes as `buttons()`. | The `opts` argument only carries the carousel-level intro text: | Option | Type | Default | Description | | --- | --- | --- | --- | | `text` | `string` | `' '` | Text shown above the card strip. | A carousel accepts at most **10 cards** and requires at least one. Each card's buttons are validated like a normal `buttons()` call (reply ids unique per card, CTA fields required, max 10 buttons per card). --- ## Handling responses Interactive taps and selections arrive as events on the client. See [Events](/events) for the full event list and registration patterns. Fired when a user taps a **reply** button (from `.buttons()`, `.template()`, or a carousel card). CTA buttons (url/copy/call) do **not** fire this event. ```typescript client.on('button-click', (ctx) => { console.log(ctx.buttonId) // the reply button's `id` console.log(ctx.buttonText) // the button label (may be undefined) console.log(ctx.sender.jid) // who tapped it }) ``` `ButtonClickPayload`: | Field | Type | Description | | --- | --- | --- | | `key` | `WAMessageKey` | Key of the message containing the tapped button. | | `buttonId` | `string` | The reply button's `id`. | | `buttonText` | `string` (optional) | The button label, when present. | | `sender` | `SenderInfo` | The tapper (`jid`, `pushName`, `username`, `lid`, `pn`, `isMe`). | | `timestamp` | `number` | Unix timestamp of the tap. | Fired when a user picks a row from a `.list()` message. ```typescript client.on('list-select', (ctx) => { console.log(ctx.rowId) // the selected row's `id` console.log(ctx.title) // the list's button title (may be undefined) console.log(ctx.sender.jid) // who selected it }) ``` `ListSelectPayload`: | Field | Type | Description | | --- | --- | --- | | `key` | `WAMessageKey` | Key of the message containing the list. | | `rowId` | `string` | The selected row's `id` (the `id` you set on the row). | | `title` | `string` (optional) | The list title, when present. | | `sender` | `SenderInfo` | The selector (`jid`, `pushName`, `username`, etc.). | | `timestamp` | `number` | Unix timestamp of the selection. | ### Routing taps to handlers Because every reply button and list row carries the `id` you defined, a single handler can route on it: ```typescript const actions: Record Promise> = { yes: () => client.send(jid).text('You said yes!'), no: () => client.send(jid).text('Maybe next time.'), } client.on('button-click', async (ctx) => { await actions[ctx.buttonId]?.() }) client.on('list-select', async (ctx) => { await client.send(ctx.sender.jid).text(`You picked: ${ctx.rowId}`) }) ``` Use stable, meaningful ids (`'order_pizza'`, `'remind_1'`) so your handlers stay readable. Ids are the contract between the message you send and the event you receive. --- ## Putting it together ### Connect the client ```typescript const client = new Client() client.on('qr', ({ qrString }) => console.log('Scan QR:', qrString)) ``` ### Send an interactive message on connect ```typescript const TO = '628xxxxxxxxxx@s.whatsapp.net' client.on('connect', async () => { await client.send(TO).buttons( [ { id: 'yes', text: 'Yes' }, { id: 'no', text: 'No' }, { type: 'url', text: 'Docs', url: 'https://github.com/zeative/zaileys' }, ], { title: 'Zaileys', text: 'Ready to go?', footer: 'tap a button' }, ) }) ``` ### Handle the response ```typescript client.on('button-click', (ctx) => { console.log(`button-click → ${ctx.buttonId} from ${ctx.sender.jid}`) }) ``` ## Gotchas - **Limits throw, they don't truncate.** More than 10 buttons, more than 10 list rows, more than 10 carousel cards, or more than 3 template buttons throws `INVALID_OPTIONS` at build time — catch it or stay within bounds. - **Empty labels throw.** Every button needs a non-empty `text` (except `location`, where `text` is optional). Reply buttons additionally need a non-empty, unique `id`. - **CTA buttons emit no event.** Only reply buttons (`button-click`) and list rows (`list-select`) come back to your code; url/copy/call/reminder/location/address are handled by the WhatsApp client. - **`limitedTimeOffer.expiresAt` is in seconds.** Don't pass `Date.now()` (milliseconds) directly. ## See also - [Events](/events) — the full event catalog, including `button-click` and `list-select`. - [Sending Messages](/sending-messages) — the fluent `client.send(jid)` builder, replies, mentions, and disappearing messages. - [Media](/media) — accepted media sources (path, URL, Buffer) for header `image`/`video` and carousel cards. - [Rich Responses](/rich-responses) — AI-style rich text and markdown rendering. --- # Rich Responses (AIRich) Write ordinary **markdown**, pass `{ rich: true }` to `.text()`, and Zaileys renders it as a Meta-AI-style rich card on WhatsApp — syntax-highlighted code, tables, image galleries, inline links and citations, LaTeX formulas, plus `:::` directive blocks for products, reels, posts, suggestions and more. There is **no separate `aiRich()` method**. Rich rendering is just a flag on the regular text builder you already know from [Sending Messages](/sending-messages): ```typescript const client = new Client() await client.send('628xxx@s.whatsapp.net').text('**Hello** from *zaileys*', { rich: true }) ``` The same `content` string is parsed by a small markdown engine (`parseRichMarkdown`) into a list of typed parts (text, code, table, image, video, product, reels, post, tip, suggest), then encoded into the WhatsApp rich-response payload. AIRich uses a reverse-engineered WhatsApp rich-response format. It is **experimental** and may break when WhatsApp changes its payload. Plain `.text()` (without `rich: true`) is completely unaffected and always safe. ## The `.text()` signature ```typescript text(content: string, opts?: TextOptions): MessageBuilder type TextOptions = { rich?: boolean title?: string footer?: string sources?: Array<[profileUrl: string, url: string, text: string]> } ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `rich` | `boolean` | `false` | When `true`, parse `content` as rich markdown. When omitted/`false`, send `content` as a normal text message. | | `title` | `string` | `''` | Disclaimer / header label shown above the rich card (e.g. a bot name). | | `footer` | `string` | `''` | Footer text appended as the last block of the card. | | `sources` | `Array<[profileUrl, url, text]>` | `[]` | Citation source chips. Each tuple is `[faviconUrl, linkUrl, displayName]`. | When `rich` is `false` or omitted, `title`, `footer` and `sources` are ignored — they only apply to the rich renderer. ### Full example ```typescript await client.send('628xxx@s.whatsapp.net').text( [ '*Daily brief* ☕', '', 'Repo: [GitHub](https://github.com/zeative/zaileys)', 'A citation: [](https://github.com/zeative/zaileys)', '', '```ts', "const client = new Client()", '```', '', '| Feature | Status |', '|---|---|', '| Buttons | Ready |', '| AIRich | Experimental |', ].join('\n'), { rich: true, title: '📰 zaileys Daily', footer: '💡 Dibuat dengan zaileys — github.com/zeative/zaileys', sources: [ ['https://avatars.githubusercontent.com/u/9919?s=64', 'https://github.com/zeative/zaileys', 'zaileys on GitHub'], ], }, ) ``` ## Supported markdown These are detected automatically — no directive needed. The parser walks the string line by line, so most block features must start on **their own line**. | Markdown | Renders as | | --- | --- | | `*bold*`, `_italic_`, plain paragraphs | a text block | | `[label](url)` | inline hyperlink | | `[](url)` (empty label) | numbered citation | | `[expr\|w\|h]` | LaTeX formula (see below) | | ` ```lang … ``` ` | syntax-highlighted code block | | `\| a \| b \|` followed by `\|---\|` | table | | `![alt](url)` on its own line | image (consecutive image lines → gallery) | ### Text, bold, italic & links Consecutive non-block lines are joined into a single text block. Inline markup is extracted from the text, so links and citations render as tappable chips. ```typescript await client.send(jid).text( [ '*Tech Brief — Monday* ☕', '', 'Daily roundup from [zaileys](https://github.com/zeative/zaileys).', 'Data monitored automatically. [](https://github.com/zeative/zaileys)', ].join('\n'), { rich: true, title: '📰 zaileys Daily' }, ) ``` - `[zaileys](https://…)` → a hyperlink chip labelled `zaileys`. - `[](https://…)` → a citation (empty label). Citations are auto-numbered in order of appearance. Escape a literal `[` with a backslash (`\[`) if you do not want it treated as the start of a link/citation/LaTeX entity. ### LaTeX formulas WhatsApp cannot render raw LaTeX, so AIRich uses a **pre-rendered formula image**. The syntax is a link-like entity using angle brackets `<…>` instead of parentheses: ```text [expression|width|height|fontHeight|padding] ``` Only `expression` and `imageUrl` are required; the trailing fields are optional sizing hints. | Field | Default | Notes | | --- | --- | --- | | `expression` | `image` | The LaTeX source text (shown as alt/label). | | `width` | `100` | Rendered image width. | | `height` | `100` | Rendered image height. | | `fontHeight` | `83.33` | Font height hint. | | `padding` | `15` | Padding hint. | ```typescript await client.send(jid).text( "Today's formula: [E = mc^2|160|44]", { rich: true, title: '🧮 zaileys' }, ) ``` The `` must point to an already-rendered formula image (e.g. a CodeCogs PNG). Zaileys does not render LaTeX itself — it only references the image you supply. ### Code blocks Fenced code blocks become syntax-highlighted cards. Provide a language after the opening fence; if omitted, `plaintext` is used. JavaScript/TypeScript (`js`, `ts`, `javascript`, `typescript`) get keyword, string, number, comment and method-call highlighting. ````typescript await client.send(jid).text( [ '```typescript', "import { Client } from 'zaileys'", '', 'const client = new Client()', '', "client.on('message', async (msg) => {", " await client.send(msg.senderId).text('hi')", '})', '```', ].join('\n'), { rich: true }, ) ```` ### Tables A table is a line containing `|` immediately followed by a separator row (`|---|`, dashes/colons). The first row is the header; remaining rows are the body. Ragged rows are padded to the widest row. ```typescript await client.send(jid).text( [ '| Repo | Stars | Δ 24h |', '|---|---|---|', '| zaileys | 12.4k | +318 |', '| baileys | 15.1k | +92 |', '| venom | 6.2k | +11 |', ].join('\n'), { rich: true, title: '📊 Trending repos' }, ) ``` ### Images & galleries An `![alt](url)` line on its own becomes an image. **Consecutive** image lines collapse into a single swipeable gallery. ```typescript await client.send(jid).text( [ '*Release gallery* — swipe through the screenshots.', '', '![shot](https://placehold.co/600x800/png)', '![shot](https://placehold.co/512x512/png)', ].join('\n'), { rich: true, title: '🖼️ zaileys v4', footer: '#zaileys' }, ) ``` You can also produce images via the `:::image` directive (below). Inline `![](…)` is the shorthand; the directive is handy when you want to group images explicitly inside a directive flow. ## Directive blocks For primitives that have no native markdown form, use a `:::name … :::` fence. The opening line is `:::name` (alone on its line), followed by body lines, closed by a bare `:::`. ```text :::name body line 1 body line 2 ::: ``` Body items are read as a list — each line may optionally start with `-` or `*`. The available directives are: `suggest`, `tip`, `image`, `video`, `product`, `reels`, `post`. Most directives use an **inline field syntax** inside each item: `key: value` pairs separated by `|`. Keys are case-insensitive. Unknown keys are ignored. ### `:::suggest` — follow-up prompt pills Each item is split on `|` into individual suggestion pills. You can put all pills on one line or use multiple lines. ```typescript await client.send(jid).text( [ 'Anything else?', '', ':::suggest', 'See changelog | Upgrade guide | Compare v3 vs v4', ':::', ].join('\n'), { rich: true }, ) ``` ### `:::tip` — callout text A single highlighted metadata text block. Multiple body lines are joined with newlines. ```typescript await client.send(jid).text( [ ':::tip', 'Tap an image to open the full preview', ':::', ].join('\n'), { rich: true }, ) ``` ### `:::image` — image / gallery One URL per body line. A single URL → one image; multiple URLs → a gallery. (Equivalent to inline `![](url)` lines.) ```typescript await client.send(jid).text( [ ':::image', 'https://placehold.co/600x800/png', 'https://placehold.co/512x512/png', ':::', ].join('\n'), { rich: true }, ) ``` ### `:::video` — video clip(s) Each body line is `url | duration`. The URL is required; the duration (seconds) is read from the **first** item's second field and applied to the block. Multiple URLs become multiple clips. ```typescript await client.send(jid).text( [ ':::video', 'https://example.com/clip.mp4 | 10', ':::', ].join('\n'), { rich: true, title: '🎬 zaileys' }, ) ``` | Field | Type | Notes | | --- | --- | --- | | (first, unkeyed) | `string` | Video URL (required). | | (second, unkeyed) | `number` | Duration in seconds, read from the first item only. Defaults to `0`. | ### `:::product` — product card(s) Each item is a product. One item → a single card; multiple items → a horizontal scroll carousel. ```typescript await client.send(jid).text( [ 'Community merch 🛍️', '', ':::product', '- title: Sticker Pack | price: Rp35.000 | sale: Rp25.000 | brand: zaileys | image: https://placehold.co/512x512/png | url: https://github.com/zeative/zaileys', '- title: Hoodie Dev | price: Rp320.000 | sale: Rp275.000 | brand: zaileys | image: https://placehold.co/600x800/png | url: https://github.com/zeative/zaileys', ':::', ].join('\n'), { rich: true, title: '🛍️ zaileys Store' }, ) ``` | Field | Type | Notes | | --- | --- | --- | | `title` | `string` | **Required** — item is skipped if missing/empty. | | `price` | `string` | Regular price. | | `sale` / `saleprice` | `string` | Sale price (`sale` and `saleprice` both map to it). | | `brand` | `string` | Brand label. | | `url` | `string` | Product link. | | `image` | `string` | Main product image URL. | | `icon` | `string` | Additional/secondary image URL. | ### `:::reels` — reels carousel Each item is a reel. Always rendered as a horizontal scroll. ```typescript await client.send(jid).text( [ 'Trending in the community 👇', '', ':::reels', '- user: zeative | title: nativeFlow buttons demo | url: https://example.com/clip.mp4 | thumb: https://placehold.co/512x512/png | views: 12400 | likes: 980 | verified: true', '- user: zeative | title: AIRich rich response | url: https://example.com/clip.mp4 | thumb: https://placehold.co/600x800/png | views: 8800 | likes: 740 | verified: true', ':::', ].join('\n'), { rich: true, title: '🔥 Trending' }, ) ``` | Field | Type | Notes | | --- | --- | --- | | `user` / `username` | `string` | Creator handle (both keys map to `username`). | | `title` | `string` | Reel title. | | `profile` | `string` | Creator avatar / profile URL. | | `thumb` | `string` | Thumbnail URL. | | `url` | `string` | Video URL. | | `likes` | `number` | Like count. | | `shares` | `number` | Share count. | | `views` | `number` | View count. | | `source` | `string` | Source app label (defaults to `IG`). | | `verified` | `boolean` | `true`/`1`/`yes` → verified badge. | ### `:::post` — social post card(s) Each item is a post. Always rendered as a horizontal scroll (a single item still renders fine). ```typescript await client.send(jid).text( [ ':::post', '- user: zeative | title: zaileys v4 is out | caption: Buttons, carousel, AIRich — all built-in. | thumb: https://placehold.co/512x512/png | likes: 1500 | comments: 132 | verified: true | source: GITHUB', ':::', ].join('\n'), { rich: true, title: '📣 Announcements' }, ) ``` | Field | Type | Notes | | --- | --- | --- | | `user` / `username` | `string` | Author handle (both keys map to `username`). | | `title` | `string` | Post title. | | `subtitle` | `string` | Subtitle / secondary line. | | `profile` | `string` | Author avatar URL. | | `thumb` | `string` | Thumbnail URL. | | `caption` | `string` | Post caption. | | `likes` | `number` | Like count. | | `comments` | `number` | Comment count. | | `shares` | `number` | Share count. | | `url` | `string` | Post link. | | `source` | `string` | Source app label (defaults to `INSTAGRAM`). | | `footer` | `string` | Footer label. | | `icon` | `string` | Footer icon URL. | | `verified` | `boolean` | `true`/`1`/`yes` → verified badge. | For `reels` and `post`, numeric fields (`likes`, `views`, `comments`, `shares`) must parse as numbers — non-numeric values are dropped. Boolean fields accept `true`, `1`, or `yes`. ## Putting it all together Markdown and directives can be freely interleaved; they render in source order. This mirrors the `examples/airich-bot.ts` showcase: ```typescript const md = [ '*Galeri rilis v4* — geser untuk lihat tangkapan layar & klip.', '', '![shot](https://placehold.co/600x800/png)', '![shot](https://placehold.co/512x512/png)', '', ':::video', 'https://example.com/clip.mp4 | 10', ':::', '', ':::tip', 'Ketuk gambar untuk pratinjau penuh', ':::', '', ':::suggest', 'Lihat changelog | Cara upgrade | Bandingkan v3 vs v4', ':::', ].join('\n') await client.send(jid).text(md, { rich: true, title: '🖼️ zaileys v4', footer: '#zaileys' }) ``` ## Rich replies The same engine powers context replies. `msg.reply(content, opts?)` accepts the identical `TextOptions`, so `{ rich, title, footer, sources }` work there too. See [Events](/events) for the full message context API. ```typescript client.on('text', async (msg) => { if (msg.text.trim().toLowerCase() === 'rich') { await msg.reply( [ '*Rich reply example* ✨', '', '```ts', 'const x = 1', '```', '', ':::suggest', 'Again | Close', ':::', ].join('\n'), { rich: true, title: '🤖 zaileys' }, ) } }) ``` ## Tips & gotchas Rich content is sent as a forwarded bot message payload. It cannot be combined with regular text styling on the same builder call — pick `rich: true` *or* a plain string, not both. - **Empty content throws.** `text('', { rich: true })` (or content that parses to no parts) raises a builder error. Always supply at least one renderable block. - **Block features need their own line.** Code fences, table separators, image lines and `:::` directive markers must each start on a fresh line, exactly as shown. - **Directive items support `-`/`*` bullets.** Leading `-` or `*` on each item line is stripped, so both `- title: …` and `title: …` work. - **`product` requires `title`.** Items without a `title` are silently dropped. - **Field keys are case-insensitive** and split on `|`. Unrecognized keys are ignored, so adding a typo will simply have no effect rather than erroring. ## See also - [Sending Messages](/sending-messages) — the `.text()` builder and other content types. - [Interactive Messages](/interactive) — buttons, lists, carousels, and native flows. - [Events](/events) — the message context (`msg.reply`, `msg.react`, etc.). --- # Commands Zaileys ships a small but capable command framework on top of the message pipeline. Set a `commandPrefix` in your [client options](/configuration), register handlers with `client.command()`, and each matching message is parsed into a typed context with positional arguments, flags, JSON payloads, and reply helpers. ## Enabling commands The framework is **off by default**. It only activates when you pass `commandPrefix` to the `Client` constructor. The option accepts a single prefix string or an array of prefixes. ```typescript // Single prefix const client = new Client({ commandPrefix: '/' }) // Multiple prefixes — any of them triggers a command const multi = new Client({ commandPrefix: ['/', '!', '.'] }) ``` | Option | Type | Default | Description | | ------ | ---- | ------- | ----------- | | `commandPrefix` | `string \| string[]` | `undefined` | Prefix(es) that mark a message as a command. Omit to disable the framework entirely. | Empty strings are stripped from the prefix list. `commandPrefix: ''`, `commandPrefix: []`, or omitting the option all leave the command framework disabled — your `command()` handlers will never fire. Commands are matched on the `text` event stream, so they work in DMs, groups, and any chat that produces a text message. See [Events](/events) for the full event surface. ## Registering a command Use `client.command(name, handler)`. The handler receives a single `ctx` argument and may be sync or async. `command()` returns the client, so calls are chainable. ```typescript const client = new Client({ commandPrefix: ['/', '!'] }) client.command('ping', async (ctx) => { await ctx.reply('pong') }) ``` With `commandPrefix: ['/', '!']`, both `/ping` and `!ping` invoke this handler. Command names are **case-insensitive**. `/PING`, `/Ping`, and `/ping` all resolve to the command registered as `ping`. Internally every name is lowercased on both registration and lookup. ### Aliases Separate alternative names with a pipe (`|`). The first segment is the canonical name (the value you receive in `ctx.command`); the rest are aliases that resolve to the same handler. ```typescript client.command('help|h|?', async (ctx) => { await ctx.reply('Commands: /ping, /weather , /help') }) ``` Now `/help`, `/h`, and `/?` all run the handler, and `ctx.command` is always `'help'`. Registering a name or alias that already exists throws a `ZaileysCommandError` with code `DUPLICATE_COMMAND`. Empty specs (`''`) throw `INVALID_COMMAND_NAME`. Keep your command names unique across all aliases. ### Multi-word commands A single segment may contain spaces, which registers a multi-word (sub)command. Resolution prefers the **longest** match, so you can layer specific subcommands over a general one. ```typescript client.command('group info', async (ctx) => { await ctx.reply('Group subcommand: info') }) client.command('group kick', async (ctx) => { await ctx.reply(`Kicking: ${ctx.args.join(', ')}`) }) ``` `/group kick 628123` resolves to `group kick` with `ctx.args === ['628123']`. The leading words that form the command name are consumed and never appear in `ctx.args`. ## The command context A `CommandContext` extends the full [message context](/events) — every field and method from a normal text message is available (`ctx.senderId`, `ctx.roomId`, `ctx.isGroup`, `ctx.message()`, `ctx.replied()`, and so on). On top of that base, the command framework adds the following: | Field / Method | Type | Description | | -------------- | ---- | ----------- | | `command` | `string` | The canonical command name that matched (e.g. `'help'`, `'group kick'`). | | `args` | `string[]` | Positional arguments after the command name. Flags and the leading command words are removed. | | `flags` | `Record` | Parsed `--flag` options. `true` for boolean flags, the string value otherwise. | | `json` | `unknown` | The first argument that parsed as valid JSON (object/array), or `undefined`. | | `raw` | `string` | The original message text, including the prefix. | | `reply(content, opts?)` | `Promise` | Sends a reply quoting the triggering message. `opts` is [`TextOptions`](/sending-messages) (supports `{ rich: true }`). | | `react(emoji)` | `Promise` | Reacts to the triggering message with an emoji. | | `edit(content)` | `Promise` | Edits the **last message sent via `ctx.reply()`** in this handler run. | ```typescript client.command('weather', async (ctx) => { const city = ctx.args[0] if (!city) { await ctx.reply('Usage: /weather ') return } await ctx.reply(`Weather in ${city}: sunny, 28 degrees`) }) ``` Because the context inherits everything from the message context, you can branch on the chat: ```typescript client.command('whoami', async (ctx) => { if (ctx.isGroup) { await ctx.reply(`You are ${ctx.senderId} in room ${ctx.roomId}`) return } await ctx.reply(`You are ${ctx.senderId} (${ctx.senderName ?? 'unknown'})`) }) ``` `ctx.edit()` throws a `ZaileysCommandError` with code `NO_SENT_MESSAGE` if you call it before any `ctx.reply()`. Reply first, then edit. ```typescript client.command('progress', async (ctx) => { await ctx.reply('Working...') // ...do something... await ctx.edit('Done.') }) ``` ## Argument parsing When a message matches a prefix, the text after the prefix is tokenized. The parser is quote- and flag-aware. Understanding the rules helps you design predictable commands. ### Tokenization - Whitespace separates tokens. - Double (`"`) or single (`'`) quotes group a token, so `/say "hello world"` yields one argument `hello world`. Quotes are only treated as openers at the **start** of a token. - Inside quotes, `\` escapes the next character (e.g. `"a\"b"` → `a"b`). - The **first** token is the command name (lowercased); the rest are candidate args/flags. ```typescript client.command('say', async (ctx) => { // /say "hello world" loud -> args = ['hello world', 'loud'] await ctx.reply(ctx.args.join(' | ')) }) ``` ### Flags Tokens starting with `--` become entries in `ctx.flags`: | Input | Result | | ----- | ------ | | `--verbose` | `flags.verbose === true` (boolean — no following value, or followed by another flag) | | `--level=high` | `flags.level === 'high'` (inline `=` value) | | `--level high` | `flags.level === 'high'` (next token consumed as the value) | | `--` | pushed into `ctx.args` as the literal string `"--"` (empty flag body) | ```typescript client.command('deploy', async (ctx) => { // /deploy app1 --env=prod --force const target = ctx.args[0] // 'app1' const env = ctx.flags.env // 'prod' const force = ctx.flags.force === true await ctx.reply(`Deploy ${target} to ${env}${force ? ' (forced)' : ''}`) }) ``` A bare flag followed by a non-flag token consumes that token as its value: `--level high` sets `flags.level = 'high'` and `high` does **not** appear in `ctx.args`. If you want a positional `high`, put the flag last or use the `--level=high` form. ### JSON arguments The first token that begins with `{` or `[` and parses as valid JSON is exposed on `ctx.json`. The token also remains in `ctx.args` (JSON parsing is additive, not consuming). Invalid JSON is left untouched and simply stays an arg. ```typescript client.command('config', async (ctx) => { // /config {"theme":"dark","notify":true} const cfg = ctx.json as { theme: string; notify: boolean } | undefined if (!cfg) { await ctx.reply('Send a JSON object: /config {"theme":"dark"}') return } await ctx.reply(`Theme set to ${cfg.theme}`) }) ``` A JSON object/array passed as a single argument almost always needs quoting at the WhatsApp level to survive intact, or it must contain no spaces (as above). Spaces split tokens, so `{"theme": "dark"}` (with a space) would break across tokens unless quoted. ## Middleware Register middleware with `client.use(middleware)`. Each middleware has the signature `(ctx, next) => void | Promise` and runs **before** the matched command handler — ideal for logging, authentication, and rate limiting. Like `command()`, `use()` returns the client and is chainable. ```typescript const client = new Client({ commandPrefix: ['/', '!'] }) const loggingMiddleware: Middleware = async (ctx, next) => { console.log(`[command] ${ctx.command} from ${ctx.senderId} args=${ctx.args.join(',')}`) await next() } client.use(loggingMiddleware) ``` ### Ordering and the onion model Middleware runs in registration order, wrapping the handler like layers of an onion. Call `next()` to pass control to the next middleware (and eventually the handler); skip `next()` to short-circuit and stop the chain. ```typescript // 1. Outermost: timing client.use(async (ctx, next) => { const start = Date.now() await next() console.log(`${ctx.command} took ${Date.now() - start}ms`) }) // 2. Auth gate — short-circuits unauthorized callers const ADMINS = ['628123@s.whatsapp.net'] client.use(async (ctx, next) => { if (!ADMINS.includes(ctx.senderId)) { await ctx.reply('Not authorized.') return // do NOT call next() — handler never runs } await next() }) // 3. Handler runs only if both middlewares called next() client.command('shutdown', async (ctx) => { await ctx.reply('Shutting down...') }) ``` For `/shutdown`, the execution order is: timing (before `next`) → auth → handler → timing (after `next`). If the auth middleware returns without calling `next()`, the handler and the rest of the chain are skipped, but the timing middleware's "after" code still runs because it already awaited `next()`. Calling `next()` more than once in a single middleware throws a `ZaileysCommandError` with code `MIDDLEWARE_ERROR`. Call it exactly once (to continue) or not at all (to stop). ### Error handling in the chain Errors thrown by middleware or handlers are caught by the dispatcher and logged via the client logger; they do not crash the process. Non-`ZaileysCommandError` throws are wrapped: a handler failure becomes a `HANDLER_ERROR`, and a middleware failure becomes a `MIDDLEWARE_ERROR`. See [Error handling](/error-handling) for the full error model. ```typescript client.use(async (ctx, next) => { try { await next() } catch (err) { await ctx.reply('Something went wrong.') throw err // re-throw so the client logger still records it } }) ``` ## A complete bot This mirrors `examples/command-bot.ts` — a small router with logging middleware and a few commands. ```typescript const client = new Client({ commandPrefix: ['/', '!'] }) client.on('qr', ({ qrString }) => console.log('Scan QR:', qrString)) const loggingMiddleware: Middleware = async (ctx, next) => { console.log(`[command] ${ctx.command} from ${ctx.senderId} args=${ctx.args.join(',')}`) await next() } client.use(loggingMiddleware) client.command('ping', async (ctx) => { await ctx.reply('pong') }) client.command('help|h|?', async (ctx) => { await ctx.reply('Commands: /ping, /weather , /help') }) client.command('weather', async (ctx) => { const city = ctx.args[0] if (!city) { await ctx.reply('Usage: /weather ') return } await ctx.reply(`Weather in ${city}: sunny, 28 degrees`) }) client.on('connect', ({ me }) => { console.log('Command bot ready as', me.id) }) ``` You can register commands and middleware before the connection is established. They are attached lazily once the socket is ready, so order between `command()`, `use()`, and `connect()` does not matter for setup. See [Configuration](/configuration) for `autoConnect` behavior. ## Edge cases & gotchas ### Unknown commands are silently ignored If a message matches a prefix but no registered command (or alias) resolves, nothing happens — no error, no reply. Add a `help` command and tell users which commands exist, or add a catch-all by checking unmatched text on the `text` event. ### A bare prefix does nothing A message that is just the prefix (e.g. `/` with no command) parses to an empty command name and resolves to no handler. It is ignored. ### Prefix matching is literal and ordered Prefixes are matched with a simple `startsWith` against the message text, in the order you listed them. The first prefix that matches wins. Because matching is literal, a prefix like `.` will also trigger on messages that merely start with a period — choose prefixes that are unlikely to collide with normal chat. ### Commands fire on text messages only The dispatcher subscribes to the `text` event. Media captions and other non-text payloads do not go through the command router. Use the relevant [Events](/events) for those. ### Reactions and edits target the triggering message `ctx.react()` reacts to the message that invoked the command. `ctx.edit()` edits the most recent message you sent with `ctx.reply()` during this handler run — it does not edit the user's message. ## Types reference All command types are exported from `zaileys`: ```typescript import type { CommandContext, CommandHandler, Middleware, CommandPrefix, ParsedArgs, } from 'zaileys' ``` | Type | Shape | | ---- | ----- | | `CommandPrefix` | `string \| string[]` | | `CommandHandler` | `(ctx: CommandContext) => Promise \| void` | | `Middleware` | `(ctx: CommandContext, next: () => Promise) => Promise \| void` | | `CommandContext` | `MessageContext` + `command`, `args`, `flags`, `json`, `raw`, `reply`, `react`, `edit` | ## Related - [Configuration](/configuration) — `commandPrefix`, `autoConnect`, and other client options. - [Events](/events) — the `text` event and the full message context that commands inherit. - [Sending messages](/sending-messages) — `TextOptions` accepted by `ctx.reply()`. - [Error handling](/error-handling) — `ZaileysCommandError` codes and the dispatcher's error model. --- # Broadcast & Schedule Zaileys ships a small automation toolkit on top of the regular message builder: send the same message to many recipients with built-in rate limiting (`client.broadcast`), queue a message for a future moment (`client.scheduleAt`), and drive typing/online indicators (`client.presence`). Every one of these reuses the same builder you already know from [Sending Messages](/sending-messages), so anything you can send normally you can broadcast or schedule. ## Broadcast `client.broadcast()` sends one message to a list of recipients, pacing the sends through a token bucket so you never exceed WhatsApp's tolerances. The builder callback runs **once per recipient**, giving you a fresh builder bound to that recipient's JID. ```typescript const client = new Client() client.on('connect', async () => { const result = await client.broadcast( ['6281111111111@s.whatsapp.net', '6282222222222@s.whatsapp.net'], (builder) => builder.text('Announcement: maintenance tonight at 22:00.'), { rateLimitPerSec: 5 }, ) console.log(`Sent ${result.sent.length}, failed ${result.failed.length}`) }) ``` `broadcast()` requires an active connection — it throws if the socket is not connected. Call it from inside a `connect` event handler (or any time after the client has connected). See [Events](/events) and [Configuration](/configuration#autoconnect). ### Signature ```typescript broadcast( jids: string[], build: (b: MessageBuilder) => MessageBuilder, options?: BroadcastOptions, ): Promise ``` | Parameter | Type | Description | | --- | --- | --- | | `jids` | `string[]` | Recipient JIDs (`628xxx@s.whatsapp.net` for users, `xxx@g.us` for groups). An empty array resolves immediately with empty results. | | `build` | `(b) => MessageBuilder` | Builder callback run once per recipient. Must return a builder with content set (e.g. after `.text()`, `.image()`, …). | | `options` | `BroadcastOptions` | Optional rate-limit, retry, and progress settings (table below). | ### Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `rateLimitPerSec` | `number` | `5` | Max sends per second across the whole broadcast (global token-bucket rate). | | `retry` | `RetryPolicy` | _none_ | When set, each recipient's send is retried on failure (see [Retry & error handling](#retry--error-handling)). | | `onProgress` | `(done, total, jid, ok) => void` | _none_ | Called after each recipient, success or failure. See callback signature below. | The `onProgress` callback signature is exactly: ```typescript onProgress: (done: number, total: number, jid: string, ok: boolean) => void ``` - `done` — how many recipients have been processed so far (1-based, increments on success **and** failure). - `total` — total number of recipients (`jids.length`). - `jid` — the recipient that was just processed. - `ok` — `true` if the send succeeded, `false` if it failed. ```typescript const result = await client.broadcast( recipients, (b) => b.text('Promo: 20% off all items this weekend.'), { rateLimitPerSec: 5, onProgress: (done, total, jid, ok) => { console.log(`[${done}/${total}] ${jid} ${ok ? 'sent' : 'failed'}`) }, }, ) ``` ### Result shape `broadcast()` resolves to a `BroadcastResult` once every recipient has been attempted. It never rejects on per-recipient failures — failed sends are collected, not thrown. ```typescript type BroadcastResult = { sent: string[] // JIDs that succeeded failed: { jid: string; error: Error }[] // JIDs that failed, with the original error } ``` ```typescript const { sent, failed } = await client.broadcast(recipients, (b) => b.text('Hello!')) console.log(`${sent.length} delivered`) for (const f of failed) { console.error(`Failed ${f.jid}: ${f.error.message}`) } ``` ### Any builder content works The builder passed to `broadcast()` is the full [message builder](/sending-messages) — text is just the simplest case. You can send media, captions, mentions, buttons, or anything else the builder supports. ```typescript await client.broadcast( recipients, (b) => b.image('https://example.com/banner.jpg', { caption: 'New collection is live!', }), { rateLimitPerSec: 3 }, ) ``` Because `build` runs per recipient, you can personalise each message. Capture the recipient from the loop by reading it inside the callback if you build the JID list with a lookup table, or send separate `broadcast()` calls for distinct content. ## Rate limiting (token bucket) Broadcasts are paced by an internal **token-bucket** rate limiter. Conceptually: - The bucket starts full with `rateLimitPerSec` tokens (this is also the burst capacity). - It refills continuously at `rateLimitPerSec` tokens per second. - Each recipient consumes one token before its send. If no token is available, the broadcast `await`s just long enough for one to refill, then proceeds. This means an initial burst can go out immediately (up to the bucket capacity), after which sends settle into a steady `rateLimitPerSec` cadence — no manual `sleep()` calls required. ```typescript // At most ~2 messages per second, smoothing out a 500-recipient blast. await client.broadcast(bigList, (b) => b.text('Newsletter #42'), { rateLimitPerSec: 2, }) ``` WhatsApp aggressively flags accounts that blast unsolicited messages. Keep `rateLimitPerSec` conservative, only message users who expect to hear from you, and prefer small, relevant lists.
Advanced: the underlying `RateLimiter` (exported) The rate limiter is exported from `zaileys` and supports a richer set of knobs than `broadcast()` surfaces. `broadcast()` only wires up `perSec`, but the limiter itself accepts: | Option | Type | Default | Description | | --- | --- | --- | --- | | `perSec` | `number` | _required_ | Global refill rate (tokens/sec). Must be `> 0`. | | `perJidPerSec` | `number` | _none_ | Optional **per-recipient** rate cap (tokens/sec) layered on top of the global one. Must be `> 0`. | | `burst` | `number` | `perSec` | Global bucket capacity (max instantaneous burst). Must be `> 0`. | Invalid values throw a `ZaileysAutomationError` with code `RATE_LIMIT_INVALID`. ```typescript const limiter = new RateLimiter({ perSec: 10, perJidPerSec: 1, burst: 20 }) await limiter.acquire('6281111111111@s.whatsapp.net') // waits if needed ```
## Retry & error handling By default, broadcast does **not** retry — a failed recipient simply lands in `result.failed` with its original `Error`. The run never aborts because one JID is bad; every recipient is attempted independently. To retry transient failures, pass a `retry` policy. When set, each recipient's send is wrapped in a serial queue (concurrency `1`) that re-runs the send up to `maxRetries` times, sleeping `backoffMs(attempt)` milliseconds between attempts. A recipient only counts as failed after all retries are exhausted. ```typescript type RetryPolicy = { maxRetries: number backoffMs: (attempt: number) => number // attempt is 1-based } ``` ```typescript await client.broadcast( recipients, (b) => b.text('Important notice'), { rateLimitPerSec: 5, retry: { maxRetries: 3, backoffMs: (attempt) => attempt * 1000, // 1s, 2s, 3s }, }, ) ``` The rate limiter still applies: a token is acquired once per recipient before its first attempt, so retries do not bypass your `rateLimitPerSec` budget. See [Error Handling](/error-handling) for general error-handling patterns. ## Schedule `client.scheduleAt()` queues a message to be sent at a future `Date`. Critically, the builder callback is **evaluated once, at schedule time**, into a serializable snapshot (`{ recipient, content, options? }`) — it is never stored as a live closure. That snapshot is what gets persisted and replayed, which is exactly what makes restart-recovery possible. ```typescript const client = new Client() client.on('connect', async () => { const job = await client.scheduleAt( new Date(Date.now() + 60_000), // one minute from now (b) => b.to('6281111111111@s.whatsapp.net').text('This sends in one minute.'), ) console.log('Scheduled job id:', job.id) }) ``` ### Signature ```typescript scheduleAt( date: Date, build: (b: MessageBuilder) => MessageBuilder, ): Promise ``` | Parameter | Type | Description | | --- | --- | --- | | `date` | `Date` | When to fire. Must be a valid `Date` — an invalid date throws `ZaileysAutomationError` (`SCHEDULE_INVALID`). A past date fires as soon as possible. | | `build` | `(b) => MessageBuilder` | Builder callback evaluated immediately to capture the message. Must select a recipient and set content; producing no content throws `SCHEDULE_INVALID`. | The returned `ScheduleHandle`: ```typescript type ScheduleHandle = { id: string // unique job id (UUID) cancel(): void // cancel before it fires; removes it from the store too } ``` ```typescript const job = await client.scheduleAt(date, (b) => b.to(jid).text('Reminder')) // Changed your mind before it fires: job.cancel() ``` Because the message is snapshotted at schedule time, the recipient must be resolvable from the builder right away. Make sure your callback sets a recipient (e.g. via `b.to(jid)`) and content before returning. A callback that throws or sets no content rejects with `SCHEDULE_INVALID`. ### Persistence & restart recovery Each scheduled job is timed in-process with a timer and, when supported, persisted to the [message store](/storage). On startup the client automatically calls the scheduler's `loadPending()` to re-arm any jobs that were saved but had not yet fired — so a job scheduled for 3am survives a restart at midnight. Restart-survival depends on the store implementing the optional scheduled-job methods: | Method | Purpose | | --- | --- | | `saveScheduledJob(job)` | Persist a job when it is scheduled. | | `listScheduledJobs()` | Load not-yet-fired jobs on startup (used by `loadPending()`). | | `deleteScheduledJob(id)` | Remove a job after it fires or is cancelled. | These methods are **optional** on the store interface. Among the bundled adapters, only the **Convex** adapter implements scheduled-job persistence today. With any store that does not implement them — including the in-memory default, SQLite, Postgres, and Redis adapters — jobs are kept only in memory and are **lost on restart**. See [Storage Adapters](/storage) for adapter details. When a job fires, the snapshot is dispatched through the live socket. If the send fails at fire time, the error is logged via the client logger (it does not crash the process), and the job is removed from the store. ## Presence automation `client.presence` exposes typing, recording, and online/offline indicators. It is a lazily-created `PresenceModule` and requires an active connection (it throws `ZaileysAutomationError` with code `NOT_CONNECTED` otherwise). ```typescript const client = new Client() const jid = '6281111111111@s.whatsapp.net' client.on('connect', async () => { await client.presence.online() // mark account as online await client.presence.typing(jid) // show "typing…" in that chat await client.send(jid).text('Hi! Thanks for waiting.') }) ``` ### Methods | Method | Signature | Description | | --- | --- | --- | | `online()` | `() => Promise` | Marks the account as available (`available`). | | `offline()` | `() => Promise` | Marks the account as unavailable (`unavailable`). | | `typing(jid, ms?)` | `(jid: string, ms?: number) => Promise` | Shows a "typing…" indicator in `jid`'s chat. If `ms` is given, it auto-clears (sends `paused`) after that many milliseconds. | | `recording(jid, ms?)` | `(jid: string, ms?: number) => Promise` | Shows a "recording audio…" indicator in `jid`'s chat, with the same optional `ms` auto-clear. | ```typescript // Simulate a natural typing delay before replying. await client.presence.typing(jid, 2000) // auto-clears after 2s setTimeout(() => client.send(jid).text('Done thinking!'), 2000) ``` The auto-clear timer is `unref`'d, so a pending presence clear will not keep your process alive on its own. If a presence update fails, it surfaces as `ZaileysAutomationError` with code `PRESENCE_FAILED`. ## Errors Automation-specific failures throw `ZaileysAutomationError`, which carries a `code` and an optional `cause`. You can import it from `zaileys` to handle these precisely. ```typescript try { await client.presence.online() } catch (err) { if (err instanceof ZaileysAutomationError && err.code === 'NOT_CONNECTED') { console.error('Connect the client before driving presence.') } } ``` | Code | Raised when | | --- | --- | | `NOT_CONNECTED` | A presence call is made without an active socket. | | `RATE_LIMIT_INVALID` | A `RateLimiter` is constructed with `perSec`/`perJidPerSec`/`burst` ≤ 0. | | `SCHEDULE_INVALID` | `scheduleAt` gets an invalid `Date`, or the builder throws / produces no content. | | `PRESENCE_FAILED` | A presence update fails at the socket level. | See also [Sending Messages](/sending-messages) for the builder API used by both broadcast and schedule, and [Storage Adapters](/storage) for persisting scheduled jobs across restarts. --- # Presence `client.presence` exposes the four WhatsApp presence signals your bot can emit: marking the account as online or offline globally, and showing a "typing…" or "recording audio…" indicator inside a specific chat. It is a lazily-created `PresenceModule` — the object is constructed on first access and proxies every call through the live socket. ```typescript const client = new Client() client.on('connect', async () => { await client.presence.online() const jid = '628xxxxxxxxxx@s.whatsapp.net' await client.presence.typing(jid, 2000) // shows "typing…", clears after 2 s await client.send(jid).text('Hey, thanks for reaching out!') }) ``` Every method calls an internal `requireSocket()` guard. If the client is not connected when you call any presence method, it throws a `ZaileysAutomationError` with code `NOT_CONNECTED` and message `client not connected`. Always wait for the `'connect'` event (or `await client.connect()`) before driving presence. See [Error Handling](/error-handling). ## Methods at a glance | Method | Signature | Description | | --- | --- | --- | | `online()` | `() => Promise` | Marks the account as available (`available`) globally. | | `offline()` | `() => Promise` | Marks the account as unavailable (`unavailable`) globally. | | `typing(jid, ms?)` | `(jid: string, ms?: number) => Promise` | Shows a "typing…" indicator in the given chat. If `ms` is provided, auto-clears to `paused` after that many milliseconds. | | `recording(jid, ms?)` | `(jid: string, ms?: number) => Promise` | Shows a "recording audio…" indicator in the given chat. Auto-clears to `paused` after `ms` milliseconds if provided. | ## `online()` ```typescript online(): Promise ``` Broadcasts an `available` status update globally, making your account appear online to contacts. ```typescript client.on('connect', async () => { await client.presence.online() }) ``` ## `offline()` ```typescript offline(): Promise ``` Broadcasts an `unavailable` status update globally. Use this to signal that the account is no longer active — for example, during scheduled downtime or before a clean shutdown. ```typescript process.on('SIGINT', async () => { await client.presence.offline() await client.disconnect() process.exit(0) }) ``` ## `typing(jid, ms?)` ```typescript typing(jid: string, ms?: number): Promise ``` | Parameter | Type | Description | | --- | --- | --- | | `jid` | `string` | The chat JID to show the indicator in (`628xxxxxxxxxx@s.whatsapp.net` for users, `xxx@g.us` for groups). | | `ms` | `number` (optional) | If provided, schedules an automatic `paused` clear after this many milliseconds. | Sends a `composing` presence update to the specified chat so the recipient sees "typing…". When `ms` is given, a timer fires after that delay and sends `paused` to clear the indicator — you do not need to call anything else. The timer is `unref`'d so it will not prevent your process from exiting. ```typescript const jid = '628xxxxxxxxxx@s.whatsapp.net' // Manual clear (you are responsible for clearing later) await client.presence.typing(jid) // Auto-clear after 1.5 s await client.presence.typing(jid, 1500) ``` ## `recording(jid, ms?)` ```typescript recording(jid: string, ms?: number): Promise ``` | Parameter | Type | Description | | --- | --- | --- | | `jid` | `string` | The chat JID to show the indicator in. | | `ms` | `number` (optional) | Auto-clears to `paused` after this many milliseconds, same as `typing`. | Shows a "recording audio…" indicator (`recording`) in the chat. Behaves identically to `typing` regarding auto-clear: pass `ms` to let zaileys clear it automatically. ```typescript const jid = '628xxxxxxxxxx@s.whatsapp.net' await client.presence.recording(jid, 3000) // clears after 3 s await client.send(jid).audio('https://example.com/voice-note.ogg', { ptt: true }) ``` ## Auto-clear behavior When you pass `ms` to `typing` or `recording`, zaileys schedules an internal `setTimeout` that sends a `paused` update to the same JID after the delay. This clears the indicator without any extra call from your side: ```typescript // Pattern: show indicator → wait the same delay → send reply const DELAY_MS = 2000 const jid = '628xxxxxxxxxx@s.whatsapp.net' await client.presence.typing(jid, DELAY_MS) setTimeout(async () => { await client.send(jid).text('Here is your answer.') }, DELAY_MS) ``` The auto-clear `setTimeout` is `unref`'d in Node.js — a pending clear will not keep your process alive on its own. If the socket disconnects before the timer fires, the clear is silently dropped (the `sendPresenceUpdate` error is caught and ignored internally). ## Practical pattern: typing indicator inside a message handler The most common use case is showing a "typing…" indicator before replying to an inbound message. Call `typing` with an `ms` value that matches how long your handler will actually take, then send the reply after that same delay: ```typescript const client = new Client() client.on('text', async (ctx) => { const jid = ctx.roomId const THINK_MS = 1500 // Show "typing…" — auto-clears after THINK_MS await client.presence.typing(jid, THINK_MS) // Simulate processing time, then reply await new Promise((resolve) => setTimeout(resolve, THINK_MS)) await client.send(jid).text(`You said: ${ctx.text}`) }) ``` For a voice-note bot the same pattern works with `recording`: ```typescript client.on('text', async (ctx) => { const jid = ctx.roomId await client.presence.recording(jid, 2000) await new Promise((resolve) => setTimeout(resolve, 2000)) await client.send(jid).audio('https://example.com/reply.ogg', { ptt: true }) }) ``` ## Presence throttle **Built-in spam guard.** Sending presence updates in rapid succession (e.g., calling `typing` inside a high-frequency loop) is a known signal that WhatsApp uses to identify bot accounts. The `PresenceModule` includes a built-in throttle that silently drops duplicate updates for the same `(type, chat)` pair within a configurable window. **How it works:** the throttle is keyed per `type + jid`. The first call for a given key goes through immediately and records a timestamp. Any subsequent call for the same key within `minIntervalMs` milliseconds is dropped silently — the `Promise` resolves without sending. Once the window expires, the next call goes through and resets the timestamp. **Default:** throttle is **on** with `minIntervalMs: 1000` (1 second). This means calling `typing(jid)` in a tight loop sends at most one update per second per chat, regardless of how many times you call it. Configure via the `presence` option in [Configuration](/configuration): ```typescript // Tighten the window to 500 ms const client = new Client({ presence: { minIntervalMs: 500 }, }) // Disable the throttle entirely (not recommended in production) const client2 = new Client({ presence: { enabled: false }, }) ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `enabled` | `boolean` | `true` | Whether the throttle is active. | | `minIntervalMs` | `number` | `1000` | Minimum milliseconds between two identical `(type, jid)` updates. | If you disable the throttle and drive presence from a hot path, you risk triggering WhatsApp's rate-limiting or account restrictions. See [Troubleshooting](/troubleshooting) if your account gets flagged. ## Error handling Presence methods throw `ZaileysAutomationError` on failure. Import the class from `zaileys` to handle specific codes: ```typescript const client = new Client() const jid = '628xxxxxxxxxx@s.whatsapp.net' try { await client.presence.typing(jid) } catch (err) { if (err instanceof ZaileysAutomationError) { if (err.code === 'NOT_CONNECTED') { console.error('Client must be connected before driving presence.') } else if (err.code === 'PRESENCE_FAILED') { console.error('Presence update failed at socket level:', err.cause) } } } ``` | Code | When it is thrown | | --- | --- | | `NOT_CONNECTED` | Any presence method is called before the client has an active socket. | | `PRESENCE_FAILED` | The underlying socket `sendPresenceUpdate` call throws. The original error is attached as `err.cause`. | See [Error Handling](/error-handling) for the full `ZaileysAutomationError` reference and general error-handling patterns. ## See also - [Automation](/automation) — `client.broadcast()` and `client.scheduleAt()` for bulk and scheduled sends. - [Configuration](/configuration) — the `presence` ClientOption and all other connection options. - [Error Handling](/error-handling) — `ZaileysAutomationError` codes and catch patterns. - [Troubleshooting](/troubleshooting) — what to do if your account gets rate-limited or flagged. --- # Groups `client.group` is the group management namespace on the zaileys [Client](/client). It provides full lifecycle control over WhatsApp groups: create and leave groups, manage participants, rotate invite links, update subject and description, configure disappearing messages, and lock or unlock group settings. ```typescript const client = new Client({ sessionId: 'default' }) client.on('connect', async () => { const groupId = '120363000000000000@g.us' const meta = await client.group.metadata(groupId) console.log(meta.subject, 'has', meta.participants.length, 'members') }) ``` Every method on `client.group` calls an internal `requireSocket()` guard. If the client is not connected, the call immediately throws a `ZaileysDomainError` with code `NOT_CONNECTED` and message `client not connected`. Always wait for the `'connect'` event (or `await client.connect()`) before calling any group method. See [Error Handling](/error-handling). ## At a glance | Method | Signature | Returns | Description | | ------ | --------- | ------- | ----------- | | `create` | `create(subject, participants[])` | `Promise` | Create a new group. | | `metadata` | `metadata(groupId)` | `Promise` | Fetch group info. | | `addMember` | `addMember(groupId, jids[])` | `Promise` | Add participants. | | `removeMember` | `removeMember(groupId, jids[])` | `Promise` | Remove participants. | | `promote` | `promote(groupId, jids[])` | `Promise` | Promote participants to admin. | | `demote` | `demote(groupId, jids[])` | `Promise` | Demote admins to member. | | `updateSubject` | `updateSubject(groupId, subject)` | `Promise` | Change the group name. | | `updateDescription` | `updateDescription(groupId, description?)` | `Promise` | Change or clear the group description. | | `leave` | `leave(groupId)` | `Promise` | Leave the group. | | `tagMember` | `tagMember(groupId, jid, label)` | `Promise` | Apply a member label in the group. | | `inviteCode` | `inviteCode(groupId)` | `Promise` | Get the current invite link code. | | `revokeInvite` | `revokeInvite(groupId)` | `Promise` | Revoke and regenerate the invite link. | | `acceptInvite` | `acceptInvite(code)` | `Promise` | Join a group by invite code; returns the group JID. | | `toggleEphemeral` | `toggleEphemeral(groupId, seconds)` | `Promise` | Set the disappearing-message timer (`0` disables). | | `setting` | `setting(groupId, value)` | `Promise` | Update group restrictions (`announcement`, `locked`, etc.). | ## Rate limiting and ban safety **Rapid group operations are one of the top WhatsApp ban triggers.** Creating many groups, joining many groups, or adding/removing large numbers of participants in quick succession is treated as automated bulk abuse. zaileys ships an `operationGuard` that automatically spaces out sensitive operations by category. It is **enabled by default**. The default minimum intervals are: | Category | Operations | Default interval | | -------- | ---------- | ---------------- | | `group.create` | `create()` | 60 seconds | | `group.participants` | `addMember()`, `removeMember()`, `promote()`, `demote()` | 10 seconds | | `group.join` | `acceptInvite()` | 30 seconds | When multiple calls of the same category queue up, each one waits for the previous to finish plus the minimum interval before running. This means calling `addMember()` three times in a row will space the sends ~10 seconds apart automatically. To tune or disable the guard, see [Configuration — operationGuard](/configuration#operationguard). For advice on what patterns are risky, see [Troubleshooting](/troubleshooting). ## `create` ```typescript create(subject: string, participants: string[]): Promise ``` Creates a new WhatsApp group with the given subject (name) and initial list of participant JIDs. Returns the full `GroupMetadata` for the newly created group. Throttled by `operationGuard` under the `group.create` category (default 60 s between calls). ```typescript client.on('connect', async () => { const group = await client.group.create('Project Zaileys', [ '628111111111@s.whatsapp.net', '628222222222@s.whatsapp.net', ]) console.log('Created group:', group.id) console.log('Subject:', group.subject) }) ``` | Parameter | Type | Description | | --------- | ---- | ----------- | | `subject` | `string` | The group name (displayed to all participants). | | `participants` | `string[]` | Initial member JIDs in the format `628xxxxxxxxxx@s.whatsapp.net`. | **Returns:** `GroupMetadata` — see [GroupMetadata shape](#groupmetadata). ## `metadata` ```typescript metadata(groupId: string): Promise ``` Fetches the current metadata for a group: subject, description, participant list, admin flags, creation timestamp, and related fields. This is the primary read operation for group state. ```typescript client.on('connect', async () => { const meta = await client.group.metadata('120363000000000000@g.us') console.log('Subject:', meta.subject) console.log('Participants:', meta.participants.length) const admins = meta.participants.filter((p) => p.admin) console.log('Admins:', admins.map((p) => p.id)) }) ``` | Parameter | Type | Description | | --------- | ---- | ----------- | | `groupId` | `string` | The group JID (e.g. `120363000000000000@g.us`). | **Returns:** `GroupMetadata` — see [GroupMetadata shape](#groupmetadata). ## `addMember` ```typescript addMember(groupId: string, jids: string[]): Promise ``` Adds one or more participants to the group. Returns a result entry for each JID indicating whether the operation succeeded. Throttled by `operationGuard` under the `group.participants` category (default 10 s between calls). You must be a group admin to add members. ```typescript client.on('connect', async () => { const results = await client.group.addMember('120363000000000000@g.us', [ '628333333333@s.whatsapp.net', '628444444444@s.whatsapp.net', ]) for (const r of results) { console.log(r.jid, '->', r.status) } }) ``` | Parameter | Type | Description | | --------- | ---- | ----------- | | `groupId` | `string` | Target group JID. | | `jids` | `string[]` | JIDs to add. | **Returns:** `ParticipantUpdateResult[]` — `{ jid: string; status: string }` for each entry. ## `removeMember` ```typescript removeMember(groupId: string, jids: string[]): Promise ``` Removes one or more participants from the group. You must be a group admin. Throttled under `group.participants` (default 10 s). ```typescript client.on('connect', async () => { const results = await client.group.removeMember('120363000000000000@g.us', [ '628333333333@s.whatsapp.net', ]) for (const r of results) { console.log(r.jid, '->', r.status) } }) ``` | Parameter | Type | Description | | --------- | ---- | ----------- | | `groupId` | `string` | Target group JID. | | `jids` | `string[]` | JIDs to remove. | **Returns:** `ParticipantUpdateResult[]`. ## `promote` ```typescript promote(groupId: string, jids: string[]): Promise ``` Promotes one or more participants to group admin. You must be a group admin yourself. Throttled under `group.participants` (default 10 s). ```typescript client.on('connect', async () => { const results = await client.group.promote('120363000000000000@g.us', [ '628111111111@s.whatsapp.net', ]) for (const r of results) { console.log(r.jid, 'promoted, status:', r.status) } }) ``` | Parameter | Type | Description | | --------- | ---- | ----------- | | `groupId` | `string` | Target group JID. | | `jids` | `string[]` | JIDs to promote. | **Returns:** `ParticipantUpdateResult[]`. ## `demote` ```typescript demote(groupId: string, jids: string[]): Promise ``` Demotes one or more group admins back to regular member. You must be a group admin yourself. Throttled under `group.participants` (default 10 s). ```typescript client.on('connect', async () => { const results = await client.group.demote('120363000000000000@g.us', [ '628111111111@s.whatsapp.net', ]) for (const r of results) { console.log(r.jid, 'demoted, status:', r.status) } }) ``` | Parameter | Type | Description | | --------- | ---- | ----------- | | `groupId` | `string` | Target group JID. | | `jids` | `string[]` | JIDs to demote. | **Returns:** `ParticipantUpdateResult[]`. ## `updateSubject` ```typescript updateSubject(groupId: string, subject: string): Promise ``` Updates the group name (subject). You must be a group admin (or the group must not have the `locked` setting). Does not go through the `operationGuard` — avoid calling in a tight loop. ```typescript client.on('connect', async () => { await client.group.updateSubject('120363000000000000@g.us', 'Team Announcements') }) ``` | Parameter | Type | Description | | --------- | ---- | ----------- | | `groupId` | `string` | Target group JID. | | `subject` | `string` | New group name. | ## `updateDescription` ```typescript updateDescription(groupId: string, description?: string): Promise ``` Updates the group description. Pass `undefined` or omit `description` to clear it. You must be a group admin. ```typescript client.on('connect', async () => { // Set description await client.group.updateDescription( '120363000000000000@g.us', 'This group is for internal announcements only.', ) // Clear description await client.group.updateDescription('120363000000000000@g.us') }) ``` | Parameter | Type | Description | | --------- | ---- | ----------- | | `groupId` | `string` | Target group JID. | | `description` | `string \| undefined` | New description, or omit to clear. | ## `leave` ```typescript leave(groupId: string): Promise ``` Leaves the specified group. After this call the bot is no longer a participant and will not receive further group events for that JID. ```typescript client.on('connect', async () => { await client.group.leave('120363000000000000@g.us') console.log('Left the group.') }) ``` | Parameter | Type | Description | | --------- | ---- | ----------- | | `groupId` | `string` | Group JID to leave. | ## `tagMember` ```typescript tagMember(groupId: string, jid: string, label: string): Promise ``` Applies a label to a member within the group. The `jid` parameter identifies the target member; the `label` is the label string to apply. Label support depends on the group's WhatsApp configuration. ```typescript client.on('connect', async () => { await client.group.tagMember( '120363000000000000@g.us', '628111111111@s.whatsapp.net', 'VIP', ) }) ``` | Parameter | Type | Description | | --------- | ---- | ----------- | | `groupId` | `string` | Target group JID. | | `jid` | `string` | The member's JID. | | `label` | `string` | Label string to apply. | ## `inviteCode` ```typescript inviteCode(groupId: string): Promise ``` Returns the current invite code for the group (the fragment appended to `https://chat.whatsapp.com/`). Throws `ZaileysDomainError('OPERATION_FAILED')` if the code is unavailable (e.g. the bot is not an admin or the group does not exist). ```typescript client.on('connect', async () => { const code = await client.group.inviteCode('120363000000000000@g.us') console.log('Invite link: https://chat.whatsapp.com/' + code) }) ``` | Parameter | Type | Description | | --------- | ---- | ----------- | | `groupId` | `string` | Target group JID. | **Returns:** `string` — the raw invite code (not a full URL). ## `revokeInvite` ```typescript revokeInvite(groupId: string): Promise ``` Revokes the current invite link and generates a new one. The old invite code immediately becomes invalid. Returns the new code. Throws `ZaileysDomainError('OPERATION_FAILED')` if the operation fails. You must be a group admin. ```typescript client.on('connect', async () => { const newCode = await client.group.revokeInvite('120363000000000000@g.us') console.log('New invite link: https://chat.whatsapp.com/' + newCode) }) ``` | Parameter | Type | Description | | --------- | ---- | ----------- | | `groupId` | `string` | Target group JID. | **Returns:** `string` — the newly generated invite code. ## `acceptInvite` ```typescript acceptInvite(code: string): Promise ``` Joins a group using an invite code. The code is the raw fragment (not the full URL). Returns the JID of the group that was joined. Throws `ZaileysDomainError('OPERATION_FAILED')` if the invite is invalid or expired. Throttled by `operationGuard` under the `group.join` category (default 30 s between calls). Accepting many invites in rapid succession is a well-known WhatsApp ban signal. The `operationGuard` spaces calls 30 seconds apart by default, but you should also avoid scripting bulk joins altogether. See [Troubleshooting](/troubleshooting). ```typescript client.on('connect', async () => { const groupJid = await client.group.acceptInvite('AbCdEfGhIjKlMnOpQrSt12') console.log('Joined:', groupJid) }) ``` | Parameter | Type | Description | | --------- | ---- | ----------- | | `code` | `string` | The invite code (the part after `https://chat.whatsapp.com/`). | **Returns:** `string` — the JID of the group that was joined. ## `toggleEphemeral` ```typescript toggleEphemeral(groupId: string, seconds: number): Promise ``` Sets the disappearing-message timer for the group. Pass `0` to disable ephemeral messages. Common values are `86400` (24 hours), `604800` (7 days), and `7776000` (90 days). You must be a group admin. ```typescript client.on('connect', async () => { // Enable 7-day disappearing messages await client.group.toggleEphemeral('120363000000000000@g.us', 604800) // Disable disappearing messages await client.group.toggleEphemeral('120363000000000000@g.us', 0) }) ``` | Parameter | Type | Description | | --------- | ---- | ----------- | | `groupId` | `string` | Target group JID. | | `seconds` | `number` | Timer in seconds. `0` disables ephemeral messages. | ## `setting` ```typescript setting( groupId: string, setting: 'announcement' | 'not_announcement' | 'locked' | 'unlocked', ): Promise ``` Applies a group-level restriction. You must be a group admin. | Value | Effect | | ----- | ------ | | `'announcement'` | Only admins can send messages. | | `'not_announcement'` | All participants can send messages. | | `'locked'` | Only admins can edit the group subject, description, and icon. | | `'unlocked'` | All participants can edit group info. | ```typescript client.on('connect', async () => { const groupId = '120363000000000000@g.us' // Mute the group — only admins can send await client.group.setting(groupId, 'announcement') // Re-open for all participants await client.group.setting(groupId, 'not_announcement') // Lock editing to admins only await client.group.setting(groupId, 'locked') // Allow anyone to edit group info await client.group.setting(groupId, 'unlocked') }) ``` | Parameter | Type | Description | | --------- | ---- | ----------- | | `groupId` | `string` | Target group JID. | | `setting` | `'announcement' \| 'not_announcement' \| 'locked' \| 'unlocked'` | The restriction to apply. | ## GroupMetadata `create()` and `metadata()` return a `GroupMetadata` object (re-exported from the underlying transport layer). Key fields you will typically use: | Field | Type | Description | | ----- | ---- | ----------- | | `id` | `string` | The group JID (e.g. `120363000000000000@g.us`). | | `subject` | `string` | The group name. | | `desc` | `string \| undefined` | The group description. | | `owner` | `string \| undefined` | JID of the group creator/owner. | | `creation` | `number \| undefined` | Unix timestamp of group creation. | | `participants` | `GroupParticipant[]` | Full participant list with admin flags. | | `size` | `number \| undefined` | Number of participants. | Each `GroupParticipant`: | Field | Type | Description | | ----- | ---- | ----------- | | `id` | `string` | Participant JID. | | `admin` | `'admin' \| 'superadmin' \| null \| undefined` | Admin role, or absent if regular member. | ## ParticipantUpdateResult `addMember()`, `removeMember()`, `promote()`, and `demote()` all return `ParticipantUpdateResult[]`: ```typescript interface ParticipantUpdateResult { jid: string // the participant's JID status: string // WhatsApp status code for this individual update } ``` Iterate the result to detect per-member failures (e.g. a JID that does not have a WhatsApp account will have a non-success status code). ```typescript const results = await client.group.addMember(groupId, jids) const failed = results.filter((r) => r.status !== '200') if (failed.length > 0) { console.warn('Some members could not be added:', failed) } ``` ## Error handling All `client.group` methods throw `ZaileysDomainError` on failure. The relevant error codes are: | Code | When thrown | | ---- | ----------- | | `NOT_CONNECTED` | Any method called before the client is connected. | | `OPERATION_FAILED` | `inviteCode()`, `revokeInvite()`, or `acceptInvite()` when the operation cannot complete (e.g. invalid/expired invite, insufficient permissions). | ```typescript const client = new Client() client.on('connect', async () => { try { const code = await client.group.inviteCode('120363000000000000@g.us') console.log(code) } catch (err) { if (err instanceof ZaileysDomainError) { if (err.code === 'NOT_CONNECTED') { console.error('Client disconnected unexpectedly.') } else if (err.code === 'OPERATION_FAILED') { console.error('Could not retrieve invite code — are you a group admin?') } } } }) ``` See [Error Handling](/error-handling) for the full error taxonomy and how to distinguish domain errors from builder and automation errors. ## Complete example The example below creates a group, promotes a member to admin, locks editing to admins, and prints the invite link — demonstrating the common admin setup flow. ```typescript const client = new Client({ sessionId: 'default' }) client.on('connect', async () => { // 1. Create the group const group = await client.group.create('Announcements', [ '628111111111@s.whatsapp.net', '628222222222@s.whatsapp.net', ]) console.log('Group created:', group.id) // 2. Promote a co-admin await client.group.promote(group.id, ['628111111111@s.whatsapp.net']) // 3. Only admins can send messages await client.group.setting(group.id, 'announcement') // 4. Only admins can edit group info await client.group.setting(group.id, 'locked') // 5. Enable 7-day disappearing messages await client.group.toggleEphemeral(group.id, 604800) // 6. Grab the invite link const code = await client.group.inviteCode(group.id) console.log('Share this link: https://chat.whatsapp.com/' + code) // 7. Update description await client.group.updateDescription(group.id, 'Official announcement channel. Admins only.') }) ``` ## See also - [Client & Lifecycle](/client) — how to construct the client and connect. - [Configuration](/configuration#operationguard) — tuning or disabling the `operationGuard`. - [Error Handling](/error-handling) — `ZaileysDomainError` codes and catch patterns. - [Troubleshooting](/troubleshooting) — what to do if operations fail or the account gets flagged. - [Communities](/client#clientcommunity) — grouping multiple groups under a community umbrella. --- # Community `client.community` exposes the `CommunityModule`, which covers every operation you can perform on a WhatsApp Community from zaileys. A WhatsApp Community is essentially a group-of-groups: one parent entity (the community) links multiple regular group chats under it, giving members a shared announcement space and a directory of sub-groups. Community JIDs use the same `xxx@g.us` format as regular groups. ```typescript const client = new Client() client.on('connect', async () => { const community = await client.community.create('Devs Hub', 'Welcome to the hub') console.log('Community created:', community.id) }) ``` **NOT_CONNECTED guard.** Every `client.community` method calls `requireSocket()` internally. If the client is not yet connected, the call throws `ZaileysDomainError` with code `NOT_CONNECTED` and message `client not connected`. Always call community methods from inside a `connect` handler or after `await client.connect()`. See [Error Handling](/error-handling). ## Methods at a glance | Method | Returns | Description | | ------ | ------- | ----------- | | `create(subject, body)` | `Promise` | Create a new community. | | `createGroup(subject, participants, communityId)` | `Promise` | Create a sub-group directly inside a community. | | `linkGroup(communityId, groupId)` | `Promise` | Link an existing group into the community. | | `unlinkGroup(communityId, groupId)` | `Promise` | Unlink a group from the community. | | `subGroups(communityId)` | `Promise` | List the community's linked groups. | | `leave(communityId)` | `Promise` | Leave the community. | | `updateSubject(communityId, subject)` | `Promise` | Rename the community. | | `updateDescription(communityId, description?)` | `Promise` | Update (or clear) the community description. | | `inviteCode(communityId)` | `Promise` | Get the current invite code. | | `revokeInvite(communityId)` | `Promise` | Revoke and regenerate the invite code. | | `acceptInvite(code)` | `Promise` | Join a community via invite code. | **Ban-safety — `operationGuard` is active by default.** `create` and `createGroup` share the `community.create` category (minimum interval **120 seconds**). `acceptInvite` uses `community.join` (minimum interval **30 seconds**). The guard serialises concurrent calls in the same category and inserts the required wait between them so rapid community creation or bulk joins never fire faster than WhatsApp tolerates. Disabling or bypassing the guard increases the risk of a temporary account restriction. See [Configuration](/configuration#operationguard) and [Troubleshooting](/troubleshooting). ## `create` ```typescript create(subject: string, body: string): Promise ``` Creates a new community. `subject` becomes the community name; `body` is the announcement text shown to members when they join. Returns `GroupMetadata` — the full metadata object for the newly created community, including its `id` (a `xxx@g.us` JID). | Parameter | Type | Description | | --------- | ---- | ----------- | | `subject` | `string` | Community name. | | `body` | `string` | Announcement body shown on join. | ```typescript const community = await client.community.create( 'Devs Hub', 'A space for developers using zaileys.', ) console.log('Community id:', community.id) // e.g. "120363000000000001@g.us" ``` ## `createGroup` ```typescript createGroup(subject: string, participants: string[], communityId: string): Promise ``` Creates a new regular group and immediately links it into the specified community. Participants are added at creation time. Returns `GroupMetadata` for the new group. | Parameter | Type | Description | | --------- | ---- | ----------- | | `subject` | `string` | Group name. | | `participants` | `string[]` | Array of user JIDs (`628xxxxxxxxxx@s.whatsapp.net`) to add. | | `communityId` | `string` | Community JID (`xxx@g.us`). | ```typescript const communityId = '120363000000000001@g.us' const group = await client.community.createGroup( 'Backend Team', [ '628111111111@s.whatsapp.net', '628222222222@s.whatsapp.net', ], communityId, ) console.log('Sub-group created:', group.id) ``` ## `linkGroup` ```typescript linkGroup(communityId: string, groupId: string): Promise ``` Links an existing group into the community. The group must exist and the bot must have the necessary permissions in both the group and the community. | Parameter | Type | Description | | --------- | ---- | ----------- | | `communityId` | `string` | Community JID (`xxx@g.us`). | | `groupId` | `string` | Group JID to link (`xxx@g.us`). | ```typescript const communityId = '120363000000000001@g.us' const groupId = '120363000000000002@g.us' await client.community.linkGroup(communityId, groupId) console.log('Group linked.') ``` ## `unlinkGroup` ```typescript unlinkGroup(communityId: string, groupId: string): Promise ``` Removes a group from the community without deleting the group itself. The group continues to exist as a standalone group. | Parameter | Type | Description | | --------- | ---- | ----------- | | `communityId` | `string` | Community JID (`xxx@g.us`). | | `groupId` | `string` | Group JID to unlink (`xxx@g.us`). | ```typescript await client.community.unlinkGroup(communityId, groupId) console.log('Group unlinked.') ``` ## `subGroups` ```typescript subGroups(communityId: string): Promise ``` Returns the list of groups currently linked to the community. Each entry is a `LinkedGroup` object. | Parameter | Type | Description | | --------- | ---- | ----------- | | `communityId` | `string` | Community JID (`xxx@g.us`). | ### `LinkedGroup` shape | Field | Type | Description | | ----- | ---- | ----------- | | `id` | `string \| undefined` | Group JID. | | `subject` | `string` | Group name. | | `creation` | `number \| undefined` | Unix timestamp of group creation. | | `owner` | `string \| undefined` | JID of the group owner. | | `size` | `number \| undefined` | Number of members. | ```typescript const communityId = '120363000000000001@g.us' const groups = await client.community.subGroups(communityId) for (const g of groups) { console.log(g.subject, '—', g.size, 'members') } ``` ## `leave` ```typescript leave(communityId: string): Promise ``` Leaves the community. The bot's account is removed from the community and all its sub-groups that were joined through it. | Parameter | Type | Description | | --------- | ---- | ----------- | | `communityId` | `string` | Community JID (`xxx@g.us`). | ```typescript await client.community.leave('120363000000000001@g.us') ``` ## `updateSubject` ```typescript updateSubject(communityId: string, subject: string): Promise ``` Renames the community. Requires admin rights in the community. | Parameter | Type | Description | | --------- | ---- | ----------- | | `communityId` | `string` | Community JID (`xxx@g.us`). | | `subject` | `string` | New community name. | ```typescript await client.community.updateSubject('120363000000000001@g.us', 'Devs Hub v2') ``` ## `updateDescription` ```typescript updateDescription(communityId: string, description?: string): Promise ``` Updates the community description. Passing `undefined` (or calling with only one argument) clears the description. | Parameter | Type | Description | | --------- | ---- | ----------- | | `communityId` | `string` | Community JID (`xxx@g.us`). | | `description` | `string \| undefined` | New description. Omit or pass `undefined` to clear. | ```typescript // Set a new description await client.community.updateDescription( '120363000000000001@g.us', 'Open community for zaileys developers.', ) // Clear the description await client.community.updateDescription('120363000000000001@g.us') ``` ## `inviteCode` ```typescript inviteCode(communityId: string): Promise ``` Returns the current invite code string for the community, or `undefined` if none is available. Append the code to `https://chat.whatsapp.com/` to form a sharable link. | Parameter | Type | Description | | --------- | ---- | ----------- | | `communityId` | `string` | Community JID (`xxx@g.us`). | ```typescript const code = await client.community.inviteCode('120363000000000001@g.us') if (code) { console.log('Invite link: https://chat.whatsapp.com/' + code) } ``` ## `revokeInvite` ```typescript revokeInvite(communityId: string): Promise ``` Invalidates the current invite code and issues a new one. Returns the new code, or `undefined` on failure. Use this when the existing link has been shared too broadly. | Parameter | Type | Description | | --------- | ---- | ----------- | | `communityId` | `string` | Community JID (`xxx@g.us`). | ```typescript const newCode = await client.community.revokeInvite('120363000000000001@g.us') if (newCode) { console.log('New invite link: https://chat.whatsapp.com/' + newCode) } ``` ## `acceptInvite` ```typescript acceptInvite(code: string): Promise ``` Joins a community using an invite code. Returns the community JID on success, or `undefined`. `acceptInvite` is guarded by the `community.join` category with a **30-second** minimum interval. Joining multiple communities in quick succession will be automatically spaced by the `operationGuard`. See [Configuration](/configuration#operationguard). | Parameter | Type | Description | | --------- | ---- | ----------- | | `code` | `string` | The invite code (just the code portion, not the full URL). | ```typescript // Extract just the code from a full URL if needed const url = 'https://chat.whatsapp.com/AbCdEfGhIjKlMnOpQrStUv' const code = url.split('/').pop()! const communityId = await client.community.acceptInvite(code) console.log('Joined community:', communityId) ``` ## Complete example The following example creates a community, creates two sub-groups inside it, lists them, then updates the community name. ```typescript const client = new Client({ sessionId: 'community-bot' }) client.on('connect', async () => { // 1. Create the community const community = await client.community.create( 'Devs Hub', 'Central space for all zaileys developers.', ) console.log('Community:', community.id) // 2. Create sub-groups inside it const backend = await client.community.createGroup( 'Backend', ['628111111111@s.whatsapp.net'], community.id, ) const frontend = await client.community.createGroup( 'Frontend', ['628222222222@s.whatsapp.net'], community.id, ) console.log('Groups created:', backend.id, frontend.id) // 3. List linked groups const groups = await client.community.subGroups(community.id) console.log('Sub-groups:', groups.map((g) => g.subject)) // 4. Rename the community await client.community.updateSubject(community.id, 'Devs Hub v2') // 5. Get an invite link const code = await client.community.inviteCode(community.id) if (code) { console.log('Share:', 'https://chat.whatsapp.com/' + code) } }) ``` ## Error handling All `client.community` methods throw `ZaileysDomainError` on failure. The most common codes are: | Code | When it occurs | | ---- | -------------- | | `NOT_CONNECTED` | Method called before the client has connected. | ```typescript try { await client.community.create('Test', 'body') } catch (err) { if (err instanceof ZaileysDomainError && err.code === 'NOT_CONNECTED') { console.error('Connect the client first.') } else { throw err } } ``` See [Error Handling](/error-handling) for the full error hierarchy and all codes. ## See also - [Groups](/groups) — create and manage regular WhatsApp groups. - [Newsletter](/newsletter) — WhatsApp Channels (newsletters). - [Configuration](/configuration#operationguard) — tune or disable `operationGuard` intervals. - [Troubleshooting](/troubleshooting) — what to do when operations are being rate-limited. --- # Newsletter (Channels) `client.newsletter` is the domain namespace for **WhatsApp Channels** — the broadcast-only publishing surface built into WhatsApp. Every method proxies to the live socket and requires an active connection. ```typescript const client = new Client({ sessionId: 'default' }) client.on('connect', async () => { const channel = await client.newsletter.create('My Channel', { description: 'Updates and announcements', }) console.log('created', channel.id) }) ``` Newsletter JIDs look like `xxxxxxxxxxxxxxxxxx@newsletter` — they are distinct from user (`@s.whatsapp.net`), group (`@g.us`), and community JIDs. **NOT_CONNECTED guard.** Every `client.newsletter` method calls `requireSocket()` internally. If the client is not yet connected, the call throws `ZaileysDomainError` with code `NOT_CONNECTED`. Always call newsletter methods inside a `'connect'` handler or after `await client.connect()`. See [Error Handling](/error-handling). ## Methods at a glance | Method | Signature | Returns | Description | | ------ | --------- | ------- | ----------- | | `create` | `create(name, opts?)` | `Promise` | Create a new channel. | | `follow` | `follow(jid)` | `Promise` | Follow a channel. | | `unfollow` | `unfollow(jid)` | `Promise` | Unfollow a channel. | | `metadata` | `metadata(jid)` | `Promise` | Fetch channel metadata. | | `updateName` | `updateName(jid, name)` | `Promise` | Rename the channel. | | `updateDescription` | `updateDescription(jid, description)` | `Promise` | Update the channel description. | | `updatePicture` | `updatePicture(jid, picture)` | `Promise` | Update the channel picture. | | `mute` | `mute(jid)` | `Promise` | Mute channel notifications. | | `unmute` | `unmute(jid)` | `Promise` | Unmute channel notifications. | | `delete` | `delete(jid)` | `Promise` | Permanently delete the channel. | **Ban safety — operationGuard is ON by default.** Creating a channel and follow/unfollow operations are spaced by the built-in `operationGuard` to avoid the rapid-fire pattern WhatsApp flags as automated abuse: | Category | Default interval | | -------- | ---------------- | | `newsletter.create` | 120 s | | `newsletter.follow` | 2 s | | `newsletter.update` | 3 s | Calls queue behind each other per category and automatically wait until the interval has elapsed. You can tune or disable this via the `operationGuard` option when constructing the client. See [Configuration](/configuration#operationguard) and [Troubleshooting](/troubleshooting). ## `create` ```typescript create( name: string, opts?: { description?: string; picture?: Buffer }, ): Promise ``` Creates a new WhatsApp Channel with the given display name. Optionally sets a description and a cover picture in the same call. Returns `NewsletterMetadata` with at minimum the channel's `id` (a `@newsletter` JID) that you will use for subsequent calls. When `picture` is provided, `create` first creates the channel and then uploads the picture as a second step — both steps run inside the `newsletter.create` guard slot. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `name` | `string` | yes | Display name for the channel. | | `opts.description` | `string` | no | Short channel description shown on the info screen. | | `opts.picture` | `Buffer` | no | Channel cover image as a `Buffer`. | ```typescript const client = new Client() client.on('connect', async () => { const picture = readFileSync('./channel-cover.jpg') const channel = await client.newsletter.create('zaileys updates', { description: 'Release notes, tips, and announcements', picture, }) console.log('Channel JID:', channel.id) }) ``` ## `follow` ```typescript follow(jid: string): Promise ``` Follows the channel identified by `jid`. Subject to the `newsletter.follow` guard (2 s between calls). | Parameter | Type | Description | | --------- | ---- | ----------- | | `jid` | `string` | The `@newsletter` JID of the channel to follow. | ```typescript await client.newsletter.follow('xxxxxxxxxxxxxxxxxx@newsletter') ``` ## `unfollow` ```typescript unfollow(jid: string): Promise ``` Unfollows the channel identified by `jid`. Shares the same `newsletter.follow` guard slot as `follow` (2 s spacing between any follow/unfollow operations). | Parameter | Type | Description | | --------- | ---- | ----------- | | `jid` | `string` | The `@newsletter` JID of the channel to unfollow. | ```typescript await client.newsletter.unfollow('xxxxxxxxxxxxxxxxxx@newsletter') ``` ## `metadata` ```typescript metadata(jid: string): Promise ``` Fetches the metadata for the given channel. Throws `ZaileysDomainError` with code `NEWSLETTER_NOT_FOUND` if the channel does not exist or is not accessible. This method does **not** go through the operationGuard — it is a read-only lookup with no throttling. | Parameter | Type | Description | | --------- | ---- | ----------- | | `jid` | `string` | The `@newsletter` JID to look up. | ```typescript try { const info = await client.newsletter.metadata('xxxxxxxxxxxxxxxxxx@newsletter') console.log('Channel name:', info.name) console.log('Subscribers:', info.subscriberCount) } catch (err) { if (err instanceof ZaileysDomainError && err.code === 'NEWSLETTER_NOT_FOUND') { console.error('Channel not found or inaccessible.') } } ``` ## `updateName` ```typescript updateName(jid: string, name: string): Promise ``` Renames the channel. You must be the channel owner. | Parameter | Type | Description | | --------- | ---- | ----------- | | `jid` | `string` | The `@newsletter` JID of your channel. | | `name` | `string` | New display name. | ```typescript await client.newsletter.updateName('xxxxxxxxxxxxxxxxxx@newsletter', 'zaileys — official') ``` ## `updateDescription` ```typescript updateDescription(jid: string, description: string): Promise ``` Replaces the channel description. You must be the channel owner. | Parameter | Type | Description | | --------- | ---- | ----------- | | `jid` | `string` | The `@newsletter` JID of your channel. | | `description` | `string` | New description text. | ```typescript await client.newsletter.updateDescription( 'xxxxxxxxxxxxxxxxxx@newsletter', 'Now including weekly deep-dives and changelogs.', ) ``` ## `updatePicture` ```typescript updatePicture(jid: string, picture: Buffer): Promise ``` Replaces the channel cover picture. `picture` must be a `Buffer` containing the image data. You must be the channel owner. | Parameter | Type | Description | | --------- | ---- | ----------- | | `jid` | `string` | The `@newsletter` JID of your channel. | | `picture` | `Buffer` | New cover image as a `Buffer`. | ```typescript const newCover = readFileSync('./new-cover.png') await client.newsletter.updatePicture('xxxxxxxxxxxxxxxxxx@newsletter', newCover) ``` ## `mute` ```typescript mute(jid: string): Promise ``` Mutes push notifications from the channel without unfollowing it. | Parameter | Type | Description | | --------- | ---- | ----------- | | `jid` | `string` | The `@newsletter` JID to mute. | ```typescript await client.newsletter.mute('xxxxxxxxxxxxxxxxxx@newsletter') ``` ## `unmute` ```typescript unmute(jid: string): Promise ``` Re-enables push notifications for a previously muted channel. | Parameter | Type | Description | | --------- | ---- | ----------- | | `jid` | `string` | The `@newsletter` JID to unmute. | ```typescript await client.newsletter.unmute('xxxxxxxxxxxxxxxxxx@newsletter') ``` ## `delete` ```typescript delete(jid: string): Promise ``` Permanently deletes the channel. This action is irreversible. You must be the channel owner. | Parameter | Type | Description | | --------- | ---- | ----------- | | `jid` | `string` | The `@newsletter` JID of the channel to delete. | ```typescript await client.newsletter.delete('xxxxxxxxxxxxxxxxxx@newsletter') ``` `delete` is permanent. There is no confirmation step — the channel and all its published content are removed immediately. Make sure you are passing the correct JID before calling this. ## Error handling All `client.newsletter` methods throw `ZaileysDomainError` on failure. Import it from `zaileys` to handle errors precisely. ```typescript try { const info = await client.newsletter.metadata('xxxxxxxxxxxxxxxxxx@newsletter') console.log(info.name) } catch (err) { if (err instanceof ZaileysDomainError) { switch (err.code) { case 'NOT_CONNECTED': console.error('Client is not connected. Wait for the connect event.') break case 'NEWSLETTER_NOT_FOUND': console.error('Channel not found or no longer exists.') break default: throw err } } } ``` | Code | Raised by | When | | ---- | --------- | ---- | | `NOT_CONNECTED` | every method | Client socket is not open. | | `NEWSLETTER_NOT_FOUND` | `metadata` | The requested channel does not exist or is inaccessible. | See [Error Handling](/error-handling) for the full error taxonomy. ## Complete example ```typescript const client = new Client({ sessionId: 'default' }) client.on('connect', async () => { // Create a channel with a picture const picture = readFileSync('./cover.jpg') const channel = await client.newsletter.create('My Project Updates', { description: 'Changelogs and feature announcements', picture, }) console.log('Created:', channel.id) // Follow another channel await client.newsletter.follow('xxxxxxxxxxxxxxxxxx@newsletter') // Fetch and log metadata const info = await client.newsletter.metadata(channel.id) console.log('Subscribers:', info.subscriberCount) // Update details later await client.newsletter.updateName(channel.id, 'My Project — Updates') await client.newsletter.updateDescription(channel.id, 'Now with release notes!') // Mute and then unmute notifications on a followed channel await client.newsletter.mute('xxxxxxxxxxxxxxxxxx@newsletter') await client.newsletter.unmute('xxxxxxxxxxxxxxxxxx@newsletter') }) ``` ## See also - [Client & Lifecycle](/client) — `client.newsletter` namespace overview and other domain modules. - [Configuration](/configuration#operationguard) — tune or disable the operationGuard intervals. - [Error Handling](/error-handling) — full error taxonomy and `ZaileysDomainError`. - [Troubleshooting](/troubleshooting) — what to do when operations are throttled or accounts are flagged. --- # Privacy `client.privacy` is the zaileys namespace for WhatsApp privacy controls. It lets you read and write account-level privacy settings, manage your block list, and configure the default disappearing-message timer for new chats. ```typescript const client = new Client() client.on('connect', async () => { await client.privacy.set({ lastSeen: 'contacts', readReceipts: false }) }) ``` **NOT_CONNECTED guard.** Every `client.privacy` method calls an internal `requireSocket()`. If the client is not yet connected, the call throws a `ZaileysDomainError` with code `NOT_CONNECTED` and message `client not connected`. Always wait for the `'connect'` event (or `await client.connect()`) before using this namespace. See [Error Handling](/error-handling). ## Methods at a glance | Method | Signature | Returns | Description | | ------ | --------- | ------- | ----------- | | `set` | `set(config): Promise` | `void` | Update one or more privacy settings. Only provided keys are changed. | | `get` | `get(): Promise` | `{ [key: string]: string }` | Fetch the current privacy settings from WhatsApp. | | `block` | `block(jid): Promise` | `void` | Block a contact. | | `unblock` | `unblock(jid): Promise` | `void` | Unblock a contact. | | `blocklist` | `blocklist(): Promise` | `string[]` | Return the list of all blocked JIDs. | | `disappearingMode` | `disappearingMode(seconds): Promise` | `void` | Set the default disappearing-messages timer for **new** chats. | --- ## `set(config)` ```typescript set(config: PrivacyConfig & { readReceipts?: WAReadReceiptsValue | boolean }): Promise ``` Updates account privacy settings. Each key in `config` is independent — only keys that are present are sent to WhatsApp. You can update a single setting or several at once. ### Privacy fields | Field | Type | Allowed values | Description | | ----- | ---- | -------------- | ----------- | | `lastSeen` | `WAPrivacyValue` | `'all'` \| `'contacts'` \| `'contact_blacklist'` \| `'none'` | Who can see your Last Seen timestamp. | | `online` | `WAPrivacyOnlineValue` | `'all'` \| `'match_last_seen'` | Who can see when you are currently online. | | `profile` | `WAPrivacyValue` | `'all'` \| `'contacts'` \| `'contact_blacklist'` \| `'none'` | Who can see your profile picture. | | `status` | `WAPrivacyValue` | `'all'` \| `'contacts'` \| `'contact_blacklist'` \| `'none'` | Who can see your text status. | | `readReceipts` | `WAReadReceiptsValue \| boolean` | `'all'` \| `'none'` \| `true` \| `false` | Whether read receipts (blue ticks) are sent. `true` maps to `'all'`, `false` maps to `'none'`. | | `groupAdd` | `WAPrivacyGroupAddValue` | `'all'` \| `'contacts'` \| `'contact_blacklist'` | Who can add you to groups. | `readReceipts` accepts either the string union (`'all'` / `'none'`) or a plain boolean for convenience — `true` is equivalent to `'all'` and `false` is equivalent to `'none'`. `'contact_blacklist'` means the setting applies to everyone **except** contacts on your block list. It is available on `lastSeen`, `profile`, `status`, and `groupAdd`. ### Example ```typescript // Update several settings at once await client.privacy.set({ lastSeen: 'contacts', online: 'match_last_seen', profile: 'contacts', status: 'contacts', readReceipts: false, // boolean false → 'none' (blue ticks off) groupAdd: 'contacts', }) // Update a single setting await client.privacy.set({ readReceipts: true }) // turn blue ticks back on // Use the string form directly await client.privacy.set({ readReceipts: 'none', lastSeen: 'nobody' }) ``` `'nobody'` is **not** part of `WAPrivacyValue` in this version of the library. The correct value for hiding from everyone is `'none'`. Use the exact values from the table above. --- ## `get()` ```typescript get(): Promise ``` Fetches the current account privacy settings from WhatsApp. Returns a `PrivacySettings` object, which is a plain key-value map of `{ [key: string]: string }`. ### Example ```typescript const settings = await client.privacy.get() console.log(settings) // Example output: // { // lastSeen: 'contacts', // online: 'match_last_seen', // profile: 'contacts', // status: 'all', // readReceipts: 'all', // groupAdd: 'contacts', // } ``` The exact keys returned by `get()` mirror what WhatsApp reports for the logged-in account. Use `set()` with the matching field names to update them. --- ## `block(jid)` ```typescript block(jid: string): Promise ``` Blocks a contact. The `jid` must be a full WhatsApp JID in the format `628xxxxxxxxxx@s.whatsapp.net`. ### Example ```typescript await client.privacy.block('628123456789@s.whatsapp.net') console.log('Contact blocked') ``` --- ## `unblock(jid)` ```typescript unblock(jid: string): Promise ``` Unblocks a previously-blocked contact. ### Example ```typescript await client.privacy.unblock('628123456789@s.whatsapp.net') console.log('Contact unblocked') ``` --- ## `blocklist()` ```typescript blocklist(): Promise ``` Returns the full list of JIDs you have blocked. ### Example ```typescript const blocked = await client.privacy.blocklist() console.log('Blocked contacts:', blocked) // ['628111111111@s.whatsapp.net', '628222222222@s.whatsapp.net'] // Check if a specific contact is blocked const jid = '628123456789@s.whatsapp.net' if (blocked.includes(jid)) { console.log(`${jid} is currently blocked`) } ``` --- ## `disappearingMode(seconds)` ```typescript disappearingMode(seconds: number): Promise ``` Sets the default disappearing-messages timer for **new** chats. Existing chats are not affected. Common values are `86400` (24 hours), `604800` (7 days), and `7776000` (90 days). Pass `0` to disable disappearing messages for new chats. ### Example ```typescript // New chats will have messages disappear after 7 days await client.privacy.disappearingMode(604800) // Disable disappearing messages for new chats await client.privacy.disappearingMode(0) ``` To set a disappearing-message timer on a specific **group** (not account-wide), use [`client.group.toggleEphemeral(groupId, seconds)`](/client#clientgroup). This method only changes the account-level default applied to new individual chats. --- ## Error handling Every `client.privacy` method throws `ZaileysDomainError` on failure. The most common code is `NOT_CONNECTED` when you call a method before the client has connected. ```typescript const client = new Client({ autoConnect: false }) try { // Called before connect() — throws immediately await client.privacy.get() } catch (err) { if (err instanceof ZaileysDomainError) { console.error(err.code, err.message) // NOT_CONNECTED client not connected } } ``` Always use the `'connect'` event or `await client.connect()` before calling any privacy method: ```typescript client.on('connect', async () => { const settings = await client.privacy.get() console.log('Current privacy settings:', settings) }) ``` See [Error Handling](/error-handling) for the full `ZaileysDomainError` code reference. --- ## Complete example ```typescript const client = new Client({ sessionId: 'privacy-demo' }) client.on('connect', async () => { // Apply a baseline privacy configuration await client.privacy.set({ lastSeen: 'contacts', online: 'match_last_seen', profile: 'contacts', status: 'contacts', readReceipts: false, groupAdd: 'contacts', }) // Set new-chat default to 7-day disappearing messages await client.privacy.disappearingMode(604800) // Block a contact await client.privacy.block('628999999999@s.whatsapp.net') // Print the updated settings and block list const settings = await client.privacy.get() console.log('Privacy settings:', settings) const blocked = await client.privacy.blocklist() console.log('Blocked contacts:', blocked) }) ``` --- ## See also - [Client & Lifecycle](/client) — the `client.privacy` namespace in context, all domain namespaces. - [Error Handling](/error-handling) — `ZaileysDomainError` codes and catching patterns. - [Configuration](/configuration) — `ClientOptions` reference. --- # Storage Adapters Zaileys persists two completely independent pieces of state, and you can back each with a different adapter. Auth lives in an **`AuthStore`** (login credentials + Signal protocol keys) and chat data lives in a **`MessageStore`** (messages, chats, contacts, presence). They never share a backend unless you choose to, so you can keep your session in SQLite while streaming message history into Redis. ```typescript const client = new Client({ auth: new SqliteAuthStore({ database: './auth.db' }), store: new RedisMessageStore({ url: 'redis://localhost:6379' }), }) ``` If you omit both options, Zaileys uses safe defaults — see [Defaults](#defaults). For where `auth` and `store` sit among the other client options, see [Configuration](/configuration). ## The two store types | Concern | Interface | What it holds | When it is written | | ------- | --------- | ------------- | ------------------ | | Session | `AuthStore` (a.k.a. `AuthStoreBundle`) | `creds.json`-style credentials + Signal keys (pre-keys, sessions, sender keys, app-state sync keys, …) | On every login and key rotation, by Baileys | | History | `MessageStore` | Messages, chats, contacts, presence (and optionally scheduled jobs) | Continuously, via socket event listeners | ### AuthStore An `AuthStore` is a bundle of two sub-stores: ```typescript interface AuthStoreBundle { readonly creds: AuthCredsStore // readCreds / writeCreds / deleteCreds readonly signal: AuthStore // read / write / delete / clear / close } ``` You pass an instance to `Client({ auth })`. Zaileys wires it into Baileys' authentication state for you — you never call `read`/`write` yourself. On a logged-out disconnect (WhatsApp 401/410) Zaileys calls `clear()` so a stale session is wiped before the next login. ### MessageStore A `MessageStore` records the conversation as it streams in. After connecting, Zaileys calls `store.bind(socket)`, which subscribes the store to Baileys events (`messages.upsert`, `chats.upsert`, `contacts.upsert`, `presence.update`, and where supported `messages.update` / `chats.update`). From then on you can query it: ```typescript const store = new SqliteMessageStore({ database: './history.db' }) const client = new Client({ store }) client.on('connect', async () => { // newest-first, paginated chat history for one JID const recent = await store.listMessages('628123456789@s.whatsapp.net', { limit: 50 }) console.log(`loaded ${recent.length} messages`) const chats = await store.listChats({ archived: false }) const contacts = await store.listContacts() console.log(chats.length, 'chats,', contacts.length, 'contacts') }) ``` Read methods every `MessageStore` exposes: | Method | Returns | Notes | | ------ | ------- | ----- | | `getMessage(key)` | `WAMessage \| undefined` | `key` is a Baileys `WAMessageKey` | | `listMessages(jid, options?)` | `WAMessage[]` | Newest-first; `options.limit` (default `100`) and `options.before` (timestamp) for paging | | `getChat(jid)` / `listChats({ archived? })` | `Chat` / `Chat[]` | `archived` filter optional | | `getContact(jid)` / `listContacts()` | `Contact` / `Contact[]` | | | `getPresence(jid)` | `PresenceData \| undefined` | | JIDs follow WhatsApp conventions: individual chats are `628xxx@s.whatsapp.net` and groups are `xxx@g.us`. `listMessages('…@g.us', …)` works the same for group history. ## Defaults If you do not pass `auth` / `store`, Zaileys picks: | Option | Default adapter | Effect | | ------ | --------------- | ------ | | `auth` | `FileAuthStore({ basePath: './.zaileys/auth/' })` | Session survives restarts, scoped per `sessionId` | | `store` | `MemoryMessageStore()` | History kept in RAM only — lost on restart | ```typescript // auth → ./.zaileys/auth/, store → in-memory const client = new Client({ sessionId: 'main' }) ``` The default message store is **in-memory**: chat history (and any scheduled jobs) vanish when the process exits. Pick a persistent `store` adapter if you need durable history or restart-safe [scheduled broadcasts](/automation). ## Available adapters | Adapter | Auth store | Message store | Peer dependency | | ------- | ---------- | ------------- | --------------- | | File | `FileAuthStore` ⭐ | — | none | | Memory | `MemoryAuthStore` | `MemoryMessageStore` ⭐ | none | | SQLite | `SqliteAuthStore` | `SqliteMessageStore` | `better-sqlite3` | | Postgres | `PostgresAuthStore` | `PostgresMessageStore` | `pg` | | Redis | `RedisAuthStore` | `RedisMessageStore` | `redis` | | Convex | `ConvexAuthStore` | `ConvexMessageStore` | `convex` | ⭐ = default for that store type. There is **no** file-backed message store — pair `FileAuthStore` with one of the other message stores (or `MemoryMessageStore`) if you want messages persisted. Every non-built-in adapter loads its peer lazily. A missing peer throws a `ZaileysStoreError` with code `STORE_NOT_AVAILABLE` at first use (not at install/import time), so installs never break. Install peers like any other dependency — see [Installation](/installation). ## File (default auth) Stores creds and Signal keys as JSON files under `basePath`, using atomic temp-file writes and `BufferJSON` so binary keys round-trip byte-for-byte. No peer dependency. | Option | Type | Default | Description | | ------ | ---- | ------- | ----------- | | `basePath` | `string` | `'./.zaileys/auth'` | Directory for `creds.json` and `signal/` key files | ```typescript const client = new Client({ auth: new FileAuthStore({ basePath: './.sessions/bot-1' }), }) ``` `FileAuthStore` is an auth store only. There is no `FileMessageStore`; if you do not set `store`, message history stays in memory. ## Memory Everything lives in process memory and is gone on exit. Ideal for tests, scratch scripts, or ephemeral workers. No peer dependency, no constructor options. ```typescript const client = new Client({ auth: new MemoryAuthStore(), store: new MemoryMessageStore(), }) ``` `MemoryAuthStore` does not persist credentials — you re-scan the QR on every restart. Use it only when that is acceptable. ## SQLite Embedded single-file database via `better-sqlite3` (WAL mode, prepared statements). Both stores auto-create their tables on first use. Buffers are stored as `BLOB`s serialized with `BufferJSON`. **Peer dependency:** `better-sqlite3` ```bash npm i better-sqlite3 ``` ```bash pnpm add better-sqlite3 ``` ```bash yarn add better-sqlite3 ``` ```bash bun add better-sqlite3 ``` `SqliteAuthStore` and `SqliteMessageStore` share the same options: | Option | Type | Default | Description | | ------ | ---- | ------- | ----------- | | `database` | `string \| Buffer` | — (required) | Path to the `.db` file (or `:memory:`) | | `readonly` | `boolean` | `false` | Open the database read-only | ```typescript const client = new Client({ auth: new SqliteAuthStore({ database: './auth.db' }), store: new SqliteMessageStore({ database: './history.db' }), }) ``` You can point both stores at the **same** `database` file — their tables (`auth_creds`, `auth_signal`, `messages`, `chats`, `contacts`, `presence`) do not collide. ## Postgres Backed by `pg`. Tables (`zaileys_auth_creds`, `zaileys_auth_signal`, `zaileys_messages`, `zaileys_chats`, `zaileys_contacts`, `zaileys_presence`) are created automatically with `jsonb` payloads and the right indexes. **Peer dependency:** `pg` ```bash npm i pg ``` ```bash pnpm add pg ``` ```bash yarn add pg ``` ```bash bun add pg ``` Both `PostgresAuthStore` and `PostgresMessageStore` take: | Option | Type | Default | Description | | ------ | ---- | ------- | ----------- | | `connectionString` | `string` | — | Postgres URL; the adapter creates and owns the pool | | `pool` | `Pool` (from `pg`) | — | A pre-built pool you own and close yourself | | `max` | `number` | `pg` default | Max pool size (only when `connectionString` is used) | `connectionString` and `pool` are mutually exclusive — pass exactly one. Passing both, or neither, throws a `ZaileysStoreError` (`STORE_CONNECTION_FAILED`). ```typescript // Connection string — the adapter owns the pool lifecycle const conn = process.env.DATABASE_URL! const client = new Client({ auth: new PostgresAuthStore({ connectionString: conn, max: 5 }), store: new PostgresMessageStore({ connectionString: conn }), }) ``` ```typescript // Pre-built pool — you own and close it const pool = new Pool({ connectionString: process.env.DATABASE_URL }) const client = new Client({ auth: new PostgresAuthStore({ pool }) }) // when the store owns the pool it closes it on shutdown; a pool you pass in is yours to end() ``` ## Redis Backed by the `redis` client. Keys are namespaced (`:auth:*`, `:msg:*`, …) so multiple sessions can share one Redis instance. Messages use sorted sets for newest-first paging; presence entries get a short TTL. **Peer dependency:** `redis` ```bash npm i redis ``` ```bash pnpm add redis ``` ```bash yarn add redis ``` ```bash bun add redis ``` Both `RedisAuthStore` and `RedisMessageStore` take: | Option | Type | Default | Description | | ------ | ---- | ------- | ----------- | | `url` | `string` | — | Redis URL; the adapter creates, connects and owns the client | | `client` | `RedisClientType` | — | A pre-built, **already-connected** client you own | | `namespace` | `string` | `'zaileys'` | Key prefix; isolates sessions sharing one server | `url` and `client` are mutually exclusive — pass exactly one. If you pass a `client`, you must have already called `await client.connect()`; an unopened client throws `STORE_CONNECTION_FAILED`. ```typescript // URL — adapter manages the connection const client = new Client({ auth: new RedisAuthStore({ url: 'redis://localhost:6379', namespace: 'wa-auth' }), store: new RedisMessageStore({ url: 'redis://localhost:6379', namespace: 'wa-store' }), }) ``` ```typescript // Shared, pre-connected client const redis = createClient({ url: 'redis://localhost:6379' }) await redis.connect() const client = new Client({ auth: new RedisAuthStore({ client: redis, namespace: 'wa-auth' }), store: new RedisMessageStore({ client: redis, namespace: 'wa-store' }), }) ``` `clear()` only removes keys within the store's `namespace`. When auth and store share one Redis instance, give them **distinct namespaces** so wiping one (e.g. on logout) does not delete the other. ## Convex Convex is a hosted reactive backend reached through **deployed functions**, so unlike the other adapters it needs a one-time deploy step. Both stores talk to a single `zaileys_kv` table through the functions `zaileys:get|set|del|clear|list`. **Peer dependency:** `convex` ### Deploy the functions In your Convex project, merge the `zaileys_kv` table from [`examples/convex/schema.ts`](https://github.com/zeative/zaileys/blob/main/examples/convex/schema.ts) into your `convex/schema.ts`, copy [`examples/convex/zaileys.ts`](https://github.com/zeative/zaileys/blob/main/examples/convex/zaileys.ts) into your project as `convex/zaileys.ts`, then deploy: ```bash npx convex dev # or: npx convex deploy ``` ### Install the peer ```bash npm i convex ``` ```bash pnpm add convex ``` ```bash yarn add convex ``` ```bash bun add convex ``` ### Wire it into the Client ```typescript const url = process.env.CONVEX_URL // e.g. https://your-deployment.convex.cloud const client = new Client({ auth: new ConvexAuthStore({ url, namespace: 'wa-auth' }), store: new ConvexMessageStore({ url, namespace: 'wa-store' }), }) client.on('qr', ({ qrString }) => console.log('Scan QR:', qrString)) client.on('connect', ({ me }) => console.log('Connected as', me.id, '— session in Convex')) ``` Both `ConvexAuthStore` and `ConvexMessageStore` take: | Option | Type | Default | Description | | ------ | ---- | ------- | ----------- | | `url` | `string` | — | Convex deployment URL; the adapter builds a `ConvexHttpClient` | | `client` | `ConvexClientLike` | — | A pre-built Convex client (e.g. `ConvexHttpClient`) you own | | `namespace` | `string` | `'zaileys'` | Logical partition inside `zaileys_kv` | Pass a pre-built client instead of a `url` if you already have one: ```typescript const convex = new ConvexHttpClient(process.env.CONVEX_URL!) const auth = new ConvexAuthStore({ client: convex, namespace: 'wa-auth' }) const store = new ConvexMessageStore({ client: convex, namespace: 'wa-store' }) ``` Use **distinct `namespace` values for auth vs store** when they share one deployment — `clear()` wipes a whole namespace, and auth `clear()` runs on a 401/410 logout. The full deploy walkthrough lives in [`examples/convex/README.md`](https://github.com/zeative/zaileys/blob/main/examples/convex/README.md) and the runnable script in [`examples/convex-store.ts`](https://github.com/zeative/zaileys/blob/main/examples/convex-store.ts). ## Scheduled-job persistence [Scheduled broadcasts](/automation) (`client.schedule(...)`) store a `ScheduledJobRecord` so pending sends can be re-armed after a restart. This relies on the **message store** implementing the optional `saveScheduledJob` / `listScheduledJobs` / `deleteScheduledJob` methods. | Message store | Persists scheduled jobs? | | ------------- | ------------------------ | | `ConvexMessageStore` | ✅ Yes | | `MemoryMessageStore` | ❌ In-memory only | | `SqliteMessageStore` | ❌ In-memory only | | `PostgresMessageStore` | ❌ In-memory only | | `RedisMessageStore` | ❌ In-memory only | Only `ConvexMessageStore` implements the scheduled-job methods today. With every other message store, scheduled jobs are held in process memory and are **lost on restart** — the scheduler simply falls back to its in-memory map. If you need restart-safe schedules, use the Convex store. ## Mixing auth and store adapters Because the two stores are independent you can mix freely — there is no requirement that they share a backend. ```typescript // Session on disk, history in Redis const client = new Client({ auth: new FileAuthStore({ basePath: './.zaileys/auth/main' }), store: new RedisMessageStore({ url: 'redis://localhost:6379', namespace: 'wa' }), }) ``` ```typescript const pool = new Pool({ connectionString: process.env.DATABASE_URL }) const redis = createClient({ url: 'redis://localhost:6379' }) await redis.connect() // Durable auth in Postgres, fast history in Redis const client = new Client({ auth: new PostgresAuthStore({ pool }), store: new RedisMessageStore({ client: redis, namespace: 'wa-store' }), }) ``` ## Tips and gotchas - **Buffers round-trip safely.** Every adapter serializes with `BufferJSON`, so binary Signal keys and media references survive storage byte-for-byte. - **Errors are typed.** Failures throw `ZaileysStoreError` with a `code` such as `STORE_NOT_AVAILABLE` (missing peer), `STORE_CONNECTION_FAILED`, `STORE_READ_FAILED`, `STORE_WRITE_FAILED`, `STORE_CORRUPTED`, or `STORE_CLOSED`. See [Error handling](/error-handling). - **Owned vs. provided clients.** When you pass a `url` / `connectionString`, the adapter creates and closes the connection for you. When you pass a `client` / `pool`, the lifecycle is yours. - **`listMessages` paging.** It returns newest-first; pass `before` (a message timestamp) plus `limit` to walk backwards through history. - **Per-session scoping.** The default `FileAuthStore` path includes `sessionId`, so multiple clients in one process do not clash. With Redis/Convex use distinct `namespace` values to achieve the same. See also: [Configuration](/configuration) · [Broadcast & Schedule](/automation) · [Installation](/installation). --- # Error Handling Every failure in zaileys is a typed `Error` subclass with a stable `.code` string, so you can catch problems precisely instead of parsing message text. This page documents the full error class hierarchy, every code each class can carry, how to catch and narrow them, the client `error` event, and how to react to connection disconnects. ## The error model at a glance zaileys ships five concrete error classes. They are all plain `Error` subclasses (so a generic `catch (err)` still works), and each one adds two extra fields: - `code` — a stable string literal you switch on to react to a specific failure. - `cause` — the underlying error that triggered this one, when there is one (set via the standard `{ cause }` option). Always optional. Every class is exported from the package root, so you can import them directly: ```typescript import { Client, ZaileysBuilderError, ZaileysCommandError, ZaileysDomainError, ZaileysAutomationError, ZaileysStoreError, } from 'zaileys' ``` | Class | `name` | Thrown by | Typical trigger | | --- | --- | --- | --- | | `ZaileysBuilderError` | `'ZaileysBuilderError'` | The message builder (`client.send(...)`, `client.edit(...)`, content methods, album/broadcast send) | Invalid content options, media load failure, send rejected by the socket | | `ZaileysCommandError` | `'ZaileysCommandError'` | The command system (registry, middleware, dispatcher) | Duplicate command, bad command name, handler/middleware threw | | `ZaileysDomainError` | `'ZaileysDomainError'` | Domain modules (`client.group`, `client.privacy`, `client.newsletter`, `client.community`) | Client not connected, group/newsletter not found, operation failed | | `ZaileysAutomationError` | `'ZaileysAutomationError'` | Automation features (`client.broadcast`, `client.scheduleAt`, presence, rate limiter) | Not connected, invalid schedule/rate config, presence update failed | | `ZaileysStoreError` | `'ZaileysStoreError'` | Auth stores and message stores (file/sqlite/postgres/redis/convex adapters) | Store unavailable, connection/read/write failure, corrupted or closed store | All five classes have an identical shape: `code`, `message`, and optional `cause`. Once you know how to handle one, you know how to handle them all. ## Catching and narrowing The reliable way to react to a specific failure is `instanceof` to pick the class, then a `switch` on `.code` to pick the case. Because `code` is a string literal union in TypeScript, the editor autocompletes every valid value and warns on typos. ```typescript const client = new Client() client.on('connect', async () => { try { await client.send('6281234567890@s.whatsapp.net').text('Hello!') } catch (err) { if (err instanceof ZaileysBuilderError) { switch (err.code) { case 'EMPTY_CONTENT': console.error('Nothing to send — set content before awaiting.') break case 'INVALID_RECIPIENT': console.error('Bad JID:', err.message) break case 'SEND_FAILED': console.error('WhatsApp rejected the send:', err.cause) break default: console.error('Builder error:', err.code, err.message) } } else { throw err } } }) ``` ### Inspecting the cause chain When zaileys wraps a lower-level failure (a network error, a database driver error, a Baileys socket rejection), it preserves it on `.cause`. Read it for the real root reason. ```typescript try { await someStoreOperation() } catch (err) { if (err instanceof ZaileysStoreError) { console.error('Store failed:', err.code) console.error('Underlying cause:', err.cause) // e.g. the pg/redis driver error } } ``` `cause` is typed as `unknown` because the wrapped value can be anything. Narrow it (`err.cause instanceof Error`) before reading `.message` off it. ## ZaileysBuilderError Thrown while constructing or sending a message: content validation, media loading, and the actual socket send/relay. This is the error you will hit most often. | `code` | Meaning | | --- | --- | | `MEDIA_LOAD_FAILED` | A media source could not be fetched/read or converted — e.g. an image URL returned a non-2xx status, a local file path failed to read, or audio/sticker transcoding failed. | | `INVALID_RECIPIENT` | The target JID is not a valid WhatsApp recipient. | | `USERNAME_NOT_FOUND` | A `@username` could not be resolved to a JID. | | `EMPTY_CONTENT` | A content method was called with empty input, or you awaited the builder before setting any content (`text('')`, `poll()` with no question, `no content set`). | | `INVALID_OPTIONS` | Options failed validation — out-of-range poll/album/button/list counts, invalid latitude/longitude, bad vcard, invalid mentions JID, non-positive disappearing duration, missing `remoteJid`, unsupported interactive socket, or `client not connected`. | | `SEND_FAILED` | The socket accepted the request but rejected or returned no message key — `sendMessage`/`relayMessage` rejected, interactive media upload failed, album child/parent send failed. | | `MESSAGE_NOT_FOUND` | A message referenced for forwarding was not present in the store. | ```typescript const client = new Client() client.on('connect', async () => { try { await client .send('6281234567890@s.whatsapp.net') .poll('Lunch?', { options: ['Pizza'] }) // only one option → INVALID_OPTIONS } catch (err) { if (err instanceof ZaileysBuilderError && err.code === 'INVALID_OPTIONS') { console.error('Fix your poll:', err.message) } } }) ``` Note the guard at `client.send(...)`: if you call it before the client is connected, you get a `ZaileysBuilderError` with code `INVALID_OPTIONS` and message `"client not connected"`. See [Validating before you send](#validating-before-you-send). ## ZaileysCommandError Thrown by the [command system](/commands) — registration, middleware execution, and handler dispatch. | `code` | Meaning | | --- | --- | | `DUPLICATE_COMMAND` | A command key is registered more than once. | | `INVALID_COMMAND_NAME` | A command spec is empty or contains an empty segment. | | `HANDLER_ERROR` | A command handler threw; the original error is on `.cause`. | | `MIDDLEWARE_ERROR` | A middleware threw, or called `next()` more than once. | | `NO_SENT_MESSAGE` | `ctx.edit(...)` was used without a prior `ctx.reply(...)` to edit. | | `NOT_CONNECTED` | A command operation required an active socket but the client was not connected. | ```typescript try { await dispatchSomeCommand() } catch (err) { if (err instanceof ZaileysCommandError) { if (err.code === 'HANDLER_ERROR') { console.error('Command handler crashed:', err.cause) } else { console.error('Command system error:', err.code, err.message) } } } ``` ## ZaileysDomainError Thrown by the domain modules: `client.group`, `client.privacy`, `client.newsletter`, and `client.community`. | `code` | Meaning | | --- | --- | | `NOT_CONNECTED` | The module needs a live socket but the client is not connected. | | `GROUP_NOT_FOUND` | The referenced group does not exist or is not accessible. | | `NEWSLETTER_NOT_FOUND` | The referenced newsletter/channel could not be found. | | `INVALID_PARTICIPANT` | A participant JID was invalid for the operation. | | `OPERATION_FAILED` | The domain operation failed (e.g. invite code unavailable, invite acceptance failed). | ```typescript const client = new Client() client.on('connect', async () => { try { await client.group.acceptInvite('some-invite-code') } catch (err) { if (err instanceof ZaileysDomainError) { switch (err.code) { case 'NOT_CONNECTED': console.error('Wait for the connect event first.') break case 'OPERATION_FAILED': console.error('Invite could not be accepted:', err.message) break default: console.error('Domain error:', err.code) } } } }) ``` ## ZaileysAutomationError Thrown by automation helpers: `client.broadcast`, `client.scheduleAt`, presence updates, and the rate limiter. | `code` | Meaning | | --- | --- | | `NOT_CONNECTED` | Presence/automation needs a live socket but the client is not connected. | | `RATE_LIMIT_INVALID` | A rate-limiter value is invalid — `perSec`, `perJidPerSec`, or `burst` must be greater than zero. | | `TASK_FAILED` | A scheduled/automation task failed during execution. | | `SCHEDULE_INVALID` | `scheduleAt` got a non-`Date`, the scheduled builder threw, or it produced no content. | | `STORE_UNAVAILABLE` | A store required by an automation feature was unavailable. | | `PRESENCE_FAILED` | A presence update (`typing`, `recording`, etc.) failed; the cause is on `.cause`. | ```typescript const client = new Client() client.on('connect', async () => { try { await client.scheduleAt( new Date(Date.now() + 60_000), (b) => b.text('Reminder: standup in 1 minute.'), ) } catch (err) { if (err instanceof ZaileysAutomationError && err.code === 'SCHEDULE_INVALID') { console.error('Bad schedule:', err.message) } } }) ``` ## ZaileysStoreError Thrown by [storage adapters](/storage) — both the auth store (session credentials) and the message store, across the file, sqlite, postgres, redis, and convex backends. | `code` | Meaning | | --- | --- | | `STORE_NOT_AVAILABLE` | A required peer dependency or backend is missing (e.g. the `convex` package is not installed, `pg.Pool` constructor not found). | | `STORE_CONNECTION_FAILED` | Could not connect to or initialize the backend — bad/conflicting config, failed schema migration, module load failure. | | `STORE_WRITE_FAILED` | A write/delete/serialize operation against the store failed. | | `STORE_READ_FAILED` | A read operation against the store failed. | | `STORE_CORRUPTED` | Stored data could not be parsed (e.g. a corrupted sqlite blob). | | `STORE_CLOSED` | An operation was attempted after the store was closed. | ```typescript try { await startClientWithDatabaseStore() } catch (err) { if (err instanceof ZaileysStoreError) { switch (err.code) { case 'STORE_NOT_AVAILABLE': console.error('Missing dependency:', err.message) // e.g. "Run: pnpm add convex" break case 'STORE_CONNECTION_FAILED': console.error('Cannot reach the database:', err.cause) break case 'STORE_CORRUPTED': console.error('Session data is corrupt — clear it and re-authenticate.') break default: console.error('Store error:', err.code) } } } ``` `STORE_NOT_AVAILABLE` for optional backends (convex, postgres `pg`, redis) means the peer dependency is not installed. Install it before using that adapter — see [Storage Adapters](/storage). ## The `error` event Failures that happen outside an `await` you control — most importantly auto-connect failures — surface through the client's `error` event instead of a thrown exception. The payload is `{ sessionId: string; error: Error }`. ```typescript const client = new Client() client.on('error', ({ sessionId, error }) => { console.error(`[${sessionId}] background error:`, error.message) }) ``` The auto-connect path only emits `error` if there is at least one `error` listener attached. If you rely on `autoConnect` (the default), register an `error` listener early so background connection failures are not swallowed. See [Client](/client). ## Connection disconnects When the underlying connection drops, the client emits a `disconnect` event. The payload tells you both the normalized `reason` and whether zaileys will automatically reconnect: ```typescript disconnect: { sessionId: string; reason: DisconnectReasonDomain; willReconnect: boolean } ``` `reason` is one of these normalized values (mapped from the raw Baileys disconnect codes): | `reason` | Fatal? | Auto-reconnect | Meaning | | --- | --- | --- | --- | | `logged-out` | Yes | No | The session was logged out from the phone. Auth is cleared; you must re-authenticate. | | `connection-replaced` | Yes | No | Another session replaced this one (same account opened elsewhere). Auth is cleared. | | `forbidden` | Yes | No | The account is blocked/forbidden by WhatsApp. Auth is cleared. | | `restart-required` | No | Yes | WhatsApp asked for a restart of the connection. | | `bad-session` | No | Yes | The session data was bad; auth is cleared but a reconnect is attempted. | | `connection-closed` | No | Yes | The connection was closed; reconnect is attempted. | | `connection-lost` | No | Yes | The connection was lost (network); reconnect is attempted. | | `multi-device-mismatch` | No | Yes | A multi-device mismatch occurred; reconnect is attempted. | | `unavailable-service` | No | Yes | The service was temporarily unavailable; reconnect is attempted. | | `unknown` | No | Yes | The disconnect code was not recognized; reconnect is attempted. | A reason is **fatal** when it is `logged-out`, `connection-replaced`, or `forbidden`. Fatal disconnects stop the reconnect loop (`willReconnect` will be `false`); everything else is retried automatically. ```typescript const client = new Client() client.on('disconnect', ({ reason, willReconnect }) => { if (!willReconnect) { // Fatal: logged-out / connection-replaced / forbidden console.error(`Connection ended permanently (${reason}). Re-authentication required.`) // Surface a fresh QR/pairing flow, alert an operator, etc. return } console.warn(`Disconnected (${reason}); reconnecting automatically...`) }) client.on('reconnecting', ({ attempt, delayMs, reason }) => { console.log(`Reconnect attempt ${attempt} in ${delayMs}ms (reason: ${reason})`) }) ``` The `bad-session`, `connection-replaced`, `forbidden`, and `logged-out` reasons clear stored auth credentials. Of those, only `bad-session` triggers a reconnect — the other three are fatal. For the full lifecycle, see [Events](/events). ## Best-practice patterns ### Validating before you send The cheapest error is the one you never trigger. Guard against the two most common preventable failures — sending before connect, and empty/invalid content — at the call site. ```typescript const client = new Client() async function safeSend(jid: string, text: string) { if (client.state !== 'connected') { // Avoids INVALID_OPTIONS "client not connected" throw new Error('Not connected yet — wait for the connect event.') } if (!text.trim()) { // Avoids EMPTY_CONTENT return } try { return await client.send(jid).text(text) } catch (err) { if (err instanceof ZaileysBuilderError && err.code === 'SEND_FAILED') { console.error('Delivery failed for', jid, '-', err.cause) return } throw err } } ``` ### Handling send failures without crashing A single failed send should never take down a long-running bot. Wrap each outbound send and decide locally whether to retry, skip, or alert. ```typescript const client = new Client() async function sendWithRetry(jid: string, text: string, attempts = 3) { for (let i = 1; i <= attempts; i++) { try { return await client.send(jid).text(text) } catch (err) { const isTransient = err instanceof ZaileysBuilderError && err.code === 'SEND_FAILED' if (!isTransient || i === attempts) throw err await new Promise((r) => setTimeout(r, 500 * i)) } } } ``` ### Per-recipient broadcast errors `client.broadcast(...)` never rejects on a single bad recipient. Instead it resolves to a `BroadcastResult` that partitions outcomes — `sent` is an array of JIDs that succeeded, `failed` is an array of `{ jid, error }` for the ones that did not. Always inspect `failed`. ```typescript const client = new Client() const recipients = [ '6281111111111@s.whatsapp.net', '6282222222222@s.whatsapp.net', '6283333333333@s.whatsapp.net', ] client.on('connect', async () => { const result = await client.broadcast( recipients, (b) => b.text('Scheduled maintenance tonight at 22:00.'), { rateLimitPerSec: 5, onProgress: (done, total, jid, ok) => { console.log(`[${done}/${total}] ${jid} ${ok ? 'sent' : 'failed'}`) }, }, ) console.log(`Sent ${result.sent.length}, failed ${result.failed.length}`) for (const { jid, error } of result.failed) { console.error(`Could not reach ${jid}:`, error.message) } }) ``` `onProgress` fires once per recipient with `(done, total, jid, ok)`, so you can stream progress live and still get the full partitioned result at the end. ### A single top-level handler For everything else, a small helper that narrows across all five classes keeps your call sites clean. ```typescript import { ZaileysBuilderError, ZaileysCommandError, ZaileysDomainError, ZaileysAutomationError, ZaileysStoreError, } from 'zaileys' function describeError(err: unknown): string { if ( err instanceof ZaileysBuilderError || err instanceof ZaileysCommandError || err instanceof ZaileysDomainError || err instanceof ZaileysAutomationError || err instanceof ZaileysStoreError ) { return `${err.name} [${err.code}]: ${err.message}` } return err instanceof Error ? err.message : String(err) } ``` ### Never let `error` events go unobserved ```typescript const client = new Client() // Register before connecting so auto-connect failures are reported. client.on('error', ({ error }) => { console.error('Client error:', error.message) }) ``` On Node.js, an `EventEmitter` that emits `error` with no listener throws. zaileys guards the auto-connect path by only emitting `error` when a listener exists — but you should still attach one so failures are visible rather than silent. ## Quick reference | Class | Codes | | --- | --- | | `ZaileysBuilderError` | `MEDIA_LOAD_FAILED`, `INVALID_RECIPIENT`, `USERNAME_NOT_FOUND`, `EMPTY_CONTENT`, `INVALID_OPTIONS`, `SEND_FAILED`, `MESSAGE_NOT_FOUND` | | `ZaileysCommandError` | `DUPLICATE_COMMAND`, `INVALID_COMMAND_NAME`, `HANDLER_ERROR`, `MIDDLEWARE_ERROR`, `NO_SENT_MESSAGE`, `NOT_CONNECTED` | | `ZaileysDomainError` | `NOT_CONNECTED`, `GROUP_NOT_FOUND`, `NEWSLETTER_NOT_FOUND`, `INVALID_PARTICIPANT`, `OPERATION_FAILED` | | `ZaileysAutomationError` | `NOT_CONNECTED`, `RATE_LIMIT_INVALID`, `TASK_FAILED`, `SCHEDULE_INVALID`, `STORE_UNAVAILABLE`, `PRESENCE_FAILED` | | `ZaileysStoreError` | `STORE_NOT_AVAILABLE`, `STORE_CONNECTION_FAILED`, `STORE_WRITE_FAILED`, `STORE_READ_FAILED`, `STORE_CORRUPTED`, `STORE_CLOSED` | ## Related - [Client](/client) — `connect`, `autoConnect`, lifecycle, and the `error`/`disconnect` events. - [Sending Messages](/sending-messages) — the builder API that produces `ZaileysBuilderError`. - [Troubleshooting](/troubleshooting) — diagnosing common real-world failures. --- # Runtime Support Zaileys ships **dual ESM and CommonJS** entry points (`dist/index.mjs` + `dist/index.cjs`) with matching type declarations (`dist/index.d.ts` + `dist/index.d.cts`). The same package runs on Node.js, Bun, Deno, and Termux (Android) — both `import` and `require('zaileys')` resolve correctly through the `exports` map. ```typescript ``` ```javascript const { Client } = require('zaileys') // CommonJS ``` ## Compatibility matrix | Runtime | Minimum | ESM (`import`) | CJS (`require`) | Notes | | ------- | ------- | -------------- | --------------- | ----- | | Node.js | `>=20.0.0` | ✅ | ✅ | Primary target (`engines.node`) | | Bun | latest | ✅ | ✅ | Runs both bundles + TypeScript directly | | Deno | latest | ✅ | ✅ | Needs `--node-modules-dir` for npm deps | | Termux (Android) | Node 20+ | ✅ | ✅ | Install with `--legacy-peer-deps` (skips native `sharp`) | The `exports` map in `package.json` resolves `types` → `import` → `require` automatically. TypeScript users should set `"module": "NodeNext"` (or `"Node16"` / `"Bundler"`) so the compiler picks the right declaration file. See [Installation](/installation) for the full setup. ## ESM vs CommonJS The package is `"type": "module"` with a fully specified `exports` field: ```json { "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.cjs" } } } ``` That means both styles are first-class — pick whichever matches your project: ```typescript const client = new Client({ authType: 'pairing', phoneNumber: 628000000000, }) client.on('connection', ({ status }) => console.log(status)) await client.connect() ``` ```javascript const { Client } = require('zaileys') const client = new Client({ authType: 'pairing', phoneNumber: 628000000000, }) client.on('connection', ({ status }) => console.log(status)) client.connect() ``` Internally the published bundle rewrites all Node built-ins to the `node:` protocol (`node:fs/promises`, `node:path`, `node:child_process`, …). This is what lets the same bundle load on stricter runtimes like Deno without extra configuration. ## Node.js Node.js is the primary target. Any release `>=20.0.0` works; LTS (20/22/24) is recommended. ### Install ```bash npm install zaileys ``` ```bash pnpm add zaileys ``` ```bash yarn add zaileys ``` ### Run ```bash # Compile then run node dist/bot.js # Or run TypeScript directly with tsx (no build step) npx tsx bot.ts ``` Node 18 and below are not supported. The library uses modern APIs (top-level `await` in examples, global `fetch`) and is published targeting ES2022. Upgrade to Node 20+ if you see syntax or resolution errors. ## Bun Bun runs both the ESM and CJS bundles directly and executes TypeScript natively, so there is no separate build step. ### Install ```bash bun add zaileys ``` ### Run ```bash # Bun runs .ts files directly bun run bot.ts ``` ```typescript const client = new Client({ authType: 'qr' }) await client.connect() ``` Bun may print WebSocket upgrade-event warnings (for example about the `ws` `upgrade` listener) while the WhatsApp socket connects. These come from Bun's `ws` compatibility layer and are harmless — the connection still establishes normally. If they are noisy, lower the [logger level](/configuration) or filter Bun's diagnostics. Native peer dependencies (`better-sqlite3`, `pg`, `redis`) install fine under Bun. Bun runs their prebuilt binaries when available; if a package has no Bun-compatible prebuild it falls back to a source build, which needs the platform build tools described under Termux below. ## Deno Deno can run Zaileys through the `npm:` specifier or from a local `node_modules` directory. Because the bundle uses the `node:` protocol for every built-in, no shimming is required — but npm dependencies must be materialized to disk. ### Install dependencies to disk ```bash deno cache --node-modules-dir npm:zaileys ``` ### Run with a node_modules directory ```bash deno run --node-modules-dir --allow-all bot.ts ``` ```typescript const client = new Client({ authType: 'qr' }) await client.connect() ``` The `--node-modules-dir` flag is **required**. Several dependencies (Baileys, the storage adapters, the bundled ffmpeg/ffprobe binaries) expect a real `node_modules` layout and the ability to spawn child processes, which Deno's virtual npm cache does not provide. Without it, media conversion and the native storage adapters will fail to resolve. Grant the permissions Zaileys needs: file access for the auth/session store (`--allow-read`, `--allow-write`), network for the WhatsApp socket (`--allow-net`), running ffmpeg/ffprobe (`--allow-run`), and environment access (`--allow-env`). `--allow-all` covers all of them while prototyping. ## Termux (Android) Termux installs Zaileys as a normal Node project. The recommended install skips the native `sharp` accelerator — Baileys declares `sharp` as a peer dependency, so a plain `npm install` tries to compile it from source and **fails on Android** (no prebuilt ARM binary, missing `libvips`/`node-addon-api`). Zaileys never needs `sharp`: image processing falls back to the bundled pure-JS `jimp` path automatically. ### Install Node and ffmpeg ```bash pkg update && pkg upgrade pkg install nodejs-lts ffmpeg ``` ### Install Zaileys (skips the native sharp peer) ```bash npm install zaileys --legacy-peer-deps ``` `--legacy-peer-deps` stops npm from auto-installing Baileys' optional `sharp` peer, so the install never touches `node-gyp`. Everything Zaileys needs (`baileys`, `jimp`, `audio-decode`, the bundled ffmpeg binaries) installs normally. ### Run ```bash npx tsx bot.ts # or: node dist/bot.js ``` Plain `npm install zaileys` on Termux fails with `sharp ... command failed ... node install/build.js`. That is **not** a Zaileys bug — it is Baileys' `sharp` peer trying to build from source. Use `--legacy-peer-deps` (above) to skip it, or install the build toolchain (`pkg install python make clang`) first if you genuinely want the native `sharp` accelerator. Zaileys bundles `ffmpeg` and `ffprobe` binaries via `@ffmpeg-installer/ffmpeg` / `@ffprobe-installer/ffprobe`. When no Android/ARM prebuilt binary exists, it falls back to an `ffmpeg` found on `PATH` — that is why `pkg install ffmpeg` is in the setup above. Media features (stickers, voice notes, video thumbnails) depend on ffmpeg being reachable one way or the other. Native add-ons such as `better-sqlite3` and `sharp` compile from source on Termux because prebuilt binaries rarely ship for Android. If you skip `sharp` with `--legacy-peer-deps` (above) you get a fully working bot on the pure-JS `jimp` path. Only install `python make clang` if you specifically need a native add-on — e.g. the SQLite storage adapter; otherwise prefer the in-memory or file storage adapter (see [Storage Adapters](/storage)) and the bundled `jimp` fallback. ## Native dependencies per runtime Zaileys keeps heavy native modules **optional**. They are declared as optional peer dependencies and loaded lazily, so the core library installs and runs without any compiler. | Dependency | Used for | Required? | Fallback | | ---------- | -------- | --------- | -------- | | `better-sqlite3` | SQLite auth/message storage adapter | Optional peer | Use file / memory / Postgres / Redis adapters | | `pg` | Postgres storage adapter | Optional peer | Use another adapter | | `redis` | Redis storage adapter | Optional peer | Use another adapter | | `convex` | Convex storage adapter | Optional peer | Use another adapter | | `sharp` | Fast image/sticker processing | Not declared — opportunistic | Bundled `jimp` (pure JS) | | ffmpeg / ffprobe | Audio/video/sticker conversion | Bundled binaries | `ffmpeg` on `PATH` | ### sharp (image acceleration) `sharp` is **not** a declared dependency of Zaileys. Zaileys probes for it at runtime using a hybrid loader (`require('sharp')` first, then dynamic `import('sharp')`), so it works in both ESM and CJS bundles. If `sharp` is absent or fails to load, image and sticker processing automatically fall back to the bundled pure-JS `jimp` path. Install it only when you want the faster native path: ```bash npm i sharp ``` `sharp` *is* pulled in transitively as a peer dependency of **Baileys**, and npm auto-installs it on a normal `npm install`. On platforms with no prebuilt `sharp` binary (notably **Termux/Android**, and some Alpine/musl setups) that triggers a source build that can fail. Install with `npm install zaileys --legacy-peer-deps` to skip it and run on the `jimp` fallback. On Bun and Node with a matching prebuilt, `sharp` installs without a compiler. On Termux it compiles from source — if that is impractical, simply omit it and rely on the `jimp` fallback. ### better-sqlite3 and other storage adapters Storage adapters are wired through optional peer dependencies. Install only the driver for the backend you actually use: ```bash npm i better-sqlite3 # SQLite npm i pg # Postgres npm i redis # Redis ``` ```bash pnpm add better-sqlite3 pnpm add pg pnpm add redis ``` ```bash yarn add better-sqlite3 yarn add pg yarn add redis ``` ```bash bun add better-sqlite3 bun add pg bun add redis ``` With pnpm, `better-sqlite3` is listed under `onlyBuiltDependencies` so its native build runs during install. If you skip these drivers, the file and in-memory adapters keep Zaileys fully functional — see [Storage Adapters](/storage) for the full list and configuration. ## Quick reference - **Node.js** — `>=20.0.0`; the primary, fully supported runtime. - **Bun** — `bun add zaileys`, runs `.ts` directly; ignore harmless `ws` upgrade warnings. - **Deno** — `import 'npm:zaileys'`, always run with `--node-modules-dir` plus the right permissions. - **Termux** — install with `npm install zaileys --legacy-peer-deps` (skips Baileys' native `sharp` peer that fails to compile on Android); `pkg install nodejs-lts ffmpeg`; prefer file/memory storage and the `jimp` fallback. For platform-agnostic install steps see [Installation](/installation), for picking a session backend see [Storage Adapters](/storage), and for runtime-specific failures see [Troubleshooting](/troubleshooting). --- # Troubleshooting & FAQ Answers to the problems you are most likely to hit when running a `zaileys` bot — invalid sessions, reconnect loops, pairing failures, missing optional dependencies, and runtime quirks. Every fix below is taken directly from the messages the library actually prints. For broader background see [Error Handling](/error-handling), [Installation](/installation), [Getting Started](/getting-started), and [Runtime Support](/runtimes). ## Connection & session ### QR keeps regenerating / "session looks invalid or corrupted" If the connection authenticates and then immediately drops in a loop — and the QR keeps redrawing — your saved credentials are corrupted. `zaileys` detects this and prints a hint inside the reconnect log: ```text [zaileys] Connection lost (bad-session). Reconnecting in 1.0s (attempt 1)... [zaileys] The saved session looks invalid or corrupted (connection keeps closing before it authenticates). Delete the auth folder (default: ./.zaileys) and run again to scan a fresh QR / request a new pairing code. ``` The fix is exactly what the message says — delete the auth folder and start over. The default file auth store writes to **`./.zaileys/auth/`** (the `sessionId` is `default` unless you set one). Deleting the whole `./.zaileys` folder is the simplest reset and forces a fresh QR / pairing code on the next run. ```bash rm -rf ./.zaileys # or just one session: rm -rf ./.zaileys/auth/default ``` ```powershell Remove-Item -Recurse -Force .\.zaileys # or just one session: Remove-Item -Recurse -Force .\.zaileys\auth\default ``` If you set a custom `sessionId`, delete that subfolder instead: ```typescript const client = new Client({ sessionId: 'mybot' }) // auth lives at ./.zaileys/auth/mybot ``` ### Connection keeps closing / reconnect loop `zaileys` reconnects automatically with exponential backoff and jitter. A healthy reconnect log looks like this and is **not** an error — the library is recovering on its own: ```text [zaileys] Connection lost (connection-closed). Reconnecting in 1.0s (attempt 1)... [zaileys] Connection lost (connection-lost). Reconnecting in 2.1s (attempt 2)... ``` The disconnect reason in the parentheses tells you whether reconnecting will help. These are the reasons `zaileys` reports: | Reason | Reconnects automatically? | What it means | | --- | --- | --- | | `connection-closed` | Yes | Socket closed; transient. | | `connection-lost` | Yes | Network dropped; transient. | | `restart-required` | Yes | WhatsApp asked for a fresh socket. | | `unavailable-service` | Yes | WhatsApp service temporarily unavailable. | | `multi-device-mismatch` | Yes | Device list out of sync. | | `rate-limited` | Yes (long fixed backoff) | WhatsApp returned HTTP 429. Non-fatal, but zaileys waits `rateLimitedDelayMs` (default 5 min) instead of the exponential ladder. See [Account restricted / banned](#account-restricted--banned-your-account-is-restricted-right-now). | | `bad-session` | Yes (but creds are suspect) | Triggers the "invalid/corrupted" hint above. | | `logged-out` | No (fatal) | You unlinked the device — re-authenticate. | | `connection-replaced` | No (fatal) | Another session took over this account. | | `forbidden` | No (fatal) | Account/number blocked from connecting. | | `unknown` | Yes | Unmapped close code. | `logged-out`, `connection-replaced`, and `forbidden` are fatal — the library stops retrying and emits a final `disconnect` with `willReconnect: false`. For everything else it backs off and retries. You can observe and tune this. Defaults: `enabled: true`, `maxAttempts: Infinity`, `initialDelayMs: 3000`, `maxDelayMs: 60000`, `jitterFactor: 0.2`, `rateLimitedDelayMs: 300000`. ```typescript const client = new Client({ reconnect: { enabled: true, maxAttempts: 10, initialDelayMs: 1000, maxDelayMs: 30_000, jitterFactor: 0.2, }, }) client.on('reconnecting', ({ attempt, delayMs, reason }) => { console.log(`retry #${attempt} in ${delayMs}ms (${reason})`) }) client.on('disconnect', ({ reason, willReconnect }) => { if (!willReconnect) console.error(`fatal disconnect: ${reason}`) }) ``` If you are stuck in a `bad-session` loop, no amount of retrying will help — delete the auth folder (see the previous question). If the reason is `connection-replaced`, you have the same account linked elsewhere; only one socket can hold the session at a time. ### Pairing code rejected / "phoneNumber must be E.164" Pairing-code auth requires a valid phone number in **E.164** format (digits with country code, no `+`, spaces, dashes, or parentheses). `zaileys` strips those characters for you, but validates the result is 8–15 digits. Bad input throws before a code is even requested: ```text Error: phoneNumber is required when authType is "pairing" Error: phoneNumber must be E.164 with country code ``` Pass the number with its country code: ```typescript const client = new Client({ authType: 'pairing', phoneNumber: '628123456789', // Indonesia: 62 + number, no leading 0 }) client.on('pairing-code', ({ code }) => { console.log('Enter this in WhatsApp > Linked devices > Link with phone number:') console.log(code) }) ``` When the code is generated you will see: ```text [zaileys] Pairing code: ABCD-1234 — enter it in WhatsApp > Linked devices > Link with phone number. ``` If WhatsApp rejects the request itself (e.g. number not on WhatsApp, rate-limited), the error is wrapped: ```text Error: failed to request pairing code: ``` Common mistakes: leaving the local leading `0` (write `62812...`, not `0812...`), using a number not registered on WhatsApp, or requesting too many codes in a short window. Wait a few minutes and try again, and double-check the digits. ### Account restricted / banned ("Your account is restricted right now") If WhatsApp shows *"Your account is restricted right now"* (or you keep getting HTTP 429 / `rate-limited` disconnects), the account has been throttled or temporarily banned. The two things that trigger this: 1. **Spamming pairing codes / QR refreshes.** A reconnect loop that never finishes authenticating keeps creating a fresh socket, and each one re-emits a QR or requests a brand-new pairing code. With no cap that becomes a flood, and WhatsApp answers with `rate-overlimit` and a restriction. 2. **Rapid bulk operations.** Mass-joining or mass-creating groups, bulk-adding members, or blasting messages — especially on a fresh number — looks automated and abusive. `zaileys` now defends against both **by default**: - [`authGuard`](/configuration#authguard) caps QR (`maxQrAttempts`, default 5) and pairing (`maxPairingAttempts`, default 3) regeneration, with an escalating `pairingCooldownMs` between pairing requests. When the budget is spent it **stops** and emits an [`auth-exhausted`](/events) event instead of looping. - [`operationGuard`](/configuration#operationguard) serializes and spaces out group / community / newsletter operations per category. - The reconnect backoff is safer: `initialDelayMs` defaults to `3000` (no instant reconnect storms), and a `rate-limited` (429) disconnect uses the fixed [`rateLimitedDelayMs`](/configuration#reconnect) (default 5 minutes) instead of the exponential ladder. If you are already restricted: **Stop the bot and wait out the timer** — restrictions are usually time-based (often a few hours). Do **not** keep restarting / retrying; every fresh connection attempt while restricted prolongs the block and can escalate it. - Keep `authGuard` **on**. Disabling it (`{ enabled: false }`) is what re-creates the spam loop. - For pairing, use a valid registered number (E.164, no leading `0`) and do **not** restart the process in a tight loop — fix *why* auth isn't completing first. - Avoid mass group joins / creates / member-adds and high-volume sends, particularly on new numbers. Leave `operationGuard` on so those calls stay spaced out. - When you do reconnect, do it once and let the built-in backoff handle retries; don't wrap `connect()` in your own retry loop. See [Configuration](/configuration) for `authGuard`, `operationGuard`, `presence`, and the `reconnect` defaults referenced here. ## Module & dependency errors ### "Cannot find module" sharp / better-sqlite3 / pg / redis / convex `zaileys` keeps heavy database drivers as **optional peer dependencies** — install only the one your storage adapter needs. When a driver is missing, the matching auth/store adapter throws a clear `ZaileysStoreError` with code `STORE_NOT_AVAILABLE`: | Adapter / feature | Missing-dependency message | Install | | --- | --- | --- | | SQLite auth/store | `better-sqlite3 belum terpasang. Run: pnpm add better-sqlite3` | `better-sqlite3` | | Postgres auth/store | `pg is not installed. Run: pnpm add pg` | `pg` | | Redis auth/store | `redis peer dependency missing. Run: pnpm add redis` | `redis` | | Convex auth/store | `convex peer dependency missing` | `convex` | Install the driver that matches your adapter: ```bash npm install better-sqlite3 # or: pg | redis | convex ``` ```bash pnpm add better-sqlite3 # or: pg | redis | convex ``` ```bash yarn add better-sqlite3 # or: pg | redis | convex ``` ```bash bun add better-sqlite3 # or: pg | redis | convex ``` See [Storage Adapters](/storage) for which adapter uses which driver. The default file-based auth store needs none of these. `sharp` is a special case: it is **fully optional and not a peer dependency** of Zaileys. Sticker/image processing tries `sharp` first and silently falls back to the bundled `jimp` engine if it is not installed — so a missing `sharp` never throws, it just uses the fallback. Install `sharp` only if you want its faster image pipeline: `pnpm add sharp`. ### `npm install` fails building `sharp` (Termux / Android / Alpine) The install aborts with something like: ```text npm error code 1 npm error path .../node_modules/sharp npm error command failed npm error command sh -c node install/check.js || npm run build npm error sharp: Attempting to build from source via node-gyp npm error sharp: Please add node-addon-api to your dependencies ``` This is **not** a Zaileys bug. `sharp` is declared as a peer dependency by **Baileys** (`"sharp": "*"`), and Baileys does not mark it optional in `peerDependenciesMeta`, so npm 7+ auto-installs it. On platforms with no prebuilt `sharp` binary — **Termux/Android**, and some Alpine/musl images — npm tries to compile it from source and fails (no `libvips`, missing build tools). Zaileys never requires `sharp`: image and sticker processing fall back to the bundled pure-JS `jimp` path automatically. Install with `--legacy-peer-deps` so npm skips Baileys' `sharp` peer: ```bash npm install zaileys --legacy-peer-deps ``` ```bash # pnpm does not run dependency build scripts unless approved, so the sharp build never fires pnpm add zaileys ``` ```bash # Yarn does not auto-install peer dependencies, so sharp is skipped yarn add zaileys ``` If you genuinely want the native `sharp` accelerator on Termux, install the build toolchain first (`pkg install python make clang`) and then `npm install zaileys` — but the `jimp` fallback works fine without it. ### ESM / `require()` errors ("require is not defined", "ERR_REQUIRE_ESM", "Cannot use import statement") `zaileys` ships both ESM (`dist/index.mjs`) and CommonJS (`dist/index.cjs`) builds, so both import styles work — but they must match your project setup. ```typescript // ESM (recommended) — "type": "module" in package.json, or .mjs / .ts ``` ```javascript // CommonJS — plain .js without "type": "module", or .cjs const { Client } = require('zaileys') ``` If you get `ERR_REQUIRE_ESM` or `Cannot use import statement outside a module`, your file extension and `package.json` `type` field disagree. Either add `"type": "module"` to `package.json` and use `import`, or keep CommonJS and use `require`. The library requires **Node.js >= 20**. For TypeScript, run with [`tsx`](https://tsx.is) which handles both: `npx tsx index.ts`. See [Installation](/installation) for full setup and [Runtime Support](/runtimes) for Bun/Deno specifics. ### Bun: `ws` / WebSocket warnings (safe to ignore) When running under Bun you may see noisy warnings from the underlying `ws` package, or a one-off `Closing session:` line from libsignal. These are harmless and do not affect the connection — `zaileys` already suppresses the libsignal `Closing session:` noise on `console.info` for you. ```text # Bun ws-related warnings like these are safe to ignore: [ws] ...experimental... / addon not found Closing session: # suppressed by zaileys ``` These warnings come from Bun's compatibility layer around `ws`, not from `zaileys`. They are noise only — the WebSocket still connects. If you want a completely quiet startup, keep `ZAILEYS_DEBUG` unset (logging defaults to `silent`). See [Runtime Support](/runtimes) for the full Bun/Deno notes. ## Behavior & lifecycle ### Messages from myself aren't received (`ignoreMe`) By default `zaileys` **ignores your own outgoing messages** (`fromMe`) so your bot does not react to itself. This is controlled by the `ignoreMe` option, which defaults to `true`. If you need to process messages you send (e.g. a self-note bot or testing from your own number), turn it off: ```typescript const client = new Client({ ignoreMe: false, // default is true — set false to receive your own messages }) client.on('messages', (msg) => { console.log('got message, fromMe =', msg /* includes your own now */) }) ``` With `ignoreMe: false`, make sure your handlers do not reply to their own replies — that can create an echo loop. Gate replies on the sender or a command prefix. See [Events](/events) and [Commands](/commands). ### How do I log out / reset the session? Two different operations, depending on what you want: - **`disconnect()`** — closes the socket cleanly and keeps your credentials, so you can reconnect later without re-scanning. - **`logout()`** — unlinks the device on WhatsApp's side **and** wipes the stored credentials (clears the signal store and deletes creds). The next run will need a fresh QR / pairing code. ```typescript const client = new Client() // Temporary: stop the socket, keep the session. await client.disconnect() // Permanent: unlink the device and clear stored credentials. await client.logout() ``` If you cannot start the app to call `logout()` (e.g. the session is already corrupt), just delete the auth folder manually — that is the offline equivalent of a reset: ```bash rm -rf ./.zaileys/auth/default # replace 'default' with your sessionId ``` `logout()` only removes credentials from the configured auth store. If you use a database adapter (SQLite/Postgres/Redis/Convex), it clears the rows there instead of a folder. See [Storage Adapters](/storage). ### How do I enable debug logging? (`ZAILEYS_DEBUG`) `zaileys` logs are **silent by default**. Enable them with the `ZAILEYS_DEBUG` environment variable. Set it to `1` for `info`-level logs, or to any pino level name (`fatal`, `error`, `warn`, `info`, `debug`, `trace`) for finer control. ```bash ZAILEYS_DEBUG=1 node index.js # info level ZAILEYS_DEBUG=debug npx tsx index.ts # verbose ZAILEYS_DEBUG=trace npx tsx index.ts # everything ``` ```powershell $env:ZAILEYS_DEBUG=1; node index.js $env:ZAILEYS_DEBUG="debug"; npx tsx index.ts ``` You can also pass your own logger or level directly to the client instead of relying on the env var: ```typescript const client = new Client({ logger: { debug: (...a) => console.debug('[debug]', ...a), info: (...a) => console.info('[info]', ...a), warn: (...a) => console.warn('[warn]', ...a), error: (...a) => console.error('[error]', ...a), fatal: (...a) => console.error('[fatal]', ...a), }, }) ``` The friendly `[zaileys] ...` status lines (connecting, QR, pairing code, reconnecting, disconnect) are separate from `ZAILEYS_DEBUG` and are controlled by the `statusLog` option (default `true`). Set `statusLog: false` to silence them. `ZAILEYS_DEBUG` controls the low-level pino logs. See [Configuration](/configuration). ## Still stuck? If a `disconnect` is fatal, the auth folder is clean, and the right driver is installed but it still won't connect, capture a full log and open an issue. ```bash ZAILEYS_DEBUG=trace npx tsx index.ts 2>&1 | tee zaileys-debug.log ``` Then report it at [github.com/zeative/zaileys](https://github.com/zeative/zaileys) with the log and your runtime (Node/Bun/Deno) version. For typed error handling in code, see [Error Handling](/error-handling). --- # API Reference A grouped, quick-lookup index of every public export from the `zaileys` package. Everything below is re-exported from the package root, so a single import works for any symbol: ```typescript ``` This page is a terse exports index. For full prose, options tables, and end-to-end examples follow the deep-links to the relevant guide page on each entry. ## Client The main entry point. See [Client & Lifecycle](/client) and [Configuration](/configuration). ```typescript const client = new Client({ sessionId: 'main', auth: new MemoryAuthStore(), store: new MemoryMessageStore(), authType: 'qr', qrTerminal: true, }) client.on('connection', (s) => console.log(s.status)) client.on('message', (msg) => { if (msg.text() === 'ping') client.send(msg.chatId()).text('pong') }) ``` | Member | Signature | Description | | --- | --- | --- | | `new Client(opts?)` | `(options?: ClientOptions)` | Construct a client; auto-connects unless `autoConnect: false`. | | `connect()` | `(): Promise` | Open the WhatsApp connection (QR or pairing). | | `disconnect()` | `(): Promise` | Close the socket without clearing auth. | | `logout()` | `(): Promise` | Log out and clear stored credentials. | | `get state` | `ConnectionState` | Current state (`idle` \| `connecting` \| `connected` \| `disconnected`). | | `get socket` | `BaileysSocket \| undefined` | Underlying socket (escape hatch). | | `send(to)` | `(to: string): MessageBuilder<'init'>` | Start a fluent message builder for a JID. See [Sending Messages](/sending-messages). | | `edit(key)` | `(key: WAMessageKey): EditBuilder` | Edit a previously sent message. | | `delete(key, opts?)` | `(key, opts?: DeleteOptions): Promise` | Delete a message. | | `react(key, emoji)` | `(key, emoji: string): Promise` | React to a message. | | `forward(key, to)` | `(key, to: string): Promise` | Forward a message to a JID. | | `broadcast(jids, build, opts?)` | `(jids: string[], build, opts?): Promise` | Send to many recipients. See [Broadcast & Schedule](/automation). | | `scheduleAt(date, build, opts?)` | `(date: Date, build, opts?): Promise` | Schedule a message for later. | | `command(spec, handler)` | `(spec: string, handler: CommandHandler): this` | Register a command. See [Commands](/commands). | | `use(middleware)` | `(middleware: Middleware): this` | Add command middleware. | | `get group` | `GroupModule` | Group management. | | `get privacy` | `PrivacyModule` | Privacy & blocking. | | `get newsletter` | `NewsletterModule` | Newsletter/channel management. | | `get community` | `CommunityModule` | Community management. | | `get presence` | `PresenceModule` | Presence updates. | | `on/once/off/emit` | inherited from `TypedEventEmitter` | Typed event subscription. See [Events](/events). | Related client exports: `TypedEventEmitter`, `TypedEventEmitterOptions`. ## Configuration Types Types backing the `Client` constructor. See [Configuration](/configuration). | Export | Kind | Description | | --- | --- | --- | | `ClientOptions` | interface | All constructor options (`sessionId`, `auth`, `store`, `authType`, `phoneNumber`, `logger`, `cacheSignal`, `reconnect`, `qrTerminal`, `baileys`, `autoConnect`, `statusLog`, `commandPrefix`, `citation`, `ignoreMe`). | | `ConnectionState` | type | `idle \| connecting \| connected \| disconnected` (and reconnecting states). | | `ConnectionAuthType` | type | `'qr' \| 'pairing'`. | | `ReconnectOptions` | interface | Reconnect backoff configuration. | | `Logger` | interface | Pluggable logger contract. | | `ClientEventMap` / `ClientEventName` | type | Full event map (connection + inbound). | | `ConnectionEventMap` / `ConnectionEventName` / `ConnectionEventHandler` | type | Connection-only event types. | | `BaileysSocket` | type | Alias for the underlying `WASocket`. | ## Auth Stores Credential persistence. Pass an instance to `Client`'s `auth` option. See [Storage Adapters](/storage). ```typescript const client = new Client({ sessionId: 'main', auth: new SqliteAuthStore({ path: './session.db' }), }) ``` | Export | Kind | Description | | --- | --- | --- | | `MemoryAuthStore` | class | In-memory creds (non-persistent). | | `FileAuthStore` | class | File-based store. Options: `FileAuthStoreOptions`. | | `SqliteAuthStore` | class | SQLite-backed. Options: `SqliteAuthStoreOptions`. | | `PostgresAuthStore` | class | Postgres-backed. Options: `PostgresAuthStoreOptions`. | | `RedisAuthStore` | class | Redis-backed. Options: `RedisAuthStoreOptions`. | | `ConvexAuthStore` | class | Convex-backed. Options: `ConvexAuthStoreOptions`. | | `makeCacheableAuthStore(...)` | function | Wrap a store with an in-memory cache. Options: `CacheableAuthStoreOptions`. | | `AuthStoreBundle` | interface | `{ creds: AuthCredsStore; signal: AuthStore }` — the contract all adapters implement. | | `AuthStore` / `AuthCredsStore` | interface | Signal-key and credential sub-stores. | | `AuthStoreKey` / `AuthStoreValue` | type | Signal data key/value types. | ## Message Stores Chat/message/contact/presence persistence. Pass to `Client`'s `store` option. See [Storage Adapters](/storage). | Export | Kind | Description | | --- | --- | --- | | `MemoryMessageStore` | class | In-memory message store. | | `SqliteMessageStore` | class | SQLite-backed. Options: `SqliteMessageStoreOptions`. | | `PostgresMessageStore` | class | Postgres-backed. Options: `PostgresMessageStoreOptions`. | | `RedisMessageStore` | class | Redis-backed. Options: `RedisMessageStoreOptions`. | | `ConvexMessageStore` | class | Convex-backed. Options: `ConvexMessageStoreOptions`. | | `MessageStore` | interface | Store contract: `saveMessage`, `getMessage`, `listMessages`, `saveChat`, `getChat`, `listChats`, `saveContact`, `getContact`, `listContacts`, `savePresence`, `getPresence`, `bind`, `clear`, `close`, optional `saveScheduledJob`/`listScheduledJobs`/`deleteScheduledJob`. | | `MessageStoreListOptions` | type | Pagination/filter options for `listMessages`. | | `ScheduledJobRecord` | type | Persisted scheduled-job row. | | `BaileysSocketLike` | interface | Minimal socket shape consumed by `MessageStore.bind`. | ## Builder (Sending Messages) Fluent message construction. See [Sending Messages](/sending-messages), [Interactive Messages](/interactive), [Rich Responses](/rich-responses). ```typescript client .send('628xxx@s.whatsapp.net') .text('Hello *world*') .reply(msg.message().key) ``` ### `MessageBuilder` | Method | Signature | Description | | --- | --- | --- | | `to(recipient)` | `(recipient: string): MessageBuilder<'init'>` | Set recipient JID. | | `text(content, opts?)` | `(content: string, opts?: TextOptions): MessageBuilder<'content-set'>` | Text message (`opts.rich` enables AIRich). | | `image(src, opts?)` | `(src: MediaSource, opts?: ImageOptions)` | Image. | | `video(src, opts?)` | `(src: MediaSource, opts?: VideoOptions)` | Video. | | `audio(src, opts?)` | `(src: MediaSource, opts?: AudioOptions)` | Audio / voice note. | | `document(src, opts)` | `(src: MediaSource, opts: DocumentOptions)` | Document. | | `sticker(src, opts?)` | `(src: MediaSource, opts?: StickerOptions)` | Sticker. | | `buttons(...)` | interactive button message | See [Interactive](/interactive). | | `carousel(...)` | carousel/cards | See [Interactive](/interactive). | | `list(opts)` | `(opts: ListOptions)` | List message. | | `poll(...)` | poll message (`PollOptions`) | See [Interactive](/interactive). | | `location(...)` | location (`LocationOptions`) | Share location. | | `contact(vcard)` | `(vcard: string)` | Share a contact. | | `template(opts)` | `(opts: TemplateOptions)` | Template message. | | `album(items)` | `(items: AlbumItem[])` | Media album. | | `reply(quoted)` | `(quoted: WAMessage \| WAMessageKey)` | Quote a message. | | `mentions(jids)` | `(jids: string[])` | Mention specific JIDs. | | `mentionAll()` | `()` | Mention all group members. | | `disappearing(seconds)` | `(seconds: number)` | Set disappearing timer. | | `then(...)` | thenable | Awaiting the builder sends the message and resolves to a `WAMessageKey`. | | `sendMessage(...)` | low-level send | Internal/escape-hatch send. | ### `EditBuilder` | Method | Signature | Description | | --- | --- | --- | | `text(content)` | `(content: string): this` | Replace text. | | `image(src, opts?)` | `(src: MediaSource, opts?: ImageOptions): this` | Replace with image. | | `video(src, opts?)` | `(src: MediaSource, opts?: VideoOptions): this` | Replace with video. | ### Builder mutations & helpers | Export | Kind | Description | | --- | --- | --- | | `deleteMessage(...)` | function | Delete a message. Options: `DeleteOptions`. | | `reactToMessage(...)` | function | React to a message. | | `forwardMessage(...)` | function | Forward a message. | | `isJid(value)` | function | `(value: string): boolean` — JID-format check. | | `resolveUsername(...)` | function | Resolve a username to a JID. Socket: `UsernameResolveSocketLike`. | | `BuilderSocketLike` / `TextOptions` | type | Builder socket shape + text options. | ### Builder types `BuilderState`, `BuilderContext`, `MediaSource`, `ImageOptions`, `VideoOptions`, `AudioOptions`, `DocumentOptions`, `StickerOptions`, `AlbumItem`, `ListOptions`, `ListSection`, `PollOptions`, `LocationOptions`, `TemplateOptions`, `ButtonDef`, `InteractiveButton` (`ReplyButton`, `UrlButton`, `CopyButton`, `CallButton`, `ReminderButton`, `CancelReminderButton`, `LocationRequestButton`, `AddressButton`), `BottomSheetOptions`, `LimitedTimeOfferOptions`. ## Events Inbound event payloads and helpers. See [Events](/events). ```typescript client.on('message', (msg) => console.log(msg.text(), msg.chatId())) client.on('call', (call) => console.log(call)) ``` | Export | Kind | Description | | --- | --- | --- | | `buildMessageContext(...)` | function | Build the rich `MessageContext` from a raw message. | | `dropSpoofedSelfOnly(upsert)` | function | Guard that drops spoofed self-only protocol messages. | | `SELF_ONLY_PROTOCOL_TYPES` | const | Frozen list of self-only protocol types. | | `MessageContext` | type | The rich, lazy message object passed to handlers. | | `ChatType` / `SenderInfo` / `SenderDevice` | type | Sender/chat metadata. | | Payload types | type | `ButtonClickPayload`, `CallPayload`/`CallBase`, `DeletePayload`, `EditPayload`, `GroupJoinPayload`, `GroupLeavePayload`, `GroupUpdatePayload`, `GroupParticipantInfo`, `HistorySyncPayload`, `LimitedPayload`, `ListSelectPayload`, `MemberTagPayload`, `NewsletterPayload`, `PollVotePayload`, `PresencePayload`, `ReactionPayload`, `QuotedRef`. | | Media context | type | `ContextMedia`, `MediaDescriptor`, `MediaDownloadResult`, `MediaKind`. | | Mentions | type | `MentionContext`, `MentionAllContext`. | | Event maps | type | `InboundEventMap`, `InboundEventName`. | | Citations | type | `CitationConfig`, `CitationPredicates`. | | Misc | type | `BuildContextInput`, `UpsertPayload`, `SelfOnlyProtocolType`. | ## Commands Prefix-based command routing. See [Commands](/commands). ```typescript client.command('ping', async (ctx) => ctx.reply('pong')) client.use(async (ctx, next) => { console.log(ctx.command); await next() }) ``` | Export | Kind | Description | | --- | --- | --- | | `parseCommand(text, prefixes)` | function | Parse text into `ParsedArgs`. | | `CommandRegistry` | class | Holds command definitions: `register`, `resolve`, `list`. | | `runMiddleware(...)` | function | Run a middleware chain. | | `attachCommandDispatcher(...)` | function | Wire the dispatcher to a client. | | `CommandContext` | interface | Extends `MessageContext` with `command`, `args`, `flags`, `json`, `reply`, `react`, `edit`. | | `CommandHandler` / `Middleware` | type | Handler and middleware function shapes. | | `CommandDefinition` / `ParsedArgs` / `CommandPrefix` | type | Definition, parsed args, and prefix types. | | `DispatcherDeps` / `DispatcherHandle` / `ResolvedCommand` | type | Dispatcher internals. | ## Automation Rate limiting, queues, broadcast, scheduling, presence. See [Broadcast & Schedule](/automation). | Export | Kind | Description | | --- | --- | --- | | `RateLimiter` | class | Token-bucket limiter. Method: `acquire(jid?)`. Options: `RateLimiterOptions`, `RateLimiterClock`. | | `TaskQueue` | class | Concurrency-limited queue. Methods: `add(task)`, `onIdle()`. Options: `TaskQueueOptions`, `TaskQueueClock`. | | `runBroadcast(...)` | function | Fan-out send. Options: `BroadcastOptions`, `BroadcastResult`, `BroadcastDeps`. | | `Scheduler` | class | Persistent scheduler. Methods: `scheduleAt(...)`, `loadPending()`, `dispose()`. Deps/types: `SchedulerDeps`, `SchedulerTimer`, `ScheduleHandle`, `ScheduledContentSnapshot`. | | `PresenceModule` | class | Methods: `online()`, `offline()`, `typing(jid, ms?)`, `recording(jid, ms?)`. Types: `AutomationSocketLike`, `WAPresence`. Full guide: [Presence](/presence). | | `RetryPolicy` / `ScheduledJob` / `ScheduledJobRecord` | type | Retry config and scheduled-job shapes. | ## Domain Modules Accessed via `client.group`, `client.privacy`, `client.newsletter`, `client.community`. Full guides: [Groups](/groups) · [Communities](/community) · [Newsletters](/newsletter) · [Privacy & Blocking](/privacy). See also [Client & Lifecycle](/client). ```typescript const meta = await client.group.metadata('xxx@g.us') await client.group.addMember('xxx@g.us', ['628xxx@s.whatsapp.net']) ``` ### `GroupModule` `create`, `addMember`, `removeMember`, `promote`, `demote`, `updateSubject`, `updateDescription`, `leave`, `metadata`, `tagMember`, `inviteCode`, `revokeInvite`, `acceptInvite`, `toggleEphemeral`, `setting`. ### `PrivacyModule` `set`, `get`, `block`, `unblock`, `blocklist`, `disappearingMode`. ### `NewsletterModule` `create`, `follow`, `unfollow`, `metadata`, `updateName`, `updateDescription`, `updatePicture`, `mute`, `unmute`, `delete`. ### `CommunityModule` `create`, `createGroup`, `linkGroup`, `unlinkGroup`, `subGroups`, `leave`, `updateSubject`, `updateDescription`, `inviteCode`, `revokeInvite`, `acceptInvite`. Domain types: `ParticipantUpdateResult`, `PrivacyConfig`, `PrivacySettings`, `LinkedGroup`, `DomainSocketLike`. ## Media FFmpeg/sharp-backed media processing. See [Media](/media). ```typescript const m = new Media('./input.mp3') const opus = await m.audio.toOpus() const thumb = await m.video.thumbnail() ``` | Export | Kind | Description | | --- | --- | --- | | `Media` | class | Facade with getters: `audio` (`toOpus`/`toMp3`/`convert`/`waveform`), `video` (`toMp4`/`thumbnail`), `image` (`toJpeg`/`thumbnail`/`resize`), `sticker.create`, `document.create`, `thumbnail.get`. | | `AudioProcessor` / `VideoProcessor` / `ImageProcessor` / `StickerProcessor` / `DocumentProcessor` | class | Low-level processors. | | `FFmpegProcessor` / `FileManager` / `BufferConverter` / `MimeValidator` | class | FFmpeg/IO helpers. | | `initializeFFmpeg(disable?)` / `detectFileType(buffer)` / `generateId()` / `ffmpegTransform(...)` | function | Setup and transform helpers. | | `FFMPEG_CONSTANTS` | const | Shared MIME/extension constants. | | `MediaInput` / `FileExtension` / `AudioType` / `StickerShapeType` | type | Input and format types. | | `FFmpegConfig` / `StickerMetadataType` | interface | Config and sticker metadata. | ## Connection Lower-level connection primitives (advanced). See [Client & Lifecycle](/client). | Export | Kind | Description | | --- | --- | --- | | `createPairingFlow(opts)` | function | Build a pairing-code flow. Types: `PairingFlow`, `PairingFlowOptions`, `PairingFlowResult`. | | `createReconnectStrategy(...)` | function | Reconnect backoff strategy. Types: `ReconnectStrategy`, `ReconnectDecision`, `ReconnectStrategyDeps`. | | `createConnectionStateMachine(initial?)` | function | State machine. Types: `ConnectionStateMachine`, `StateTransitionListener`. | | `signalKeyStoreFromAuthStore(store, logger?)` | function | Adapt an `AuthStore` into a Baileys signal key store. | | `renderQrInTerminal(qrString)` | function | Render a QR string in the terminal. | | `mapDisconnectReason(code)` / `isFatalDisconnect(r)` / `shouldClearAuth(r)` / `shouldReconnect(r)` | function | Disconnect-reason classifiers. Type: `DisconnectReasonDomain`. | | `normalizePhoneNumber(raw)` / `validateE164(raw)` | function | Phone-number helpers. | ## Utilities | Export | Kind | Description | | --- | --- | --- | | `createLogger(options?)` | function | Build a Pino-based logger. Options: `CreateLoggerOptions`. | | `adoptLogger(maybe, fallback?)` | function | Normalize a partial logger into a full `Logger`. | | `chunk(...)` | function | Split an array into fixed-size chunks. | | `ZaileysLogger` / `LoggerLevel` | type | Logger instance and level types. | ## Errors Typed error classes per subsystem. Each carries a discriminated `code`. See [Error Handling](/error-handling) for handling patterns. ```typescript try { await client.send(jid).text('hi') } catch (e) { if (e instanceof ZaileysBuilderError) console.error(e.code, e.message) } ``` | Export | Kind | Description | | --- | --- | --- | | `ZaileysBuilderError` | class | Builder/send failures. Code: `BuilderErrorCode`. | | `ZaileysDomainError` | class | Group/privacy/newsletter/community failures. Code: `DomainErrorCode`. | | `ZaileysCommandError` | class | Command parsing/dispatch failures. Code: `CommandErrorCode`. | | `ZaileysAutomationError` | class | Broadcast/schedule/queue failures. Code: `AutomationErrorCode`. | | `ZaileysStoreError` | class | Store failures. Code: `StoreErrorCode`. | ## Misc Types | Export | Kind | Description | | --- | --- | --- | | `LIDMapping` | interface | Linked-device ID mapping. | | `LIDMappingUpdatePayload` | type | Payload for LID mapping updates. | Optional native dependencies (SQLite, Postgres `pg`, Redis, Convex, FFmpeg, sharp) are loaded lazily. Install only the adapter you use — see [Storage Adapters](/storage) and [Media](/media). --- # AI Skill Zaileys ships an **official Agent Skill** that turns your AI assistant into a zaileys expert. Instead of guessing the API, your assistant gets the verified surface, copy-paste recipes, error diagnostics, and a list of anti-patterns to avoid — so it implements best practices and fixes problems by symptom → cause → fix. The skill lives in the zaileys repo itself (`skills/zaileys/`), so installing is just pointing your tool at `zeative/zaileys`. No separate package. ## Install Native Claude Code plugin — supports auto-update when the repo changes. ```bash /plugin marketplace add zeative/zaileys /plugin install zaileys-official@zeative ``` Update later with `/plugin marketplace update zeative/zaileys`. Commands are namespaced as `/zaileys-official:`. [`npx skills`](https://github.com/vercel-labs/skills) works across Claude Code, Codex, Cursor, and OpenCode. ```bash npx skills add zeative/zaileys # into the current project (.claude/skills/) npx skills add zeative/zaileys -g # global (~/.claude/skills/) ``` Both methods install the **same** skill suite — pick whichever fits your tool. ## The suite One orchestrator that auto-routes, plus three focused skills: | Skill | Plugin command | npx command | Does | | ----- | -------------- | ----------- | ---- | | **assist** | `/zaileys-official:assist` | `/assist` | Orchestrator — auto-detects intent (build / debug / review / explain) so you don't pick a command | | **scaffold** | `/zaileys-official:scaffold` | `/scaffold` | Generate a complete, runnable bot from a short spec (auth, storage, features) | | **debug** | `/zaileys-official:debug` | `/debug` | Paste an error/symptom → error class + `.code` or runtime cause → concrete fix | | **review** | `/zaileys-official:review` | `/review` | Audit code against best practices, anti-patterns, and ban-safety | You usually don't need to pick a command — `assist` has a broad trigger and auto-activates on any zaileys task, then routes internally. Use the focused commands when you want a specific job. ## What you get After installing, your AI assistant can: - **Implement with best practices** — typed events, the fluent send builder, interactive messages, AIRich, commands, broadcast, and the right storage adapter for your runtime. - **Diagnose errors precisely** — identify the error class and `.code`, what it means, and how to fix it. - **Catch anti-patterns** — flag tight send loops, missing `await` on keys, raw-JID owner checks, wrong `expiresAt` units, and more during review. - **Stay accurate** — every reference is grounded in the zaileys source, so the assistant won't invent methods or options. ## What's inside Each focused skill is a self-contained `SKILL.md`. The **assist** orchestrator additionally carries the deep references the whole suite draws on: | File | Purpose | | ---- | ------- | | `assist/SKILL.md` | Orchestrator: mental model, golden rules, intent routing, API cheat-sheet | | `assist/references/api.md` | Full API surface: `ClientOptions`, send builder, events, mutations, domain, storage, media | | `assist/references/recipes.md` | Best-practice, copy-paste patterns for every common bot | | `assist/references/errors.md` | Every error class + code → meaning → fix | | `assist/references/troubleshooting.md` | Runtime symptoms (QR loops, sessions, disconnects, peer deps) → fix | | `assist/references/pitfalls.md` | Common mistakes → the correct way | | `scaffold/SKILL.md` · `debug/SKILL.md` · `review/SKILL.md` | Focused task skills (project generation, error diagnosis, code review) | Prefer to feed an LLM directly? The whole documentation is also available as a single file at [`/llms-full.txt`](/llms-full.txt), with an index at [`/llms.txt`](/llms.txt). ## Keeping it updated The skill is versioned alongside the library. With the Claude Code plugin, run `/plugin marketplace update zeative/zaileys` to pull the latest; with `npx skills`, re-run the `add` command. New releases bump the plugin version so updates are detected.