Custom SMS transports
@notifkit/provider-twilio covers SMS out of the box.
This page is for the other case: sending through Vonage, AWS SNS, Infobip or anything else,
which takes about thirty lines of transport code.
@notifkit/provider-twilio is a complete first-party
SMS transport, with status callbacks, signature verification, and STOP replies feeding the
suppression list. Register it and none of this page applies. What follows is for a provider
it does not cover.
sms is a first-class channel throughout the engine. You can store phone
contacts, write SMS templates, target the channel from notify(), and it
participates in fallback chains, quiet hours, throttling and suppression exactly as email
does. Only the last step, talking to a provider, is transport-specific.
Until some SMS transport is registered, an SMS send reaches Delivery, finds no transport
for the channel, and is recorded as a failed row with no_transport. Nothing is
silently dropped, but nothing is sent either.
What the engine already handles
| Feature | State |
|---|---|
| Phone contacts on a user | Works: phone on an inline user, or POST /v1/users/:id/contacts |
channel: "sms" templates | Works: text is the key a transport reads |
| Targeting, segments, scheduling | Works |
| Quiet hours, throttling, opt-outs | Works. The engine gates SMS like any other channel |
| Suppression | Works, but only if your transport reports it; see below |
| Fallback to or from SMS | Works |
| Actually sending | provider-twilio, or the transport below |
Writing one
The interface is small. Declare the channel, read text, send it, map the result.
The example below posts to Twilio's REST API because it is the one most people recognise;
swap the endpoint and the body for your own provider, and reach for
@notifkit/provider-twilio if Twilio really is your
provider.
import type { Transport, NotificationDispatchedPayload, DeliveryResult } from "notifkit";
export class CustomSmsTransport implements Transport {
readonly channel = "sms" as const;
// Twilio's default is 1 message/second per number. Declaring it lets the
// delivery worker pace sends instead of collecting 429s.
readonly limits = { limit: 1, windowSeconds: 1 };
constructor(private opts: { accountSid: string; authToken: string; from: string }) {}
async send(task: NotificationDispatchedPayload): Promise<DeliveryResult> {
// destination is optional on the payload — report it, never substitute.
if (!task.destination) {
return { success: false, error: "no destination resolved for sms" };
}
const content = task.renderedContent.content as Record<string, unknown>;
const body = (content.text ?? content.body) as string | undefined;
if (!body) return { success: false, error: "template produced no text" };
const auth = Buffer.from(`${this.opts.accountSid}:${this.opts.authToken}`).toString("base64");
const res = await fetch(
`https://api.twilio.com/2010-04-01/Accounts/${this.opts.accountSid}/Messages.json`,
{
method: "POST",
headers: {
Authorization: `Basic ${auth}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({ To: task.destination, From: this.opts.from, Body: body }),
// The delivery worker races send() against its own 10s timeout and
// passes an AbortSignal — honour it instead of leaking the request.
signal: (task as any).signal,
},
);
const json = (await res.json()) as { sid?: string; message?: string };
if (!res.ok) return { success: false, error: json.message ?? `twilio ${res.status}` };
return { success: true, providerMessageId: json.sid ?? "" };
}
}
import { registerTransport } from "notifkit";
registerTransport(
new CustomSmsTransport({
accountSid: process.env.TWILIO_SID!,
authToken: process.env.TWILIO_TOKEN!,
from: "+15005550006",
}),
);
The send() above is enough to deliver, and not enough to comply. An SMS opt-out
arrives as an inbound STOP message from the carrier, which reaches you as a
provider webhook, so a transport with no parseWebhook has no way to record
one, and the person keeps receiving messages they have explicitly refused.
Implement verifyWebhook and parseWebhook, and set
recipient on the event you return: the delivery log records the message, not
the destination, so without that field an opt-out can be logged but not acted on.
Channels & fallback covers both hooks.
ConsoleTransport with
{ channel: "sms" } gives you the whole pipeline without a Twilio account, and
Testing has a capturing transport to assert against. The
registry is covered by the repository's suite, but a transport you write is not.