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.

ts
// 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 tenant
  • templates.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 passSendo uses
accountThat account (id, phone number id or Account). With tenant too, an account of another tenant throws AccountNotFoundError.
tenantThe tenant’s default active number; without a default, its only active number. Several and no default: AmbiguousAccountError. None: AccountNotFoundError.
neitherThe 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.

ts
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#

MethodDoes
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.

ts
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 mode

Disconnecting#

disconnect(id):

  1. sets active = false and clears is_default; inbound webhooks for the number become unrouted and sends throw AccountNotFoundError;
  2. promotes the tenant’s only remaining active number to default, if there is exactly one;
  3. for Embedded Signup numbers, unsubscribes your app from the WABA (DELETE /{waba_id}/subscribed_apps), best effort: a failure is logged, never thrown;
  4. calls your deleteToken(tokenRef) hook, if configured (a failure is logged);
  5. 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);
  6. emits account.disconnected and, 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.

ts
// 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 (null is 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 accounts and tenants together at registration and throws ConfigError on 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.
ts
// 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#

EventPayloadWhen
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.
ts
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#

OptionTypeNotes
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#

shell
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.