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 category | Who receives it | Skipped as |
|---|---|---|
MARKETING | Only contacts with marketingOptIn = true (an explicit yes). | no_opt_in |
UTILITY, AUTHENTICATION | Every contact who hasn't explicitly opted out. | opted_out |
| Any category Sendo doesn't know | Treated as MARKETING. | no_opt_in |
- Opted out means
marketingOptIn = falsewith a decision date (optInAt): the contact said no, by keyword or through your app. A contact nobody asked yet hasoptInAt = null: they get utility messages ("tu cobro de noviembre fue emitido") but not promotions. - Phones that don't parse are skipped as
invalid_phoneand never stored as contacts. - Only APPROVED, active templates can be broadcast (
TEMPLATE_NOT_FOUNDotherwise). Sync them first withsendo.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).
// 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.
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.
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: "…" }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 → cancelledcreatestores the broadcast asqueuedand each recipient aspendingorskippedwith the reason. Nothing is sent yet.- The tick sends it.
sendo.maintenance.tick()(every minute, see the tick) sends up toratePerMinutepending recipients per broadcast (default 60), using the same conversation as any other message. A broadcast never moves a conversation tohumanand never runs the agent for the template; replies land in the normal conversation and the inbox. - Status webhooks move each recipient to
sent,delivered,readorfailed(with Meta's error). The broadcast endsdonewhen nothing is pending. cancel(id)turns the pending recipients intocancelled. 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'sbillingReadybecomesfalse. Pending recipients stay pending. Once the business adds a payment method and Meta confirms it (the tick re-checks billing hourly, or callsendo.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
failedand its pending recipients are cancelled.
The Broadcasts screen (broadcasts)#
npx shadcn add @sendo/broadcastsThe Broadcasts screen at /inbox/broadcasts (admins only):
- 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.
- 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.
- 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.