Channels
Email, SMS, push, and custom notification channels
A channel defines the shape of one notification type. Email has to, subject, and html. SMS has to and body. Push has title, body, and device tokens. Each channel declares the arguments a send expects, the slots a procedure must configure, and the render function that produces the output a transport delivers.
Built-in channels
Better-Notify ships three channels as separate packages:
Rich HTML and plain text messages delivered through SMTP, Resend, or any email transport.
SMS
Text messages delivered to phone numbers through your SMS provider.
Push
Push notifications delivered to device tokens with title, body, and optional data payload.
Each package exports a channel factory (e.g. emailChannel()) that you pass to createNotify. The factory can accept channel-level defaults — for example, a shared from address for all email procedures.
import { emailChannel } from '@betternotify/email';
const email = emailChannel({
defaults: {
from: { name: 'My App', email: 'hello@myapp.com' },
},
});
const rpc = createNotify({ channels: { email } });Every procedure built with rpc.email() inherits the defaults. Individual procedures can override them.
Channels and transports
A channel's render function produces a typed output — RenderedMessage for email, RenderedSms for SMS, your own type for custom channels. A transport receives that output and delivers it. The channel never knows which transport is running, and the transport never sees raw input or slot configuration.
This separation is what makes transports swappable. The same email channel works with SMTP, Resend, or a mock — because they all accept RenderedMessage. See Transports for how delivery works.
One route, one channel
Every procedure binds to exactly one channel. When you call rpc.email(), that route is an email route — it cannot also be an SMS route or a Discord route. The type system enforces this: each channel defines its own slots (.subject() and .template() for email, .body() for SMS, .embeds() for Discord), and they are not interchangeable.
To reach users across multiple channels, define a separate procedure per channel and orchestrate sends at the application level:
const rpc = createNotify({ channels: { email, discord, telegram } });
const catalog = rpc.catalog({
welcomeEmail: rpc.email().input(welcomeInput).subject(/* ... */).template(/* ... */),
welcomeDiscord: rpc.discord().input(welcomeInput).body(/* ... */).embeds(/* ... */),
welcomeTelegram: rpc.telegram().input(welcomeInput).body(/* ... */).parseMode('HTML'),
});After creating the client, send to each channel independently — fire them in parallel when order does not matter:
const [emailResult, discordResult, telegramResult] = await Promise.all([
mail.welcomeEmail.send({ to: 'ada@example.com', input }),
mail.welcomeDiscord.send({ input }),
mail.welcomeTelegram.send({ to: chatId, input }),
]);| Do | Don't | |
|---|---|---|
| Routing | One procedure per channel — welcomeEmail, welcomeSms | Try to make one procedure send to multiple channels |
| Shared input | Extract a shared Zod schema and pass it to each procedure's .input() | Duplicate the schema definition across procedures |
| Cross-channel dispatch | Orchestrate with Promise.all or sequential await at the call site | Expect a single .send() to fan out across channels |
| Failover across providers | Use multiTransport with 'failover' strategy within one channel | Mix transports from different channels in multiTransport |
| Shared defaults | Set channel-level defaults via emailChannel({ defaults }) | Override channel-specific config from another channel's transport |
This constraint is intentional. Each channel has different rendering logic, addressing requirements, and slot shapes — mixing them in a single route would break type safety and make the pipeline unpredictable. Separate routes keep each channel independently testable and deployable.
Custom channels
Any notification type you can model as "validate args → resolve slots → render output" fits as a channel. defineChannel and slot are the only imports you need:
import { defineChannel, slot } from '@betternotify/core';
import { z } from 'zod';
const slackChannel = defineChannel({
name: 'slack' as const,
slots: { text: slot.resolver<string>() },
validateArgs: z.object({
channel: z.string(),
threadTs: z.string().optional(),
}),
render: ({ runtime, args }) => ({
channel: args.channel,
text: runtime.text,
threadTs: args.threadTs,
}),
});Pass the custom channel to createNotify and the builder exposes rpc.slack() with a typed .text() slot — same pattern as the built-in channels.
For a full walkthrough with transport implementation, batch sends, and testing, see Custom Channels.
Anatomy of a channel
Under the hood, every channel — built-in or custom — is built with defineChannel and has four parts:
Prop
Type
Slots
Slots define what a procedure must configure and how the values are resolved at send time. There are two kinds:
Resolver slots accept a static value or a function that receives { input, ctx } and runs at send time. Use resolvers when the value depends on the procedure's input or context:
import { slot } from '@betternotify/core';
const subject = slot.resolver<string>();
// Procedure can set it as static or dynamic:
rpc.email().subject('Welcome!')
rpc.email().subject(({ input }) => `Welcome, ${input.name}!`)Value slots accept a static value only. Use values when the configuration is fixed at definition time:
const template = slot.value<TemplateAdapter>();
rpc.email().template({ render: async ({ input }) => ({ html: '...', text: '...' }) })Both kinds are required by default. Call .optional() to make a slot optional — the procedure can omit it, and render receives undefined:
const tags = slot.value<Tags>().optional();
const priority = slot.value<Priority>().optional();When the catalog finalizes, Better-Notify checks that every required slot has been set. A missing required slot throws at startup, not at send time.