← back
Packages · SMS

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.

sending through twilio? use the package

@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

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 sendingprovider-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",
  }),
);
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.