MainSDK
Search⌘K
Interfaces

Messaging and notifications

Notifications

notify

Reach a person on push, WhatsApp, SMS or email with one call that follows their channel choice, opt-outs, consent and quiet hours.

Building for 0.3ships as a stable APIPhase 1 · Core, public betaSpec email-notifyFree in your app, Apache-2.0

Status 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.
Stability levels
Updated 2026-10-28

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 }

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?)

Guarantees

Whatever the driver.

  • Opt-outs and quiet hours respected
  • Every SMS recipient has a recorded opt-in
  • WhatsApp only with recorded consent and approved templates
  • No personal data in push payloads

Drivers

15 drivers. Select one to see its line in main.yaml.

main.yamlWhatsApp Cloud API
notify:
  driver: whatsapp-cloud

Change this one line to swap providers; your calls stay the same.WhatsApp integration

Codes

CodeWhere it appearsMeaning
MAIN-NOTIFY-0001Checkmainsdk check, before deployThe notify section of main.yaml is invalid or a provider block is missing (400)
MAIN-NOTIFY-0006Checkmainsdk check, before deployA quiet-hours time is not HH:MM or a time zone is not a known zone (400)
MAIN-CHECK-0026Checkmainsdk check, before deployAn interface resolves to a local driver in production
MAIN-NOTIFY-0002RuntimeError responses and logsSee the spec
MAIN-NOTIFY-0003RuntimeError responses and logsSMS skipped: no recorded opt-in
MAIN-NOTIFY-0004RuntimeError responses and logsWhatsApp skipped: no consent
MAIN-NOTIFY-0005RuntimeError responses and logsSee the spec
MAIN-NOTIFY-0007RuntimeError responses and logsSee the spec
MAIN-NOTIFY-0008RuntimeError responses and logsSee the spec
MAIN-NOTIFY-0009RuntimeError responses and logsSee the spec
MAIN-NOTIFY-0010RuntimeError responses and logsSee the spec
MAIN-NOTIFY-0011RuntimeError responses and logsSee the spec

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
notify@1 contractPhase 10.3email-notify#contract
Channel choice, preferences and opt-outsPhase 10.3email-notify#notify-routing
Quiet hours and digestsPhase 10.3email-notify#quiet-hours
Staged rollout with auto-pausePhase 10.3email-notify#rollout
Idempotent sendsPhase 10.3email-notify#idempotency
Twilio and MSG91 SMS driversPhase 10.3email-notify#drivers-notify
WhatsApp Cloud API, Bird and MSG91 channel driversPhase 10.3email-notify#drivers-whatsapp
Push through FCM, APNs and ExpoPhase 31.1expo-adapter#push-through-notify

Quick start

Declare the channels, the drivers and a template for each channel, then send with a category.

main.yaml
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." }
app/orders/ship.ts
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.

main.yaml
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] }
KeyTypeDefaultWhat it does
channelslist[email]Channels to try, in order: push, whatsapp, sms, email
smsstringnonetwilio, msg91 or local. With none, SMS is skipped
whatsappstringnonewhatsapp-cloud, bird, msg91 or local. With none, WhatsApp is skipped
quiet.from, quiet.to"HH:MM"noneDefault quiet hours; each person can set their own
quiet.tzIANA zoneUTCThe zone for people with none of their own
marketinglist[marketing]Categories whose email goes as marketing, with an unsubscribe link
rollout.stageslist 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, .errorspercentage2%, 0.1%, 1%, noneA stage at or above a threshold pauses the broadcast
rollout.settleduration0Wait after a stage for bounces to arrive before judging it
limits.sms-per-day, limits.whatsapp-per-dayintegernoneDaily caps to bound cost. More is skipped with MAIN-NOTIFY-0010
templates.<name>.smsmapnonetext and dlt-template-id, or template and vars (see below)
templates.<name>.whatsappmapnonename (the approved template), language, params
templates.<name>.emailstringthe notification's nameThe email template to use
twilio, msg91, whatsapp-cloud, birdmapnoneSettings 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) with sms.vars.
  • India (+91) numbers need sms.dlt-template-id. Without it SMS is skipped with MAIN-NOTIFY-0005.

Push through FCM, APNs and Expo comes with the Expo adapter, configured as notify.push:

main.yaml
notify:
  push: { drivers: [fcm, apns], via: expo }   # phase 3

Until 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:

main.in.yaml
notify:
  channels: [whatsapp, sms, push, email]
  whatsapp: bird
  sms: msg91

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

app/api/invoices/[id]/remind/route.ts
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.

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

app/settings/phone/actions.ts
"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.

app/settings/notifications/actions.ts
"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.

app/security/alerts.ts
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.

server/orders.ts
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.

app/campaigns/spring-sale.ts
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.

app/engagement/come-back.ts
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:

main.yaml
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 app

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

app/api/settings/contact/route.ts
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.

CallWhat 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

CallWhat 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 email directly.

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.

CodeWhereMeaning and fix
MAIN-NOTIFY-0001check, runtimeThe notify section is invalid or a provider block is missing. Add the block the driver needs
MAIN-NOTIFY-0002runtimeThe call is malformed: no person id, template or category, a missing data value, or a broadcast with no recipients
MAIN-NOTIFY-0003runtimeSMS skipped: no recorded opt-in. Call notify.consent.create(user, "sms") when the person agrees
MAIN-NOTIFY-0004runtimeWhatsApp skipped: no recorded consent. Record it first; business-initiated messages also need an approved template
MAIN-NOTIFY-0005runtimeSMS skipped: an India number with no dlt-template-id
MAIN-NOTIFY-0006check, runtimeA quiet-hours time is not HH:MM, or a time zone is not a known zone
MAIN-NOTIFY-0007runtimeThe channel provider could not deliver; the next channel is tried (502)
MAIN-NOTIFY-0008runtimePush skipped: the payload would carry personal data. Send an id and load details in the app
MAIN-NOTIFY-0009runtimeThe broadcast is missing, finished or cannot move (409)
MAIN-NOTIFY-0010runtimeThe daily SMS or WhatsApp cap is reached
MAIN-NOTIFY-0011runtimenotify.sms or notify.whatsapp is a local channel in production; the app refuses to start
MAIN-CHECK-0026checkmainsdk 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.

main.yaml
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.

ChannelDriversPages
SMStwilio, msg91, localTwilio, MSG91
WhatsAppwhatsapp-cloud, bird, msg91, localWhatsApp, Bird, MSG91
Emailthrough emailResend, Amazon SES
Pushnot builtnone

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.

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" });
  });
});

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 data and 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.allow does 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

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet