Numbers and tenants
Send as an organization with { tenant }, default numbers, and the sendo.accounts API: add, label, pause, disconnect, purge.
A SaaS connects one or more WhatsApp numbers per organization (tenant). Sendo resolves which number to use from the tenant, so your code passes { tenant } instead of account ids. For the data model and inbound routing, see Multi-number.
Sending as a tenant#
Every account-scoped call takes tenant next to account: all of sendo.send.*, sendo.templates.sync and sendo.templates.list. tenant: null means the accounts without a tenant.
// Goes out from the organization's default number. No account ids to pass around.
await sendo.send.template(patientPhone, "recordatorio_cita", {
tenant: tenantId,
language: "es",
variables: ["Ana", "mañana 10:00"],
});
await sendo.send.text(patientPhone, "Te esperamos.", { tenant: tenantId });
await sendo.templates.sync({ tenant: tenantId }); // every active number of the tenanttemplates.sync({ tenant })pulls the templates of every active number of the tenant;templates.list({ tenant })lists them.- Replies (
reply, agents,sendo.inbox.reply) always go out from the conversation’s own number; they don’t need either option.
Resolution order#
| You pass | Sendo uses |
|---|---|
account | That account (id, phone number id or Account). With tenant too, an account of another tenant throws AccountNotFoundError. |
tenant | The tenant’s default active number; without a default, its only active number. Several and no default: AmbiguousAccountError. None: AccountNotFoundError. |
| neither | The only active number of the deployment; several: AmbiguousAccountError, whose message suggests passing { tenant }. |
AmbiguousAccountError carries candidates (only that tenant’s account ids when tenant was given) and tenantId. AccountNotFoundError carries account and tenantId.
try {
await sendo.send.text(patientPhone, "Hola", { tenant: tenantId });
} catch (error) {
// Two or more active numbers in the tenant and none is the default.
if (error instanceof AmbiguousAccountError) console.error(error.tenantId, error.candidates);
}Default number#
Each tenant has at most one default number (the accounts without a tenant count as one tenant); the database enforces it with a unique index.
- The first number added to a tenant becomes its default (
accounts.add, Embedded Signup). sendo.accounts.setDefault(id)moves the default atomically: the old one is unset and the new one set in one transaction, so concurrent calls never leave two defaults.- When the default is disconnected and exactly one active number is left, that one becomes the default; otherwise the tenant has none until you pick one.
- Each change emits
account.default.
sendo.accounts#
| Method | Does |
|---|---|
list({ tenant?, includeInactive? }) | Accounts, oldest first. Disconnected ones only with includeInactive. |
get(id) | One account, connected or not. |
add(input) | Registers a number (the code version of sendo accounts add). A number disconnected in the same tenant is reconnected with its id and history. |
setDefault(id) | Makes it the tenant’s default. Disconnected numbers throw AccountStateError. |
setLabel(id, label) | Name shown in the inbox (e.g. "Recepción"); blank clears it. |
pauseAgent(id, paused, { by? }) | Stops (or resumes) AI turns on every conversation of the number. |
setAgentMode(id, "auto" | "draft" | null) | Per-number agent mode; null keeps the agent definition’s own mode. |
disconnect(id, { by? }) | See Disconnecting. |
purge(id) | See Deleting an organization’s data. |
refreshBilling(id) | Re-reads the payment method on Meta (Embedded Signup). |
completeEmbeddedSignup(input) | Finishes Embedded Signup from the numbers screen. |
Every id can also be the Meta phone number id. An unknown one throws AccountNotFoundError. These are server-side methods: check the actor’s numbers.manage capability in your server actions before calling them.
const numbers = await sendo.accounts.list({ tenant: tenantId, includeInactive: true });
const added = await sendo.accounts.add({
tenantId,
phoneNumberId: "333333333333333",
wabaId: "444444444444444",
displayPhone: "+502 5555 3333",
tokenRef: "SENDO_TOKEN_CLINICA_ESTE", // env var NAME, never the token
label: "Recepción",
}); // the tenant's first number becomes its default
await sendo.accounts.setDefault(added.id); // atomic swap within the tenant
await sendo.accounts.setLabel(added.id, "Recepción principal");
await sendo.accounts.pauseAgent(added.id, true, { by: "user_123" });
await sendo.accounts.setAgentMode(added.id, "draft"); // null = the agent's own modeDisconnecting#
disconnect(id):
- sets
active = falseand clearsis_default; inbound webhooks for the number becomeunroutedand sends throwAccountNotFoundError; - promotes the tenant’s only remaining active number to default, if there is exactly one;
- for Embedded Signup numbers, unsubscribes your app from the WABA (
DELETE /{waba_id}/subscribed_apps), best effort: a failure is logged, never thrown; - calls your
deleteToken(tokenRef)hook, if configured (a failure is logged); - keeps contacts, conversations and messages, so the inbox still shows the history, read-only (out of the inbox’s default “All active numbers” view; “Show disconnected”, picking the number or a search brings them back);
- emits
account.disconnectedand, when the default changed,account.default.
Connecting the same number again in the same tenant reuses the row: same account id, the old conversations come back, with the new tokenRef. Connecting it from another tenant throws DuplicateAccountError (ACCOUNT_EXISTS).
Deleting an organization’s data#
purge(id) deletes a disconnected account and, by cascade, its contacts, conversations, messages, media rows and templates, then deletes the media objects from storage. It throws AccountStateError (ACCOUNT_STATE) on an active account and deletes nothing, so the order is always disconnect then purge. There is no undo; when to call it is your app’s decision. Export the history first if you need a copy (Retention).
Disconnecting never deletes. On the numbers screen (@sendo/numbers) admins get Export conversations on every number, and Delete history on disconnected ones only when the app sets numbersConfig.allowPurge = true (off by default; the admin types the full number to confirm). See Retention.
// Stops sending and routing, keeps the history readable, promotes the tenant's
// only remaining number to default, calls deleteToken(tokenRef).
await sendo.accounts.disconnect(accountId, { by: "user_123" });
// Later, when the organization asks for its data to be deleted:
try {
await sendo.accounts.purge(accountId); // rows + media objects, no undo
} catch (error) {
if (error instanceof AccountStateError) console.error("Disconnect it first");
}One agent for many tenants#
A SaaS with 100 organizations doesn’t declare 100 agents. Register one definition and read the tenant from the conversation’s number: instructions and every tool receive account, and account.tenantId is the organization.
tenants?is a list of tenant ids (nullis the accounts without a tenant) or a predicate(tenantId) => boolean. Default: every tenant.- It is combined with
accounts?: an agent answers on a number only when both match. - Only one agent may match a number. When both definitions use lists, Sendo checks
accountsandtenantstogether at registration and throwsConfigErroron an overlap; overlaps through predicates are logged when a message arrives and no agent answers on that number until you fix them. mode?: "auto" | "draft"is the definition’s default; a number’s own mode (accounts.setAgentMode) wins.- No turn runs while the number (
accounts.pauseAgent) or the conversation is paused. Inbound messages are still stored and still count as unread and open the 24-hour window. A turn already running when the pause is set finishes.
// One definition for every organization: the tenant comes from the conversation's number.
const clinics = new Map([
["6f1c0c3e-0000-4000-8000-000000000001", { name: "Clínica Norte", hours: "8:00 a 17:00" }],
["6f1c0c3e-0000-4000-8000-000000000002", { name: "Clínica Sur", hours: "9:00 a 13:00" }],
]);
sendo.agent({
name: "recepcion",
tenants: (tenantId) => tenantId !== null && clinics.has(tenantId), // or a list of tenant ids
model: "openai/gpt-5-mini",
instructions: ({ account }) => {
const clinic = clinics.get(account.tenantId ?? "");
return `Eres la recepción de ${clinic?.name ?? "la clínica"}. Horario: ${clinic?.hours ?? "consultar"}.`;
},
});
// A different agent for one organization: lists that overlap throw ConfigError at registration.
sendo.agent({
name: "recepcion-pediatria",
tenants: ["6f1c0c3e-0000-4000-8000-000000000003"],
model: "openai/gpt-5-mini",
instructions: "Eres la recepción de la clínica pediátrica.",
});Events#
| Event | Payload | When |
|---|---|---|
account.default | { tenantId, account, previous } | The tenant’s default changed; account is null when it has none. |
account.disconnected | { account, by } | A number was disconnected. |
account.billing | { account, billingReady, previous } | The payment-method state of a number changed. |
sendo.on("account.default", ({ tenantId, account, previous }) => {
console.log(
"default of",
tenantId,
"is now",
account?.displayPhone ?? "none",
"was",
previous?.id,
);
});
sendo.on("account.disconnected", ({ account, by }) => {
console.log(account.displayPhone, "disconnected by", by);
});Config#
| Option | Type | Notes |
|---|---|---|
storeToken | ({ account, token }) => Promise<string> | Stores an Embedded Signup token, returns the tokenRef. Required for Embedded Signup. |
deleteToken | (tokenRef) => Promise<void> | Called by disconnect. |
meta | { appId?, configId? } | Your Meta app, for Embedded Signup. |
coexistence | { pauseAgentOnPhoneReply?, importHistory? } | Numbers shared with the WhatsApp Business app (Coexistence). Defaults true and false. |
From the terminal#
npx @quami/sendo accounts list [--tenant-id <uuid>] [--json]
npx @quami/sendo accounts set-default <id>
npx @quami/sendo accounts disconnect <id>Details in CLI.