Catalog & Procedures
Concepts

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.

catalog
procedurewelcome
channelemail
inputschema
subjectresolver
templateadapter
procedurepasswordReset
channelemail
inputschema
subjectresolver
templateadapter
procedureotpCode
channelsms
inputschema
bodyresolver

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:

rpc.catalog Route IDs Client surface transactional welcome passwordReset marketing newsletter systemAlert transactional.welcome transactional.passwordReset marketing.newsletter systemAlert mail.transactional.welcome.send() mail.transactional.passwordReset.send() mail.marketing.newsletter.send() mail.systemAlert.send()

The route ID is the canonical identifier for a procedure across the entire system. It appears as:

  • The route field in logger output
  • The route on a queued JobEnvelope
  • The correlation key in webhook events
  • The route parameter 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 sends

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

On this page