Skip to content

Commit ddd08ff

Browse files
committed
docs: comprehensive multiple-accounts guide with isolation matrix and gotchas
1 parent e7ef7e1 commit ddd08ff

2 files changed

Lines changed: 329 additions & 0 deletions

File tree

docs/content/_meta.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const paths: Record<string, string> = {
1818
megaphone: 'M3 11v2a1 1 0 0 0 1 1h2l9 5V5L6 10H4a1 1 0 0 0-1 1zM19 8a5 5 0 0 1 0 8',
1919
eye: 'M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
2020
users: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8M22 21v-2a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8',
21+
layers: 'M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5',
2122
network: 'M12 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM5 22a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19 22a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM12 8v4M6.5 16.5 11 12M17.5 16.5 13 12',
2223
rss: 'M4 11a9 9 0 0 1 9 9M4 4a16 16 0 0 1 16 16M5 19a1 1 0 1 0 0-.01',
2324
shield: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10zM9 12l2 2 4-4',
@@ -113,6 +114,7 @@ export default {
113114
'auto-delete': item('trash', 'Auto-Delete'),
114115

115116
'-- advanced': { type: 'separator', title: 'Advanced' },
117+
'multi-account': item('layers', 'Multiple Accounts'),
116118
plugins: item('plug', 'Plugins'),
117119
utilities: item('wrench', 'Utilities'),
118120
'error-handling': item('alert', 'Error Handling'),

docs/content/multi-account.mdx

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
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

Comments
 (0)