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:
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 aSendContext. Returns aTransportResult.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:
| Layer | Scope | Use case |
|---|---|---|
http.retry | Single HTTP request | Transient 503s, rate-limit backoff |
multiTransport | Across providers | Failover from Resend to SMTP |
| Queue retry (BullMQ) | Entire send attempt | Retry 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
- Read Custom Transports to build your own provider adapter.
- Read Multi-Transport to compose transports with failover, load balancing, or redundancy.
- See the provider pages (SMTP, Amazon SES, Resend) for provider-specific setup.