← website
Packages · SMS

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.

no sms package ships yet

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

FeatureState
Phone contacts on a userWorks — phone on an inline user, or POST /v1/users/:id/contacts
channel: "sms" templatesWorks — text is the key a transport reads
Targeting, segments, schedulingWorks
Quiet hours, throttling, opt-outsWorks — the engine gates SMS like any other channel
SuppressionWorks, but only if your transport reports it — see below
Fallback to or from SMSWorks
Actually sendingYou 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",
  }),
);
without parseWebhook, nobody can opt out

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.

test it before you trust it

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.