Notifications
notifyReach a person on push, WhatsApp, SMS or email with one call that follows their channel choice, opt-outs, consent and quiet hours.
email-notifyFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
notify reaches a person with one call. It picks the channel in the order you configure, skips channels the person opted out of, and holds non-urgent messages until quiet hours end.
At a glance
notify:
channels: [whatsapp, sms, email]
sms: msg91
whatsapp: whatsapp-cloud
quiet: { from: "22:00", to: "08:00", tz: Asia/Kolkata }
rollout: { stages: ["1%", "10%", "100%"], pause-if: { bounces: "2%", complaints: "0.1%", unsubscribes: "1%" } }
templates:
order.shipped:
sms: { text: "Order {{order}} shipped", dlt-template-id: "1007000000000001" }
whatsapp: { name: order_shipped, language: en, params: [order] }
whatsapp-cloud: { phone-number-id: "1234567890", token: secret:WHATSAPP_TOKEN }
msg91: { key: secret:MSG91_KEY, sender: ACMEIN }
limits: { sms-per-day: 3, whatsapp-per-day: 5 }import { notify } from "mainsdk";
await notify(user, {
template: "order.shipped",
data: { orderId: order.id },
category: "orders",
});app/orders/ship.test.ts
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { notify } from "mainsdk";
import { channels, createTestApp } from "@mainsdk/testing";
describe("order shipped", () => {
it("email-notify AC2 uses WhatsApp when the person consented", async () => {
const app = createTestApp({
yaml: [
"schema: 1",
"project: shop",
"notify:",
" channels: [whatsapp, email]",
" whatsapp: local",
" templates:",
" order.shipped:",
" whatsapp: { name: order_shipped, language: en, params: [orderId] }",
].join("\n"),
});
const user = { id: "u1", email: "jane@example.com", phone: "+919876543210" };
const before = await notify(user, { template: "order.shipped", data: { orderId: "A1" }, category: "orders" });
assert.equal(before.skipped[0]?.code, "MAIN-NOTIFY-0004");
await notify.consent.create(user, "whatsapp", { source: "test" });
const after = await notify(user, { template: "order.shipped", data: { orderId: "A2" }, category: "orders" });
assert.equal(after.channel, "whatsapp");
assert.deepEqual(channels(app).whatsapp?.last()?.params, { orderId: "A2" });
});
});Calls
notify(to, { template, data, category, channels?, urgent?, idempotencyKey? })notify.preferences(user)notify.preferences(user).set(patch)notify.contact.get(user)notify.contact.update(user, patch)notify.consent.create(user, channel, opts?)notify.consent.get(user, channel)notify.consent.revoke(user, channel)notify.release(id)notify.broadcast({ id, template, data, category, to })notify.rollout.get(id)notify.rollout.resume(id, opts?)notify.rollout.advance(id)notify.raw()notify.info()notify.push.register()createNotify(deps)parseNotify(raw)twilioSms(o)msg91Sms(o)msg91WhatsApp(o)birdWhatsApp(o)whatsappCloudChannel(o)localChannel(id, o?)
Drivers
15 drivers. Select one to see its line in main.yaml.
notify:
driver: whatsapp-cloudChange this one line to swap providers; your calls stay the same.WhatsApp integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-NOTIFY-0001 | Checkmainsdk check, before deploy | The notify section of main.yaml is invalid or a provider block is missing (400) |
MAIN-NOTIFY-0006 | Checkmainsdk check, before deploy | A quiet-hours time is not HH:MM or a time zone is not a known zone (400) |
MAIN-CHECK-0026 | Checkmainsdk check, before deploy | An interface resolves to a local driver in production |
MAIN-NOTIFY-0002 | RuntimeError responses and logs | See the spec |
MAIN-NOTIFY-0003 | RuntimeError responses and logs | SMS skipped: no recorded opt-in |
MAIN-NOTIFY-0004 | RuntimeError responses and logs | WhatsApp skipped: no consent |
MAIN-NOTIFY-0005 | RuntimeError responses and logs | See the spec |
MAIN-NOTIFY-0007 | RuntimeError responses and logs | See the spec |
MAIN-NOTIFY-0008 | RuntimeError responses and logs | See the spec |
MAIN-NOTIFY-0009 | RuntimeError responses and logs | See the spec |
MAIN-NOTIFY-0010 | RuntimeError responses and logs | See the spec |
MAIN-NOTIFY-0011 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| notify@1 contract | Phase 1 | 0.3 | email-notify#contract |
| Channel choice, preferences and opt-outs | Phase 1 | 0.3 | email-notify#notify-routing |
| Quiet hours and digests | Phase 1 | 0.3 | email-notify#quiet-hours |
| Staged rollout with auto-pause | Phase 1 | 0.3 | email-notify#rollout |
| Idempotent sends | Phase 1 | 0.3 | email-notify#idempotency |
| Twilio and MSG91 SMS drivers | Phase 1 | 0.3 | email-notify#drivers-notify |
| WhatsApp Cloud API, Bird and MSG91 channel drivers | Phase 1 | 0.3 | email-notify#drivers-whatsapp |
| Push through FCM, APNs and Expo | Phase 3 | 1.1 | expo-adapter#push-through-notify |
Quick start
Declare the channels, the drivers and a template for each channel, then send with a category.
notify:
channels: [whatsapp, sms, email]
whatsapp: whatsapp-cloud
sms: msg91
templates:
order.shipped:
whatsapp: { name: order_shipped, language: en, params: [orderId] }
sms: { template: "64f0c0ffee", vars: [orderId], dlt-template-id: "1007000000000001" }
email: shipped
whatsapp-cloud: { phone-number-id: "1234567890", token: secret:WHATSAPP_TOKEN }
msg91: { key: secret:MSG91_AUTH_KEY }
email:
driver: resend
key: secret:RESEND_API_KEY
from: "Shop <shop@example.com>"
templates:
shipped: { subject: "Order {{orderId}} shipped", text: "Order {{orderId}} is on its way." }import { notify } from "mainsdk";
export async function orderShipped(user: { id: string; email: string; phone: string }, orderId: string) {
return notify(user, {
template: "order.shipped",
data: { orderId },
category: "orders",
});
}notify resolves the person's preferences and consent, tries each channel in order, and returns a Delivery: { id, status, channel?, providerId?, skipped, deliverAt? }. status is sent, held (waiting for quiet hours to end) or undelivered (no channel could take it). skipped lists every channel not used, with a reason and, when a rule decided it, a MAIN-NOTIFY-* code.
The first argument is a user id, or { id, email?, phone?, whatsapp?, tz?, locale? }. Contact details you pass are used for this call. Save them once with notify.contact.update(user, patch) and later calls can pass only the id.
Configuration
Every key lives under notify:. The email channel uses your email block. A bad key or value stops the app at load with MAIN-NOTIFY-0001, naming the key; a bad quiet-hours time or zone is MAIN-NOTIFY-0006. Provider keys are secret: references; a literal key fails with MAIN-CONFIG-0004.
notify:
channels: [push, whatsapp, sms, email] # the order tried
sms: msg91 # twilio | msg91 | local
whatsapp: whatsapp-cloud # whatsapp-cloud | bird | msg91 | local
quiet: { from: "22:00", to: "08:00", tz: Asia/Kolkata }
marketing: [marketing]
rollout:
stages: ["1%", "10%", "100%"]
pause-if: { bounces: "2%", complaints: "0.1%", unsubscribes: "1%" }
settle: 10m
limits: { sms-per-day: 3, whatsapp-per-day: 5 }
templates:
order.shipped:
sms: { text: "Order {{orderId}} shipped", dlt-template-id: "1007000000000001" }
whatsapp: { name: order_shipped, language: en, params: [orderId] }| Key | Type | Default | What it does |
|---|---|---|---|
channels | list | [email] | Channels to try, in order: push, whatsapp, sms, email |
sms | string | none | twilio, msg91 or local. With none, SMS is skipped |
whatsapp | string | none | whatsapp-cloud, bird, msg91 or local. With none, WhatsApp is skipped |
quiet.from, quiet.to | "HH:MM" | none | Default quiet hours; each person can set their own |
quiet.tz | IANA zone | UTC | The zone for people with none of their own |
marketing | list | [marketing] | Categories whose email goes as marketing, with an unsubscribe link |
rollout.stages | list of percentages | ["1%", "10%", "100%"] | The share reached by the end of each stage. It must grow and end at "100%" |
rollout.pause-if.bounces, .complaints, .unsubscribes, .errors | percentage | 2%, 0.1%, 1%, none | A stage at or above a threshold pauses the broadcast |
rollout.settle | duration | 0 | Wait after a stage for bounces to arrive before judging it |
limits.sms-per-day, limits.whatsapp-per-day | integer | none | Daily caps to bound cost. More is skipped with MAIN-NOTIFY-0010 |
templates.<name>.sms | map | none | text and dlt-template-id, or template and vars (see below) |
templates.<name>.whatsapp | map | none | name (the approved template), language, params |
templates.<name>.email | string | the notification's name | The email template to use |
twilio, msg91, whatsapp-cloud, bird | map | none | Settings of the provider you named. See the integration pages |
Every key has a row in the main.yaml reference. push and engagement are separate, below.
Templates for each channel
A notification has one template name and a form on each channel. {{name}} in SMS text fills from data. For WhatsApp and for provider SMS templates, params and vars list the data keys in the order the template takes its variables; a missing or non-scalar value fails with MAIN-NOTIFY-0002. A channel with no template entry for this name is skipped with the reason no-template.
- Twilio sends
sms.text. - MSG91 sends
sms.template(its flow template id) withsms.vars. - India (+91) numbers need
sms.dlt-template-id. Without it SMS is skipped withMAIN-NOTIFY-0005.
Push through FCM, APNs and Expo comes with the Expo adapter, configured as notify.push:
notify:
push: { drivers: [fcm, apns], via: expo } # phase 3Until a push driver exists, the push channel is skipped with the reason no-driver. A push payload carries no personal data: a value that looks like an email or phone number, or a name, email or phone key, skips push with MAIN-NOTIFY-0008.
Overlays by market
India users are reached on WhatsApp and SMS first. Put only the difference in the market overlay:
notify:
channels: [whatsapp, sms, push, email]
whatsapp: bird
sms: msg91See main.yaml for overlay order and merge rules.
Examples
Notify from a Next.js route handler
A billing admin reminds a customer about an unpaid invoice. The idempotency key ties the send to the invoice, so a double-click or a retried request notifies once.
import { auth, models, notify, s } from "mainsdk";
const { invoices } = models({
invoices: s.object({ customerId: s.string() }),
}); // declared once in lib/invoices.ts, shown inline so the example stands alone
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await auth.require(req, { role: "admin" });
const invoice = await invoices.for(session.user).get(id);
const delivery = await notify({ id: invoice.customerId }, {
template: "invoice.reminder",
data: { invoiceId: invoice.id },
category: "billing",
idempotencyKey: `invoice.reminder:${invoice.id}`,
});
return Response.json(delivery);
}Every send takes an idempotency key. When you leave it out, MainSDK makes one from the person, template, category and data. A retry returns the first delivery.
Record consent for SMS and WhatsApp
Every SMS and WhatsApp recipient needs a recorded opt-in for the number they have now. Record it when the person agrees, with where they agreed. Without it the channel is skipped with MAIN-NOTIFY-0003 (SMS) or MAIN-NOTIFY-0004 (WhatsApp).
"use server";
import { auth, notify } from "mainsdk";
export async function agreeToWhatsApp(req: Request, phone: string) {
const session = await auth.require(req);
await notify.contact.update(session.user, { phone });
return notify.consent.create(session.user, "whatsapp", { source: "settings-page" });
}
export async function stopWhatsApp(req: Request) {
const session = await auth.require(req);
return notify.consent.revoke(session.user, "whatsapp"); // true when there was consent
}
export async function whenAgreed(userId: string) {
return notify.consent.get(userId, "whatsapp"); // { at, source } or null
}Consent belongs to the number. If the person's number changes, record consent again. The number must be in E.164 form, such as +919876543210. Records hold a keyed hash of the number, not the number, and each change is written to the audit log.
Let people choose channels and quiet hours
Read and change a person's preferences from a settings page. Quiet hours apply in the person's own time zone.
"use server";
import { auth, notify } from "mainsdk";
export async function getPreferences(req: Request) {
const session = await auth.require(req);
return notify.preferences(session.user); // { channels, optOut, quiet? }
}
export async function savePreferences(req: Request, tz: string) {
const session = await auth.require(req);
await notify.preferences(session.user).set({
quiet: { from: "22:00", to: "08:00", tz }, // e.g. "Asia/Kolkata"
channels: { sms: false }, // never by SMS
optOut: ["marketing"], // never this category
});
}set changes only what you pass. quiet: null clears the person's own hours. A person who opted out of a category gets nothing in it, and the delivery says category-opted-out for each channel.
Urgent messages and quiet hours
A non-urgent notification sent at 23:15 IST to someone with quiet hours 22:00 to 08:00 in Asia/Kolkata comes back held with deliverAt at 08:00 IST. MainSDK schedules a job that calls notify.release(id) then. Mark security messages urgent so they go at once.
import { notify } from "mainsdk";
export async function newDeviceAlert(user: { id: string }, deviceName: string) {
await notify(user, {
template: "security.new_device",
data: { deviceName },
category: "security",
urgent: true, // skips quiet hours
});
}
export async function weeklyMention(user: { id: string }, threadId: string) {
return notify(user, {
template: "thread.mentioned",
data: { threadId },
category: "mentions", // non-urgent: held in quiet hours
});
}notify.release(id) is called by the job runner. It returns null for an id that is not held, the held delivery again if called early, or the delivery once it goes.
Send from a background job
Notifications usually come from a job, not the request. This route handler publishes an event; a jobs handler sends the notification. It takes a web-standard Request: call it from a Next.js route handler, or from Hono with c.req.raw.
import { auth, events, notify } from "mainsdk";
export async function shipOrder(req: Request, orderId: string) {
const session = await auth.require(req);
await events.publish("order.shipped", { orderId, userId: session.user });
return Response.json({ ok: true });
}
events.on<{ orderId: string; userId: string }>("order.shipped", async (event) => {
await notify({ id: event.data.userId }, {
template: "order.shipped",
data: { orderId: event.data.orderId },
category: "orders",
idempotencyKey: `order.shipped:${event.data.orderId}`,
});
});Roll out a broadcast in stages
notify.broadcast sends one notification to an audience in stages. Each stage is judged by the bounces, complaints, unsubscribes and errors it caused. When a threshold is crossed, the broadcast pauses before the next stage and writes notify.rollout_paused to the audit log. The id is 8 to 64 letters, digits, _ or -; calling again with the same id returns the broadcast as it is, and nobody is sent to twice.
import { notify } from "mainsdk";
export async function launch(audience: { id: string; email: string }[]) {
const rollout = await notify.broadcast({
id: "spring-sale-2026",
template: "sale.spring",
category: "marketing",
data: { code: "SPRING" },
to: audience,
});
return rollout; // { id, status, nextStage, stages, total, tried, counts, reason? }
}
export async function afterFixingTheTemplate() {
const state = await notify.rollout.get("spring-sale-2026");
if (state?.status === "paused") {
return notify.rollout.resume("spring-sale-2026", { reason: "template fixed" });
}
return state;
}With stages 1%, 10%, 100% and a 2% bounce threshold, 3 bounces in the first 100 pause a broadcast of 10,000 before stage two. notify.rollout.resume(id, { reason? }) continues a paused broadcast and judges the next stage on what happens after the resume. notify.rollout.advance(id) judges and continues a broadcast that waited out rollout.settle; the job runner calls it. Only email counts against the thresholds today: bounces, complaints and unsubscribes come from email events, and errors counts recipients that no channel could take, as a share of those tried.
Engagement messages
Reminders that bring people back are engagement messages. They run on the same channels as notify, under stricter rules: a weekly cap, quiet hours that always apply, and an automatic stop after a run of ignored messages. A journey sends a ladder of reminders and stops as soon as the person returns.
import { journey, ladder } from "mainsdk/notify";
export const comeBack = journey("come-back", { version: 1, start: "user.inactive", category: "reminders" },
async ({ user }) => ladder(user, [
{ after: "1d", template: "reminder.gentle" },
{ after: "7d", template: "reminder.last" },
], { sendAt: "usual" }));Set the limits once in main.yaml. Each one is a guardrail that the app cannot loosen at run time:
notify:
engagement:
max-per-week: 4 # required
quiet-hours: "21:00-08:00" # in each person's time zone
stop-after-ignored: 5
guardian: { controls: [pause, frequency, quiet-hours, view] } # needed when children may use the appFor children's audiences, copy with streak-loss or guilt pressure, or comparative social proof, is never sent (MAIN-ENGAGE-0005). engagement.status(user) shows how many messages a person has had this week. Every engagement message also carries a one-step stop for its category (a one-click unsubscribe on email, a stop action on push, a STOP keyword on WhatsApp and SMS). Pages and calls are in Engagement journeys; the steps are in the engagement messages guide.
A one-step stop on a message
notify takes stop: { handle } on a call. With it, email goes as marketing mail with the RFC 8058 one-click List-Unsubscribe header (it needs email.unsubscribe-url; the link's campaign is stop:<handle>:<category>), and a push message carries stop: { category, handle } for the device's stop action. The handle names the person by a keyed hash, never by id or address. engagement sets it on every message it sends; an app may set it on its own messages and call engagement.stopByHandle(handle, { channel }) when the action fires.
Push tokens
notify.push.register() asks the person's permission on their device and stores the push token for them. It runs in an Expo app and is planned for phase 3 (spec expo-adapter). After it, notify() can use the push channel for that person.
The call is not built and not in the catalog yet, so there is no example here. Push stays skipped until it ships.
Contact details
notify.contact.get(user) returns the contact details notify would use for a person: a { id, email?, phone?, whatsapp?, tz?, locale? } object. It merges what the host app resolves for that id, what you saved with notify.contact.update, and anything you pass in user when it is an object, with later sources winning. Use it to show a person which number a message will go to.
import { auth, notify } from "mainsdk";
export async function GET(req: Request) {
const session = await auth.require(req);
const contact = await notify.contact.get(session.user);
return Response.json({ phone: contact.phone, whatsapp: contact.whatsapp });
}Reference
These functions build notify and its channel drivers. mainsdk calls them from the notify section of main.yaml, so most apps never do. They matter when you wire a driver by hand or write a test.
| Call | What it does |
|---|---|
createNotify(deps) | Build the notify interface from its ports: the parsed config, the email calls, optional sms, whatsapp and push drivers, a store, a log and a salt that keys the hashes of ids and numbers. Optional resolve fills in a person's email and phone from your own records, consent answers from your own consent records, and schedule asks the job runner to call notify.release(id) at a time |
parseNotify(raw) | Read and check the notify section. A key it doesn't know throws MAIN-NOTIFY-0001, and a missing section tries email only |
twilioSms(o) | The Twilio SMS driver. It needs account, secret (the auth token, or an API key's secret when user is that key's SID), fetch, and either from or messagingService. It posts to /Accounts/{sid}/Messages.json with basic auth |
msg91Sms(o) | The MSG91 SMS driver. It needs authKey and fetch, and sends through MSG91's Flow API, so each message needs the MSG91 flow template id in notify.templates.<name>.sms.template |
whatsappCloudChannel(o) | The WhatsApp Cloud API driver. It needs phoneNumberId, token and fetch, and sends a template message to /{phone-number-id}/messages (API version v21.0 unless you set version) |
birdWhatsApp(o) | The Bird WhatsApp driver. It needs workspace, channel, key and fetch, and sends a template message on that channel with named parameters |
msg91WhatsApp(o) | The MSG91 WhatsApp driver. It needs authKey, number, namespace and fetch, and sends through MSG91's outbound bulk endpoint with numbered body components |
localChannel(id, o?) | A channel that keeps what was sent in memory, prints one masked line when you pass print, and appends each message as a JSON line to file when you pass it. It has sent, last(to?), clear() and failNext(times, retryable?) to simulate an outage |
Every provider driver takes its own fetch, and an optional baseUrl that must be https; anything else fails with MAIN-NOTIFY-0001. A driver declares its host, so the app can allow that host and no other.
Helpers for wiring and tests
| Call | What it does |
|---|---|
createNotify(deps) | Build the notify interface from its dependencies, for tests and custom wiring. |
parseNotify(raw) | Read the notify section of main.yaml into settings. |
twilioSms(o) | The Twilio SMS driver, for the notify channel wiring. |
msg91Sms(o) | The MSG91 SMS driver. |
msg91WhatsApp(o) | The MSG91 WhatsApp driver. |
birdWhatsApp(o) | The Bird WhatsApp driver. |
whatsappCloudChannel(o) | The WhatsApp Cloud API channel. |
localChannel(id, o?) | A local channel that keeps what was sent in memory and prints one masked line, for development and tests. |
What you get
- The channel is picked in the order you configure: push, then WhatsApp, then SMS, then email.
- Channels the person opted out of or never consented to are skipped, and the code is recorded on the delivery.
- Non-urgent messages are held until the person's quiet hours end.
- Reach for it whenever your app tells a user that something happened: an order shipped, a payment failed, a teammate mentioned them. For plain transactional or marketing email with no channel choice, use
emaildirectly.
Errors and checks
Errors are MainError values that serialise as RFC 9457 problem details with a code, a fix and a docs link. When notify skips a channel, the code is recorded on the delivery and the next channel is tried. A provider that fails is skipped with the reason failed and MAIN-NOTIFY-0007.
| Code | Where | Meaning and fix |
|---|---|---|
MAIN-NOTIFY-0001 | check, runtime | The notify section is invalid or a provider block is missing. Add the block the driver needs |
MAIN-NOTIFY-0002 | runtime | The call is malformed: no person id, template or category, a missing data value, or a broadcast with no recipients |
MAIN-NOTIFY-0003 | runtime | SMS skipped: no recorded opt-in. Call notify.consent.create(user, "sms") when the person agrees |
MAIN-NOTIFY-0004 | runtime | WhatsApp skipped: no recorded consent. Record it first; business-initiated messages also need an approved template |
MAIN-NOTIFY-0005 | runtime | SMS skipped: an India number with no dlt-template-id |
MAIN-NOTIFY-0006 | check, runtime | A quiet-hours time is not HH:MM, or a time zone is not a known zone |
MAIN-NOTIFY-0007 | runtime | The channel provider could not deliver; the next channel is tried (502) |
MAIN-NOTIFY-0008 | runtime | Push skipped: the payload would carry personal data. Send an id and load details in the app |
MAIN-NOTIFY-0009 | runtime | The broadcast is missing, finished or cannot move (409) |
MAIN-NOTIFY-0010 | runtime | The daily SMS or WhatsApp cap is reached |
MAIN-NOTIFY-0011 | runtime | notify.sms or notify.whatsapp is a local channel in production; the app refuses to start |
MAIN-CHECK-0026 | check | mainsdk check --env prod found a local SMS or WhatsApp channel |
The checks run in the email gate of mainsdk check. Run mainsdk explain MAIN-NOTIFY-0003 for the cause and fix offline. All codes are in check codes and errors.
Swapping drivers
Change one line. Your notify() calls, templates and preferences stay the same.
notify:
whatsapp: msg91 # was whatsapp-cloud
msg91: { key: secret:MSG91_AUTH_KEY, whatsapp: { number: "919876500000", namespace: acme_ns } }Add the new provider's block with it. Sending is stateless, so the swap needs no migration, but sender numbers and approved templates belong to the provider: register them there first.
| Channel | Drivers | Pages |
|---|---|---|
| SMS | twilio, msg91, local | Twilio, MSG91 |
whatsapp-cloud, bird, msg91, local | WhatsApp, Bird, MSG91 | |
through email | Resend, Amazon SES | |
| Push | not built | none |
notify.raw() returns the channel drivers in use, as { sms?, whatsapp?, push? }. notify.info() returns { channels, sms?, whatsapp?, push?, quiet? }, which mainsdk info shows. A local channel in production stops the app with MAIN-NOTIFY-0011.
Testing
createTestApp() runs notify on local channels when notify.sms or notify.whatsapp is local, and email on the local mailbox. channels(app) from @mainsdk/testing returns the SMS and WhatsApp channels; mailbox(app) returns the mailbox. The same rules apply as in production: opt-outs, consent, quiet hours and idempotency.
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { notify } from "mainsdk";
import { channels, createTestApp } from "@mainsdk/testing";
describe("order shipped", () => {
it("email-notify AC2 uses WhatsApp when the person consented", async () => {
const app = createTestApp({
yaml: [
"schema: 1",
"project: shop",
"notify:",
" channels: [whatsapp, email]",
" whatsapp: local",
" templates:",
" order.shipped:",
" whatsapp: { name: order_shipped, language: en, params: [orderId] }",
].join("\n"),
});
const user = { id: "u1", email: "jane@example.com", phone: "+919876543210" };
const before = await notify(user, { template: "order.shipped", data: { orderId: "A1" }, category: "orders" });
assert.equal(before.skipped[0]?.code, "MAIN-NOTIFY-0004");
await notify.consent.create(user, "whatsapp", { source: "test" });
const after = await notify(user, { template: "order.shipped", data: { orderId: "A2" }, category: "orders" });
assert.equal(after.channel, "whatsapp");
assert.deepEqual(channels(app).whatsapp?.last()?.params, { orderId: "A2" });
});
});A local channel has sent, last(to?), clear() and failNext(times, retryable?). Move the test clock with app.clock.advance("9h") to pass quiet hours without waiting, then call notify.release(id) for the held delivery. fakeMessagingServer("twilio"), "msg91", "whatsapp-cloud" and "bird" record the requests a real driver makes, with no network.
In mainsdk dev, local SMS and WhatsApp channels log one line per message with the number masked, and emails land in the local mailbox.
Security and privacy
- Opt-outs and quiet hours are respected on every send. Every SMS recipient needs a recorded opt-in, and India SMS uses DLT-registered templates.
- WhatsApp goes only to recipients with recorded consent, and business-initiated messages use provider-approved templates.
- Push payloads carry no personal data. Put an ID in
dataand load the details in the app. - Stored ids, numbers and consent records use keyed hashes. Logs and audit records show a masked number.
- A retried send with the same idempotency key never sends twice.
- Each driver declares its host, so
network.allowdoes not change:api.twilio.com,control.msg91.com,graph.facebook.com,api.bird.com.
FAQ
Which channel will a person get? The first channel in channels that they have not opted out of, that they consented to (SMS and WhatsApp), that has a driver and a template, and that is under its daily cap. The delivery records every channel skipped and why.
Can I force a channel? Pass channels on the call to narrow the list for that message. Opt-outs and consent still apply.
Does notify replace email? No. Use email for receipts and marketing that must go by email; use notify when any channel will do.
Is web push supported? Not built. Until a push driver is configured, the push channel is skipped.
Where are consent and opt-in records kept? In your app's kv store, per tenant and in its region, under a keyed hash of the person's id. Each record holds when and where the person agreed.
Next
- Email: transactional and marketing email, the email channel behind
notify. - Events and background jobs: send notifications from jobs that retry safely.
- Privacy and data rights: record consent per purpose.
- Workflows: the journeys behind
ladderrun as workflows.
Was this page helpful?