Catalog & Procedures
Define and organize your notification procedures
A procedure is a contract for one notification. It binds a channel, an input schema, and the configuration needed to render and deliver the message. A catalog is a typed map of procedures, the single object that every other layer in Better-Notify derives its types from.
Procedures
A procedure is built through the fluent API exposed by createNotify. You pick a channel, declare the input schema, and configure the channel-specific slots:
import { createNotify } from '@betternotify/core';
import { emailChannel } from '@betternotify/email';
import { z } from 'zod';
const email = emailChannel();
const rpc = createNotify({ channels: { email } });
const welcome = rpc
.email()
.input(z.object({ name: z.string(), verifyUrl: z.string().url() }))
.subject(({ input }) => `Welcome, ${input.name}!`)
.template({
render: async ({ input }) => ({
html: `<h1>Welcome, ${input.name}!</h1>`,
text: `Welcome, ${input.name}!`,
}),
});Each builder call returns a new builder with the accumulated state. Nothing is mutated. The procedure does not exist as a finalized definition until you pass it into .catalog().
The slots available on the builder (.subject(), .template(), .from(), .tags(), etc.) are defined by the channel. Email has its set, SMS has .body(), push has .title() and .body(). See Channels for the full slot API per channel.
Middleware on procedures
Procedures can carry their own middleware via .use(). Procedure-level middleware wraps the render-and-send core, inside any plugin middleware:
const passwordReset = rpc
.email()
.input(z.object({ name: z.string(), resetUrl: z.string().url() }))
.subject(() => 'Reset your password')
.template({ render: async ({ input }) => ({ html: `...`, text: `...` }) })
.use(withRateLimit({ max: 3, window: '1h', key: ({ args }) => args.to }));Catalogs
rpc.catalog() takes a map of procedures and returns a typed catalog:
const catalog = rpc.catalog({
welcome,
passwordReset,
});The keys become the route IDs, the canonical identifiers used throughout the system. createClient uses them to build the typed client surface: mail.welcome.send(...), mail.passwordReset.send(...).
Merging catalogs
.catalog() accepts both procedures and other catalogs at the same level. Each catalog is standalone; you merge them into a bigger catalog for the full notification surface:
const transactional = rpc.catalog({
welcome,
passwordReset,
});
const marketing = rpc.catalog({
newsletter,
});
const catalog = rpc.catalog({
transactional,
marketing,
systemAlert,
});When catalogs merge, the nesting flattens into dot-path route IDs:
The route ID is the canonical identifier for a procedure across the entire system. It appears as:
- The
routefield in logger output - The
routeon a queuedJobEnvelope - The correlation key in webhook events
- The
routeparameter in middleware and hooks
This means you can filter logs, query jobs, or match webhooks by route ID without mapping between different naming schemes.
The typed client surface
createClient takes a catalog and a transport per channel, then builds a client object that mirrors the catalog structure:
import { createClient } from '@betternotify/core';
const mail = createClient({
catalog,
transportsByChannel: { email: smtpTransport({ host: '...' }) },
});Each procedure exposes three methods:
Prop
Type
await mail.transactional.welcome.send({
to: 'ada@example.com',
input: { name: 'Ada', verifyUrl: 'https://...' },
});
const preview = await mail.transactional.welcome.render(
{ name: 'Ada', verifyUrl: 'https://...' },
);Batch sends
Every procedure exposes .batch(). It sends to multiple recipients sequentially and collects per-entry results. This works on any channel, not just email:
const batch = await mail.marketing.newsletter.batch(
subscribers.map((s) => ({
to: s.email,
input: { headline: 'This week', bodyUrl: 'https://...' },
})),
{ interval: 100 },
);
console.log(batch.okCount); // number of successful sends
console.log(batch.errorCount); // number of failed sendsEach entry runs through the full pipeline independently: validation, middleware, render, transport. A failure on one entry does not stop the rest. The result contains per-entry outcomes:
for (const entry of batch.results) {
if (entry.status === 'ok') {
console.log(`[${entry.index}] sent: ${entry.result.messageId}`);
} else {
console.log(`[${entry.index}] failed: ${entry.error.message}`);
}
}Prop
Type
Handling failed entries
Since batch results include the original index, you can collect failed entries and retry them, persist them for later, or push them into a queue:
const failed = batch.results.filter((r) => r.status === 'error');
if (failed.length > 0) {
const originalEntries = subscribers.map((s) => ({
to: s.email,
input: { headline: 'This week', bodyUrl: 'https://...' },
}));
const failedEntries = failed.map((f) => ({
entry: originalEntries[f.index],
error: f.error.message,
code: f.error.code,
}));
await db.insert('failed_notifications', failedEntries);
}For automated retries, enqueue failed entries through .queue() so a worker retries them with backoff:
for (const failure of failed) {
await mail.marketing.newsletter.queue(originalEntries[failure.index], {
bullmq: { delay: 60_000 },
});
}This requires a queue producer on the client. See Queue & Workers for the full enqueue, worker, and dead-letter model.
Batch does not retry internally; each entry gets one attempt. If you need automatic retries with backoff, enqueue failed entries with .queue() or wrap the batch call with your own retry logic.
Type inference
Better-Notify exports utility types that extract procedure information from a catalog. These are useful when you need to type function parameters or return values against your catalog:
import type { InputOf, OutputOf, CtxOf } from '@betternotify/core';
type WelcomeInput = InputOf<typeof catalog, 'transactional.welcome'>;
// { name: string; verifyUrl: string }
type CatalogCtx = CtxOf<typeof catalog>;InputOf<R, K> extracts the validated input type for a procedure by its route ID. OutputOf<R, K> extracts the output type. CtxOf<R> extracts the context type that the catalog's middleware chain produces.
Root-level middleware and context
createNotify returns a builder that can carry root-level middleware via .use(). Root middleware runs on every procedure in the catalog and can narrow the context type:
const rpc = createNotify({ channels: { email } })
.use<{ tenantId: string }>(async ({ next }) => {
const tenantId = await resolveTenant();
return next({ tenantId });
});After .use(), the Ctx type parameter narrows. Every procedure in the catalog can access ctx.tenantId, and the type system enforces that the middleware provides it. Multiple .use() calls chain; each one merges its additions into the context type.
For a full walkthrough of building procedures, merging catalogs, and a working client from scratch, see Your First Email.