|
| 1 | +--- |
| 2 | +title: "Multiple Accounts — Running Many WhatsApp Numbers" |
| 3 | +description: "Run several WhatsApp accounts from one Zaileys app: one Client per account, per-account storage isolation, a client registry, lifecycle management, cloud multi-number webhook routing, and ban-safety per number." |
| 4 | +--- |
| 5 | + |
| 6 | +import { Callout, Tabs, Steps } from 'nextra/components' |
| 7 | + |
| 8 | +# Multiple Accounts |
| 9 | + |
| 10 | +Zaileys has no "multi-account mode" — and it doesn't need one. **One `Client` = one account.** Run as |
| 11 | +many as you like in one process, mixing providers freely. The whole job is keeping each account's |
| 12 | +**session, storage, and lifecycle isolated**. |
| 13 | + |
| 14 | +This page covers the isolation rules (including two sharp edges that silently corrupt sessions), a |
| 15 | +production-ready registry pattern, and ban-safety per number. |
| 16 | + |
| 17 | +## The one rule |
| 18 | + |
| 19 | +```typescript |
| 20 | +import { Client } from 'zaileys' |
| 21 | + |
| 22 | +const cs = new Client({ sessionId: 'support' }) // account A |
| 23 | +const sales = new Client({ sessionId: 'sales' }) // account B |
| 24 | +``` |
| 25 | + |
| 26 | +`sessionId` is the isolation key: it namespaces the **default auth folder** |
| 27 | +(`./.zaileys/auth/<sessionId>`) and the scheduler. Two clients with different `sessionId`s are fully |
| 28 | +independent — separate sockets, separate creds, separate reconnect state. |
| 29 | + |
| 30 | +<Callout type="warning"> |
| 31 | +**`sessionId` only isolates the *default* file auth store.** The moment you pass a custom `auth` or |
| 32 | +`store`, isolation becomes *your* responsibility — see the matrix below. Two clients pointed at the |
| 33 | +same Postgres database will **overwrite each other's credentials**, even with different `sessionId`s. |
| 34 | +</Callout> |
| 35 | + |
| 36 | +## Storage isolation matrix |
| 37 | + |
| 38 | +What actually isolates each backend (verified against the adapters): |
| 39 | + |
| 40 | +| Backend | Isolate with | Shared instance safe? | |
| 41 | +| --- | --- | :---: | |
| 42 | +| **File** (default auth) | `basePath` — auto-derived from `sessionId` | ✅ isolated by default | |
| 43 | +| **Memory** (default store) | a new instance per `Client` (the default) | ✅ if you don't pass one in | |
| 44 | +| **SQLite** | a **separate `database` file** per account | ❌ never share one file | |
| 45 | +| **Redis** | a distinct **`namespace`** per account | ❌ default is `'zaileys'` for everyone | |
| 46 | +| **Convex** | a distinct **`namespace`** per account | ❌ same as Redis | |
| 47 | +| **Postgres** | a **separate database or schema** per account | ❌ **no namespace option exists** | |
| 48 | + |
| 49 | +### ⚠️ Postgres: the sharp edge |
| 50 | + |
| 51 | +The Postgres adapters use **fixed table names** (`zaileys_auth_creds`, `zaileys_auth_signal`, |
| 52 | +`zaileys_messages`, `zaileys_chats`) and store credentials under a **fixed row id `'default'`**. There |
| 53 | +is no namespace/prefix option. Two accounts on the same `connectionString` will: |
| 54 | + |
| 55 | +- **clobber each other's credentials** (both write `id = 'default'`), and |
| 56 | +- **mix message history** (rows are keyed by `(remote_jid, id, from_me)` — no account column). |
| 57 | + |
| 58 | +✅ Give each account its own database, or its own **schema** via the connection string: |
| 59 | + |
| 60 | +```typescript |
| 61 | +const acctA = new Client({ |
| 62 | + sessionId: 'a', |
| 63 | + auth: new PostgresAuthStore({ connectionString: `${BASE}?options=-c%20search_path%3Dacct_a` }), |
| 64 | + store: new PostgresMessageStore({ connectionString: `${BASE}?options=-c%20search_path%3Dacct_a` }), |
| 65 | +}) |
| 66 | +``` |
| 67 | + |
| 68 | +(Create the schemas first: `CREATE SCHEMA acct_a; CREATE SCHEMA acct_b;`) |
| 69 | + |
| 70 | +### Redis / Convex: always set a namespace |
| 71 | + |
| 72 | +```typescript |
| 73 | +const acctA = new Client({ |
| 74 | + sessionId: 'a', |
| 75 | + auth: new RedisAuthStore({ url: REDIS_URL, namespace: 'wa:acct_a' }), |
| 76 | + store: new RedisMessageStore({ url: REDIS_URL, namespace: 'wa:acct_a' }), |
| 77 | +}) |
| 78 | +const acctB = new Client({ |
| 79 | + sessionId: 'b', |
| 80 | + auth: new RedisAuthStore({ url: REDIS_URL, namespace: 'wa:acct_b' }), |
| 81 | + store: new RedisMessageStore({ url: REDIS_URL, namespace: 'wa:acct_b' }), |
| 82 | +}) |
| 83 | +``` |
| 84 | + |
| 85 | +Both default to `'zaileys'` — leave it out on two accounts and they share one keyspace. |
| 86 | + |
| 87 | +<Callout type="warning"> |
| 88 | +**Never share a single `store` *instance* across clients.** Message keys are |
| 89 | +`remoteJid|id|fromMe` — there's no account discriminator, so two accounts talking to the same |
| 90 | +contact will read each other's history. |
| 91 | +</Callout> |
| 92 | + |
| 93 | +## The registry pattern (recommended) |
| 94 | + |
| 95 | +Don't hand-roll variables per account. Build a small registry — it scales from 2 to 200. |
| 96 | + |
| 97 | +```typescript |
| 98 | +import { Client, type ClientOptions } from 'zaileys' |
| 99 | + |
| 100 | +export interface Account { |
| 101 | + id: string |
| 102 | + options?: Partial<ClientOptions> |
| 103 | +} |
| 104 | + |
| 105 | +const clients = new Map<string, Client>() |
| 106 | + |
| 107 | +export function createAccount({ id, options }: Account): Client { |
| 108 | + if (clients.has(id)) return clients.get(id)! |
| 109 | + |
| 110 | + const client = new Client({ |
| 111 | + sessionId: id, // isolates auth dir + scheduler |
| 112 | + autoConnect: false, // start them deliberately |
| 113 | + ...options, |
| 114 | + }) |
| 115 | + |
| 116 | + // Tag every log line with the account — you'll need this the first time one misbehaves. |
| 117 | + client.on('connect', ({ me }) => console.log(`[${id}] connected as ${me.id}`)) |
| 118 | + client.on('disconnect', ({ reason, willReconnect }) => |
| 119 | + console.warn(`[${id}] disconnected: ${reason}${willReconnect ? ' (reconnecting)' : ''}`), |
| 120 | + ) |
| 121 | + client.on('error', ({ error }) => console.error(`[${id}]`, error)) |
| 122 | + |
| 123 | + clients.set(id, client) |
| 124 | + return client |
| 125 | +} |
| 126 | + |
| 127 | +export const getAccount = (id: string): Client | undefined => clients.get(id) |
| 128 | +export const allAccounts = (): Client[] => [...clients.values()] |
| 129 | +``` |
| 130 | + |
| 131 | +### Start them all |
| 132 | + |
| 133 | +```typescript |
| 134 | +const ACCOUNTS = ['support', 'sales', 'billing'] |
| 135 | + |
| 136 | +for (const id of ACCOUNTS) createAccount({ id }) |
| 137 | + |
| 138 | +// connect in parallel, but don't let one failure kill the rest |
| 139 | +const results = await Promise.allSettled(allAccounts().map((c) => c.connect())) |
| 140 | +results.forEach((r, i) => { |
| 141 | + if (r.status === 'rejected') console.error(`[${ACCOUNTS[i]}] failed to connect:`, r.reason) |
| 142 | +}) |
| 143 | +``` |
| 144 | + |
| 145 | +<Callout type="info"> |
| 146 | +Use `autoConnect: false` for multi-account. The default (`true`) fires `connect()` on the next |
| 147 | +microtask, so every account races to authenticate at once — with QR logins that means several QR codes |
| 148 | +printed at the same time and no control over order. |
| 149 | +</Callout> |
| 150 | + |
| 151 | +### Shut down cleanly |
| 152 | + |
| 153 | +```typescript |
| 154 | +async function shutdown() { |
| 155 | + await Promise.allSettled(allAccounts().map((c) => c.disconnect())) |
| 156 | + process.exit(0) |
| 157 | +} |
| 158 | +process.on('SIGINT', shutdown) |
| 159 | +process.on('SIGTERM', shutdown) |
| 160 | +``` |
| 161 | + |
| 162 | +`disconnect()` closes the socket **and** the auth/message stores. Skipping it on a DB-backed store can |
| 163 | +leave connections dangling. |
| 164 | + |
| 165 | +## Handling messages per account |
| 166 | + |
| 167 | +Handlers are per-client, so keep the account id in scope — never rely on a global "current" client. |
| 168 | + |
| 169 | +```typescript |
| 170 | +function wire(id: string, client: Client) { |
| 171 | + client.on('text', async (msg) => { |
| 172 | + if (msg.isFromMe) return |
| 173 | + // reply with the SAME client that received it |
| 174 | + await msg.reply(`[${id}] you said: ${msg.text}`) |
| 175 | + }) |
| 176 | +} |
| 177 | +``` |
| 178 | + |
| 179 | +<Callout type="warning"> |
| 180 | +The classic multi-account bug: receiving on account A and replying with `clients.get('b')`. Always |
| 181 | +reply via `msg.reply()` or the client that owns the handler. |
| 182 | +</Callout> |
| 183 | + |
| 184 | +## Mixing providers |
| 185 | + |
| 186 | +Nothing stops you from running the unofficial and official providers side by side — a common setup is |
| 187 | +a personal number for groups and a Cloud API number for campaigns: |
| 188 | + |
| 189 | +```typescript |
| 190 | +const community = new Client({ sessionId: 'community' }) // 🔗 groups, polls |
| 191 | +const campaigns = new Client({ // ☁️ templates, OTP |
| 192 | + provider: 'cloud', |
| 193 | + cloud: { accessToken, phoneNumberId, verifyToken, appSecret }, |
| 194 | +}) |
| 195 | +``` |
| 196 | + |
| 197 | +Gate provider-specific features so a shared code path doesn't explode — see |
| 198 | +[Choose Your Provider](/providers): |
| 199 | + |
| 200 | +```typescript |
| 201 | +if (client.provider === 'baileys') await client.group.create('Team', [jid]) |
| 202 | +else await client.sendTemplate(to, 'welcome', 'en_US') |
| 203 | +``` |
| 204 | + |
| 205 | +## Cloud: multiple numbers behind one webhook |
| 206 | + |
| 207 | +<Callout type="warning"> |
| 208 | +**Sharp edge.** A Meta app has **one callback URL**, but every phone number on the WABA posts to it. |
| 209 | +Zaileys' webhook handler does **not** filter by `metadata.phone_number_id` — feed the same payload to |
| 210 | +two clients and **both** will process **both** numbers' messages (duplicate replies). |
| 211 | +</Callout> |
| 212 | + |
| 213 | +Route by `phone_number_id` before dispatching: |
| 214 | + |
| 215 | +```typescript |
| 216 | +const byPhoneId = new Map<string, Client>([ |
| 217 | + [process.env.WA_PHONE_ID_A!, acctA], |
| 218 | + [process.env.WA_PHONE_ID_B!, acctB], |
| 219 | +]) |
| 220 | + |
| 221 | +// one endpoint, N accounts |
| 222 | +export async function POST(req: Request) { |
| 223 | + const raw = await req.text() |
| 224 | + const body = JSON.parse(raw) |
| 225 | + const phoneId = body?.entry?.[0]?.changes?.[0]?.value?.metadata?.phone_number_id |
| 226 | + const client = byPhoneId.get(phoneId) |
| 227 | + if (!client) return new Response('OK', { status: 200 }) // ack unknown numbers, don't 500 |
| 228 | + |
| 229 | + // hand the ORIGINAL raw body over so signature verification still passes |
| 230 | + return client.webhook()(new Request(req.url, { method: 'POST', headers: req.headers, body: raw })) |
| 231 | +} |
| 232 | +``` |
| 233 | + |
| 234 | +Simpler alternative: **one Meta app per number**, each with its own callback URL — then each client |
| 235 | +gets a clean endpoint and no routing is needed. |
| 236 | + |
| 237 | +## Ban-safety is per number |
| 238 | + |
| 239 | +Every limit WhatsApp enforces is **per account**, not per app: |
| 240 | + |
| 241 | +- Rate limits, [`broadcast({ rateLimitPerSec })`](/automation), and warm-up all apply **per number**. |
| 242 | + Ten accounts blasting at once is ten numbers at risk, not one. |
| 243 | +- On the unofficial provider, a ban hits **one** account — isolation means the others keep running. |
| 244 | + That's an argument for separating a "risky" outreach number from your main support number. |
| 245 | +- On cloud, the [quality rating and messaging tier](/official/limits) are per phone number — check each: |
| 246 | + ```typescript |
| 247 | + for (const c of allAccounts()) { |
| 248 | + if (c.provider === 'cloud') console.log(await c.cloud.info()) // quality_rating per number |
| 249 | + } |
| 250 | + ``` |
| 251 | + |
| 252 | +## One process or many? |
| 253 | + |
| 254 | +| Approach | Use when | Trade-off | |
| 255 | +| --- | --- | --- | |
| 256 | +| **All in one process** (registry above) | 2–20 accounts, shared logic | Simplest. One crash takes down every account; all sessions share the event loop + RAM. | |
| 257 | +| **Process per account** (pm2/systemd/container) | Accounts must not affect each other; different deploy cadence | Full isolation + independent restarts. More ops overhead; needs distinct storage config per process. | |
| 258 | +| **Worker per account** | Many accounts, CPU-heavy media | Isolation without N deploys. More plumbing. | |
| 259 | + |
| 260 | +Each connected `Client` holds its own socket, signal store, and in-memory caches — budget RAM per |
| 261 | +account, and prefer a DB-backed store over `MemoryMessageStore` once history matters. |
| 262 | + |
| 263 | +<Callout type="info"> |
| 264 | +Whatever you pick, keep storage isolation identical. The most common production incident is two |
| 265 | +processes (say blue/green, or a stray local run) sharing one Redis namespace or Postgres database and |
| 266 | +fighting over the same session — WhatsApp sees a conflict and logs one out. |
| 267 | +</Callout> |
| 268 | + |
| 269 | +## Common mistakes |
| 270 | + |
| 271 | +| ❌ Mistake | ✅ Fix | |
| 272 | +| --- | --- | |
| 273 | +| Two clients → same Postgres `connectionString` | Separate database or `search_path` schema per account | |
| 274 | +| Redis/Convex without `namespace` | Distinct `namespace` per account | |
| 275 | +| Sharing one `store` instance across clients | One store instance per client | |
| 276 | +| Same `sessionId` for two accounts | Unique `sessionId` per account | |
| 277 | +| `autoConnect: true` with many QR logins | `autoConnect: false` + connect deliberately | |
| 278 | +| Replying with the wrong client | `msg.reply()` / the owning client | |
| 279 | +| One cloud webhook fanned out to all clients | Route by `metadata.phone_number_id` | |
| 280 | +| `Promise.all` on connect (one failure aborts) | `Promise.allSettled` | |
| 281 | +| Exiting without `disconnect()` | Disconnect all on SIGINT/SIGTERM | |
| 282 | + |
| 283 | +## Full example |
| 284 | + |
| 285 | +A runnable two-account script lives in |
| 286 | +[`examples/multi-account.ts`](https://github.com/zeative/zaileys/blob/main/examples/multi-account.ts). |
| 287 | + |
| 288 | +```typescript |
| 289 | +import { Client } from 'zaileys' |
| 290 | + |
| 291 | +const ACCOUNTS = ['support', 'sales'] as const |
| 292 | +const clients = new Map<string, Client>() |
| 293 | + |
| 294 | +for (const id of ACCOUNTS) { |
| 295 | + const client = new Client({ |
| 296 | + sessionId: id, // → ./.zaileys/auth/<id>, fully isolated |
| 297 | + autoConnect: false, |
| 298 | + autoRejectCall: true, // no calls on bot numbers |
| 299 | + }) |
| 300 | + |
| 301 | + client.on('qr', ({ qrString }) => console.log(`[${id}] scan:`, qrString)) |
| 302 | + client.on('connect', ({ me }) => console.log(`[${id}] online as`, me.id)) |
| 303 | + client.on('disconnect', ({ reason }) => console.warn(`[${id}] down:`, reason)) |
| 304 | + client.on('text', async (msg) => { |
| 305 | + if (msg.isFromMe) return |
| 306 | + await msg.reply(`Halo dari ${id}!`) |
| 307 | + }) |
| 308 | + |
| 309 | + clients.set(id, client) |
| 310 | +} |
| 311 | + |
| 312 | +await Promise.allSettled([...clients.values()].map((c) => c.connect())) |
| 313 | + |
| 314 | +const shutdown = async () => { |
| 315 | + await Promise.allSettled([...clients.values()].map((c) => c.disconnect())) |
| 316 | + process.exit(0) |
| 317 | +} |
| 318 | +process.on('SIGINT', shutdown) |
| 319 | +process.on('SIGTERM', shutdown) |
| 320 | +``` |
| 321 | + |
| 322 | +## Next steps |
| 323 | + |
| 324 | +- [Storage Adapters](/storage) — pick and configure a backend per account. |
| 325 | +- [Configuration](/configuration) — every `ClientOptions` field. |
| 326 | +- [Choose Your Provider](/providers) — mixing unofficial and official numbers. |
| 327 | +- [Automation](/automation) — rate-limited broadcast, per number. |
0 commit comments