---
title: "Notifications"
description: "Reach a person on push, WhatsApp, SMS or email with one call that follows their channel choice, opt-outs, consent and quiet hours."
section: "interfaces"
status: "Building for 0.3 · ships as a stable API"
spec: "email-notify"
canonical: "https://mainsdk.dev/docs/interfaces/notify"
updated: "2026-10-28"
---

# Notifications

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

Status: Building for 0.3 · ships as a stable API · Canonical: https://mainsdk.dev/docs/interfaces/notify · 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.

> **Note:** Built: `notify()`, preferences, contact details, recorded consent, quiet hours, staged broadcasts with auto-pause, and the SMS drivers `twilio` and `msg91`, the WhatsApp drivers `whatsapp-cloud`, `bird` and `msg91`, and a local channel for each. Not built: push (the channel is skipped until a push driver exists, which comes with the Expo adapter) and digests (a held message goes out alone when quiet hours end). The provider request shapes are written from each provider's documentation and are unverified against the live API; verify with a sandbox key before production.

## At a glance

- Status: Building for 0.3 · ships as a stable API
- Phase: 1 (Core, public beta)
- Milestone: 0.3 Every interface
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/email-notify.md
- Category: Messaging and notifications

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

In main.yaml:

```yaml
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 }
```

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

Pick one in main.yaml with `notify.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| WhatsApp Cloud API | `whatsapp-cloud` |  |  |
| Bird | `bird` |  |  |
| MSG91 | `msg91` |  |  |
| Twilio | `twilio` |  |  |
| Local terminal channel | `local` |  |  |
| Firebase Cloud Messaging | `fcm` |  |  |
| Apple Push Notification service | `apns` |  |  |
| Expo | `expo` |  |  |
| Resend | `resend` |  | planned driver |
| Amazon SES | `ses` |  | planned driver |
| OneSignal | `onesignal` |  | planned driver |
| Slack (alerts) | `slack` |  |  |
| PagerDuty (alerts) | `pagerduty` |  |  |
| Opsgenie (alerts) | `opsgenie` |  |  |
| Signed webhook (alerts) | `webhook` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-NOTIFY-0001` | check | The `notify` section of `main.yaml` is invalid or a provider block is missing (400) |
| `MAIN-NOTIFY-0006` | check | A quiet-hours time is not `HH:MM` or a time zone is not a known zone (400) |
| `MAIN-CHECK-0026` | check | An interface resolves to a local driver in production |
| `MAIN-NOTIFY-0002` | runtime |  |
| `MAIN-NOTIFY-0003` | runtime | SMS skipped: no recorded opt-in |
| `MAIN-NOTIFY-0004` | runtime | WhatsApp skipped: no consent |
| `MAIN-NOTIFY-0005` | runtime |  |
| `MAIN-NOTIFY-0007` | runtime |  |
| `MAIN-NOTIFY-0008` | runtime |  |
| `MAIN-NOTIFY-0009` | runtime |  |
| `MAIN-NOTIFY-0010` | runtime |  |
| `MAIN-NOTIFY-0011` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| notify@1 contract | 1 | 0.3 | `email-notify#contract` |
| Channel choice, preferences and opt-outs | 1 | 0.3 | `email-notify#notify-routing` |
| Quiet hours and digests | 1 | 0.3 | `email-notify#quiet-hours` |
| Staged rollout with auto-pause | 1 | 0.3 | `email-notify#rollout` |
| Idempotent sends | 1 | 0.3 | `email-notify#idempotency` |
| Twilio and MSG91 SMS drivers | 1 | 0.3 | `email-notify#drivers-notify` |
| WhatsApp Cloud API, Bird and MSG91 channel drivers | 1 | 0.3 | `email-notify#drivers-whatsapp` |
| Push through FCM, APNs and Expo | 3 | 1.1 | `expo-adapter#push-through-notify` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

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

```yaml title="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." }
```

```ts title="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`](https://mainsdk.dev/docs/interfaces/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`.

```yaml title="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] }
```

| 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](https://mainsdk.dev/docs/reference/main-yaml). `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`:

```yaml title="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:

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

See [main.yaml](https://mainsdk.dev/docs/concepts/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.

```ts title="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.

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

```ts title="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.

```ts title="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.

```ts title="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`](https://mainsdk.dev/docs/interfaces/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`.

```ts title="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.

```ts title="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.

```ts title="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:

```yaml title="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](https://mainsdk.dev/docs/interfaces/engagement); the steps are in the [engagement messages guide](https://mainsdk.dev/docs/guides/engagement-messages).

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

```ts title="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.

| 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 [`email`](https://mainsdk.dev/docs/interfaces/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`.

| 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](https://mainsdk.dev/docs/reference/check-codes) and [errors](https://mainsdk.dev/docs/reference/errors).

## Swapping drivers

Change one line. Your `notify()` calls, templates and preferences stay the same.

```yaml title="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.

| Channel | Drivers | Pages |
| --- | --- | --- |
| SMS | `twilio`, `msg91`, `local` | [Twilio](https://mainsdk.dev/docs/integrations/twilio), [MSG91](https://mainsdk.dev/docs/integrations/msg91) |
| WhatsApp | `whatsapp-cloud`, `bird`, `msg91`, `local` | [WhatsApp](https://mainsdk.dev/docs/integrations/whatsapp), [Bird](https://mainsdk.dev/docs/integrations/bird), [MSG91](https://mainsdk.dev/docs/integrations/msg91) |
| Email | through [`email`](https://mainsdk.dev/docs/interfaces/email) | [Resend](https://mainsdk.dev/docs/integrations/resend), [Amazon SES](https://mainsdk.dev/docs/integrations/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.

```ts title="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

- [Email](https://mainsdk.dev/docs/interfaces/email): transactional and marketing email, the email channel behind `notify`.
- [Events and background jobs](https://mainsdk.dev/docs/interfaces/jobs): send notifications from jobs that retry safely.
- [Privacy and data rights](https://mainsdk.dev/docs/interfaces/privacy): record consent per purpose.
- [Workflows](https://mainsdk.dev/docs/interfaces/workflows): the journeys behind `ladder` run as workflows.
