SMS
There is no SMS transport in this repository. The channel itself is fully supported — this page is what that means in practice, and the thirty lines that close the gap.
sms is a first-class channel everywhere except the last step. 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. What is missing is a transport that talks to Twilio, Vonage or MessageBird.
Until you register one, 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 already works
| 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 | You supply this |
Writing one
The interface is small. Declare the channel, read text, send it, map the result:
import type { Transport, NotificationDispatchedPayload, DeliveryResult } from "notifkit";
export class TwilioTransport 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 rather than 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 TwilioTransport({
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.