Broadcasts and opt-in

Send one approved template to many contacts: who is eligible by category, opt-in and opt-out keywords, the tick, billing pauses and the Broadcasts screen.

A broadcast sends one APPROVED WhatsApp template to a list of contacts, a batch per minute, and keeps a result per recipient. Sendo decides who may receive it from the template's category and the contact's opt-in, and you can't override that: the preview tells you how many will get it and why the others are skipped, before anything is sent.

Who receives a broadcast#

Template categoryWho receives itSkipped as
MARKETINGOnly contacts with marketingOptIn = true (an explicit yes).no_opt_in
UTILITY, AUTHENTICATIONEvery contact who hasn't explicitly opted out.opted_out
Any category Sendo doesn't knowTreated as MARKETING.no_opt_in
  • Opted out means marketingOptIn = false with a decision date (optInAt): the contact said no, by keyword or through your app. A contact nobody asked yet has optInAt = null: they get utility messages ("tu cobro de noviembre fue emitido") but not promotions.
  • Phones that don't parse are skipped as invalid_phone and never stored as contacts.
  • Only APPROVED, active templates can be broadcast (TEMPLATE_NOT_FOUND otherwise). Sync them first with sendo.templates.sync().
  • Eligibility is checked again when each message is sent, so a contact who replies BAJA in the middle of a broadcast doesn't get it.

Opt-in and opt-out#

Record a contact's explicit decision with sendo.contacts.setOptIn(contactId, optIn, { source }). It sets marketingOptIn and optInAt, and emits contact.opt_in (repeating the stored decision is a no-op).

ts
// The contact said yes in your signup form: MARKETING broadcasts may reach them now.
await sendo.contacts.setOptIn(patientContactId, true, { source: "form" });

sendo.on("contact.opt_in", ({ contact, optIn, source, keyword }) => {
  // source "keyword" + keyword "baja" when they replied BAJA; mirror it in your own records.
  console.log(contact.id, optIn, source, keyword);
});

Opt-out keywords. An inbound message that is exactly one of the keywords (default BAJA, STOP, ALTO, CANCELAR), after trimming and ignoring case and accents (" Cancelár " matches), opts the contact out: marketingOptIn = false, contact.opt_in with source: "keyword", a confirmation message inside the 24-hour window (optOut.confirmation, default BUILT_IN_MESSAGES[locale].optOutConfirmation, so English unless createSendo({ locale: "es" })), and no agent turn for that message. "Quiero darme de baja" doesn't match; handle phrases in your agent or a rule if you want them.

ts
const sendo = createSendo({
  adapter: memoryAdapter(),
  appSecret: process.env.SENDO_META_APP_SECRET ?? "",
  verifyToken: process.env.SENDO_VERIFY_TOKEN ?? "",
  resolveToken: (account) => process.env[account.tokenRef],
  // Optional: these are the defaults. `keywords: []` turns keyword opt-out off.
  optOut: {
    keywords: ["BAJA", "STOP", "ALTO", "CANCELAR"],
    confirmation:
      "Listo, ya no te enviaremos mensajes promocionales. Si cambias de opinión, escríbenos.",
  },
});

Add the opt-out sentence to your marketing templates ("Responde BAJA si no quieres recibir promociones") so contacts know how.

Preview, create and follow#

Every sendo.broadcasts method takes an admin actor (broadcasts.manage). Recipients are contact ids, { phone } (upserted as a contact on create) or either with its own variables, which win over the shared ones.

ts
const input = {
  actor: admin,
  template: "recordatorio_cita",
  language: "es",
  recipients: [
    { phone: "+502 5555 0101", variables: ["Ana", "lunes 12"] },
    { contactId: patientContactId, variables: ["Luis", "martes 13"] },
  ],
  ratePerMinute: 60,
};
const preview = await sendo.broadcasts.preview(input);
// { total: 2, eligible: 2, skipped: { no_opt_in: 0, opted_out: 0, invalid_phone: 0 },
//   category: "UTILITY", ratePerMinute: 60, estimatedMinutes: 1, accountId: "…" }
ts
const broadcast = await sendo.broadcasts.create(input); // status "queued"; the tick sends it
const progress = await sendo.broadcasts.get(broadcast.id, { actor: admin });
// progress.counts → { pending, sent, delivered, read, failed, skipped, cancelled }
const failed = await sendo.broadcasts.recipients(broadcast.id, { actor: admin, status: "failed" });
// failed.items[0].error → { code: 131026, title: "Message undeliverable", message: "…" }
await sendo.broadcasts.cancel(broadcast.id, { actor: admin }); // pending → cancelled
  • create stores the broadcast as queued and each recipient as pending or skipped with the reason. Nothing is sent yet.
  • The tick sends it. sendo.maintenance.tick() (every minute, see the tick) sends up to ratePerMinute pending recipients per broadcast (default 60), using the same conversation as any other message. A broadcast never moves a conversation to human and never runs the agent for the template; replies land in the normal conversation and the inbox.
  • Status webhooks move each recipient to sent, delivered, read or failed (with Meta's error). The broadcast ends done when nothing is pending.
  • cancel(id) turns the pending recipients into cancelled. Messages already sent stay sent.

Billing pauses and limits#

Meta bills every delivered message directly to the WhatsApp Business account that owns the number, by category and the recipient's country. Sendo moves no money and never copies Meta's rates: see Meta's official pricing.

  • No payment method (Meta 131042): the broadcast pauses with pauseReason: "billing" and the number's billingReady becomes false. Pending recipients stay pending. Once the business adds a payment method and Meta confirms it (the tick re-checks billing hourly, or call sendo.accounts.refreshBilling(id)), the next tick resumes the broadcast by itself.
  • Rate or tier limits (130429 and the other rate-limit codes): that minute's pass stops and the recipient stays pending; the next tick continues. Meta also caps how many unique contacts a number can reach per day by its messaging tier.
  • Template no longer approved: the broadcast stops as failed and its pending recipients are cancelled.

The Broadcasts screen (broadcasts)#

shell
npx shadcn add @sendo/broadcasts

The Broadcasts screen at /inbox/broadcasts (admins only):

  1. New broadcast: pick a number, an approved template (with its preview and the category rule), its variables, one of your app's contact lists or filters, and the rate.
  2. Review broadcast: total, eligible and skipped counts by reason, the estimated time, and the cost note linking to Meta's pricing. "Send to N contacts" is disabled when nobody is eligible.
  3. Progress: counts per state (refreshed every 5 seconds while sending), per-recipient results with Meta's error on failed rows, "Cancel broadcast", and a notice with a link to add a payment method when the broadcast is paused for billing.

Before using it, edit app/(protected)/inbox/broadcasts/broadcasts-config.ts: implement requireUser() (it throws until you do) and return your audiences from broadcastAudiences. Recipients are always resolved on the server from the audience id; the browser never sends a phone list. The screen also needs the tick route from workflows. Details in the item's AGENTS.md.

The screen renders in English or Spanish: the page passes locale={sendoLocale} from lib/sendo-ui.ts, and counts and dates follow the optional formatLocale. Override single texts with the strings prop or edit components/sendo/broadcasts/strings.ts; see Languages.