Transports Overview
Transports

Transports Overview

How transports deliver your notifications

A transport is the wire-level adapter that takes a fully-rendered message and delivers it to a provider — SMTP relay, Resend HTTP endpoint, or anything else that can accept an outbound message.

Transports sit at the bottom of the Better-Notify pipeline. By the time a transport's send is called, the input has been validated, the template rendered, and middleware has run. The transport's only job is delivery.

The Transport type

Every transport — built-in or custom — satisfies this shape:

type Transport<TRendered = unknown, TData = unknown> = {
  readonly name: string;
  send(rendered: TRendered, ctx: SendContext): Promise<TransportResult<TData>>;
  verify?(): Promise<{ ok: boolean; details?: unknown }>;
  close?(): Promise<void>;
};

name identifies the transport in logs, error messages, and multi-transport selection. send does the actual delivery. verify and close are optional lifecycle hooks for startup checks and graceful shutdown.

SendContext

Each send call receives a context object alongside the rendered message:

Prop

Type

TransportResult

send returns a discriminated union:

type TransportResult<TData = unknown> =
  | { ok: true; data: TData }
  | { ok: false; error: Error };

Return { ok: true, data } on success with whatever provider-specific data you want to surface. Return { ok: false, error } on controlled failure — this triggers failover in multi-transport strategies. Throwing works too; both paths are handled the same way.

Available transports

provider ships as a separate package with its own peer dependencies. core lives in @betternotify/core/transports — no extra install needed. The channel badge shows which channel the transport delivers to. any means the transport works with any channel.

Wiring a transport to a client

Transports are passed to createClient via the transportsByChannel option. Each channel gets one transport:

import { createClient } from '@betternotify/core';
import { smtpTransport } from '@betternotify/smtp';

const mail = createClient({
  catalog,
  transportsByChannel: {
    email: smtpTransport({
      host: 'smtp.example.com',
      port: 587,
      auth: { user: process.env.SMTP_USER!, pass: process.env.SMTP_PASS! },
    }),
  },
});

That single transport slot accepts any Transport — a provider package, a custom transport, or a multi-transport composite that wraps several providers together.

Swapping transports by environment

Because the catalog and client are separate, you can swap transports without touching route definitions:

import { createMockTransport } from '@betternotify/core/transports';
import { smtpTransport } from '@betternotify/smtp';

const transport =
  process.env.NODE_ENV === 'test'
    ? createMockTransport()
    : smtpTransport({
        host: 'smtp.example.com',
        port: 587,
        auth: { user: process.env.SMTP_USER!, pass: process.env.SMTP_PASS! },
      });

const mail = createClient({
  catalog,
  transportsByChannel: { email: transport },
});

The same pattern works for local development (mock), staging (single provider), and production (multi-transport failover).

Lifecycle hooks

Transports can optionally implement verify and close. Together with send, they form the full transport lifecycle:

send loop ok: true ok: false ok: true ok: false throws App starts verify Transport ready Log warning, transport degraded App shuts down close Sockets and clients released Receive rendered message send Return data to pipeline Return error to pipeline
  • verify() runs during startup to check credentials, test connectivity, or confirm configuration. Returns { ok: boolean; details?: unknown }.
  • send() runs on every delivery. Receives a fully-rendered message and a SendContext. Returns a TransportResult.
  • close() runs during graceful shutdown to release sockets, flush buffers, or close SDK clients.

Both verify and close propagate through multi-transport composites automatically. If you only need send, skip them — createTransport fills in no-op defaults.

HTTP configuration

All HTTP-backed transports (Slack, Telegram, Discord, Resend, Cloudflare Email, GitHub, Twilio, Zapier) accept an optional http object for low-level HTTP tuning — timeouts, retry, and request lifecycle hooks:

import { slackTransport } from '@betternotify/slack';

const transport = slackTransport({
  token: process.env.SLACK_BOT_TOKEN!,
  http: {
    timeoutMs: 5_000,
    retry: {
      type: 'exponential',
      attempts: 3,
      baseDelay: 250,
      maxDelay: 2_000,
      shouldRetry: (response) => response?.status === 503,
    },
    onRetry: (ctx) => {
      console.log('retrying request…');
    },
  },
});

Prop

Type

Retry vs. multi-transport vs. queue retry

HTTP retry, multi-transport retry, and queue retry operate at different layers:

LayerScopeUse case
http.retrySingle HTTP requestTransient 503s, rate-limit backoff
multiTransportAcross providersFailover from Resend to SMTP
Queue retry (BullMQ)Entire send attemptRetry the full pipeline after a crash

Each layer is independent. A single transport can use HTTP retry without multiTransport, and vice versa.

Where to go next

On this page