Multi-number
Several numbers and tenants on one deployment: routing, accounts and access.
Accounts and tenants#
One deployment can serve many WhatsApp numbers. Each number is a sendo_accounts row with its phone_number_id, waba_id, its own token env var (token_ref), a timezone and an optional tenant_id (for example one number per clinic or building). Every contact, conversation and message copies the account’s tenant_id.
- Contacts are unique per account and phone: the same person writing to two tenants’ numbers becomes two contacts, each with its own history.
sendo.accounts.disconnect(id)stops routing and sending for a number without deleting its data (Numbers and tenants).
Adding numbers#
# .env.local / deployment env
SENDO_TOKEN_CLINICA_NORTE=...
SENDO_TOKEN_CLINICA_SUR=...
npx @quami/sendo accounts add --phone-number-id 111111111111111 --token-env SENDO_TOKEN_CLINICA_NORTE \
--tenant-id 6f1c0c3e-0000-4000-8000-000000000001 --timezone America/Guatemala --yes
npx @quami/sendo accounts add --phone-number-id 222222222222222 --token-env SENDO_TOKEN_CLINICA_SUR \
--tenant-id 6f1c0c3e-0000-4000-8000-000000000002 --yesNumbers in different WABAs can use different tokens; numbers in the same WABA can share one env var. Then subscribe each WABA’s webhook (Meta setup) and run doctor, or doctor --account <id> for one.
Inbound routing#
All numbers share one webhook URL. Each change in a payload carries metadata.phone_number_id, which is looked up in sendo_accounts:
- Active match: processed for that account and tenant.
- No match or inactive: the event is marked
unrouted, a warning is logged, theevent.unroutedevent fires, and nothing else is written. Add the account andsendo.events.reprocess(id)it.
// Replies to inbound messages go out from the number that received them.
sendo.on("message", async ({ account, reply }) => {
console.log("received on", account.displayPhone, "tenant", account.tenantId);
await reply.text("Recibido");
});Sending from a number#
With exactly one active account, sends use it by default. With more than one, every sendo.send.* and sendo.templates.sync call must pass tenant (the organization’s default number, see Numbers and tenants) or account: the Sendo account id, the Meta phone number id, or the Account object. Otherwise the call throws AmbiguousAccountError (or AccountNotFoundError for an unknown or inactive one). Replies (reply, agents, sendo.inbox.reply) always go out from the conversation’s own number.
// With more than one active account, every send names its account:
// the sendo account id (uuid), the Meta phone_number_id, or the Account object.
await sendo.send.template(patientPhone, "recordatorio_cita", {
account: tenantPhoneNumberId,
language: "es",
variables: ["Ana", "mañana 10:00"],
});
await sendo.send.text(patientPhone, "Te esperamos.", { account: tenantPhoneNumberId });
await sendo.templates.sync({ account: tenantPhoneNumberId });try {
await sendo.send.text(patientPhone, "Hola"); // no account
} catch (error) {
if (error instanceof AmbiguousAccountError) console.error(error.candidates); // account ids
}Agents per number#
An agent’s accounts option is a list of account ids / phone number ids or a predicate; tenants filters by organization the same way, and both must match (one agent for many tenants). Only one agent may match each account: overlapping lists throw ConfigError at registration; overlaps found later (predicates, new accounts) are logged and no agent answers on that account until you fix them. The prompt uses the account’s timezone unless the agent sets one.
const model = "openai/gpt-5-mini";
const surTenantId = "6f1c0c3e-0000-4000-8000-000000000002";
sendo.agent({ name: "norte", accounts: ["111111111111111"], model, instructions: "…" });
sendo.agent({
name: "sur",
accounts: (account) => account.tenantId === surTenantId,
model,
instructions: "…",
});Full agent options: API reference.
Access per tenant#
- Browser reads (inbox realtime) are filtered by
sendo_can_access_tenant(tenant_id): implement it with your membership table (Quickstart step 4). - Server actions pass the user’s
tenantIdtosendo.inbox.*, andlinkContact/resolveHandoffTargetsreceive theaccountso you can scope lookups byaccount.tenantId.