feat: add twilio as a new provider

This commit is contained in:
Suhaanth V V
2026-09-01 15:55:06 +05:30
parent f155773c47
commit 79803a60c7
8 changed files with 1072 additions and 8 deletions
+2 -1
View File
@@ -320,13 +320,14 @@ First-party packages:
- [`@notifkit/provider-resend`](./packages/provider-resend) — transactional email via Resend
- [`@notifkit/provider-fcm`](./packages/provider-fcm) — push notifications via Firebase Cloud Messaging
- [`@notifkit/provider-twilio`](./packages/provider-twilio) — SMS via Twilio, with signature-verified delivery status callbacks
For anything else, implement a simple `Transport`:
```ts
class MyTransport implements Transport {
async send(message) {
// Send through Twilio, SES, Postmark, APNs,
// Send through SES, Postmark, APNs,
// SendGrid, a custom webhook, or anything else.
}
}
+167 -7
View File
@@ -8,6 +8,7 @@
"": {
"name": "notifkit",
"version": "0.1.3",
"dev": true,
"license": "MIT",
"workspaces": [
"packages/*"
@@ -1766,6 +1767,10 @@
"resolved": "packages/provider-resend",
"link": true
},
"node_modules/@notifkit/provider-twilio": {
"resolved": "packages/provider-twilio",
"link": true
},
"node_modules/@one-ini/wasm": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
@@ -3860,7 +3865,6 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"devOptional": true,
"license": "MIT"
},
"node_modules/atomic-sleep": {
@@ -3872,6 +3876,59 @@
"node": ">=8.0.0"
}
},
"node_modules/axios": {
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
"integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.6",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/axios/node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/axios/node_modules/form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/axios/node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/b4a": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz",
@@ -4473,7 +4530,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
@@ -4835,6 +4891,12 @@
"node": "*"
}
},
"node_modules/dayjs": {
"version": "1.11.23",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -4889,7 +4951,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
@@ -5963,7 +6024,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -6819,6 +6879,26 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@@ -7483,7 +7563,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -8763,7 +8842,6 @@
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -8773,7 +8851,6 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
@@ -9665,6 +9742,15 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
@@ -10172,6 +10258,13 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/scmp": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/scmp/-/scmp-2.1.0.tgz",
"integrity": "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==",
"deprecated": "Just use Node.js's crypto.timingSafeEqual()",
"license": "BSD-3-Clause"
},
"node_modules/secure-json-parse": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
@@ -11726,6 +11819,49 @@
"dev": true,
"license": "Unlicense"
},
"node_modules/twilio": {
"version": "5.13.1",
"resolved": "https://registry.npmjs.org/twilio/-/twilio-5.13.1.tgz",
"integrity": "sha512-sT+PkhptF4Mf7t8eXFFvPQx4w5VHnBIPXbltGPMFRe+R2GxfRdMuFbuNA/cEm0aQR6LFQOn33+fhClg+TjRVqQ==",
"license": "MIT",
"dependencies": {
"axios": "^1.13.5",
"dayjs": "^1.11.9",
"https-proxy-agent": "^5.0.0",
"jsonwebtoken": "^9.0.3",
"qs": "^6.14.1",
"scmp": "^2.1.0",
"xmlbuilder": "^13.0.2"
},
"engines": {
"node": ">=14.0"
}
},
"node_modules/twilio/node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/twilio/node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -12730,6 +12866,15 @@
"node": ">=16.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-13.0.2.tgz",
"integrity": "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==",
"license": "MIT",
"engines": {
"node": ">=6.0"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
@@ -13022,6 +13167,21 @@
"peerDependencies": {
"notifkit": "^0.1.0"
}
},
"packages/provider-twilio": {
"name": "@notifkit/provider-twilio",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"twilio": "^5.0.0"
},
"devDependencies": {
"notifkit": "file:../../",
"typescript": "^5.8.3"
},
"peerDependencies": {
"notifkit": "^0.1.0"
}
}
}
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 devkitshq
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+65
View File
@@ -0,0 +1,65 @@
# @notifkit/provider-twilio
Twilio SMS transport for [notifkit](https://github.com/devkitshq/notifkit).
Sends through the Twilio Messages API and verifies delivery status callbacks
with `X-Twilio-Signature`, so undeliverable numbers and STOP replies feed back
into notifkit's suppression list automatically.
```bash
npm install @notifkit/provider-twilio
```
```ts
import { TwilioTransport } from "@notifkit/provider-twilio";
registry.register(
"sms",
new TwilioTransport({
accountSid: process.env.TWILIO_ACCOUNT_SID!,
authToken: process.env.TWILIO_AUTH_TOKEN!,
from: "+15550000001",
statusCallbackUrl: "https://api.example.com/webhooks/twilio",
}),
);
```
A recipient's `sms` contact target is the destination, in E.164
(`+15550000002`). Templates render into `text` (or `body`); a template may also
carry its own `from`, the same way other transports let a template override
their default sender — useful when a support long code and a marketing short
code share one transport.
## Delivery status callbacks
`statusCallbackUrl` must be the publicly reachable URL of this transport's
webhook route. Twilio signs the URL it was given along with the request body,
so signatures can only be checked against that exact string — including any
query string. The route is mounted at its path (`/webhooks/twilio` above), and
is only mounted at all when the URL is configured: an unverifiable callback is
an anonymous, forgeable write into delivery history, so the transport fails
closed rather than accepting one.
Twilio accepts a message before any carrier has seen it, so a successful
`send` means `queued`, not delivered. What actually happened arrives on the
callback:
| Twilio status | notifkit event |
| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `undelivered` / `failed` | `bounced` — `hard` for a permanently bad number (`21211`, `21612`, `21614`, `30005`, `30006`), `soft` otherwise |
| `undelivered` / `failed` + `21610` | `unsubscribed` — the recipient replied STOP |
| `read` | `opened` (RCS and WhatsApp only; SMS has no read receipt) |
| `queued`, `sending`, `sent`, `delivered`, `accepted`, `canceled` | ignored |
An unrecognised error code is treated as a **soft** bounce. Wrongly
suppressing a working number silently stops messages someone still wants, so
only codes known to mean the number itself is gone are hard.
## Opt-outs
Twilio handles STOP/START itself and rejects a send to an opted-out recipient
with error `21610`. That is returned as an invalid token — as are `21211`,
`21612`, and `21614` — so notifkit stops retrying and flags the contact.
Everything else, including rate limits, stays retryable.
Requires `notifkit` as a peer dependency. Documentation:
[notifkit.dev/docs](https://notifkit.dev/docs/). MIT licensed.
+43
View File
@@ -0,0 +1,43 @@
{
"name": "@notifkit/provider-twilio",
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist",
"src",
"README.md",
"LICENSE"
],
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"twilio": "^5.0.0"
},
"peerDependencies": {
"notifkit": "^0.1.0"
},
"devDependencies": {
"notifkit": "file:../../",
"typescript": "^5.8.3"
},
"description": "Twilio SMS transport for notifkit, with signature-verified delivery status callbacks.",
"license": "MIT",
"author": "devkitshq",
"homepage": "https://notifkit.dev",
"repository": {
"type": "git",
"url": "git+https://github.com/devkitshq/notifkit.git",
"directory": "packages/provider-twilio"
},
"bugs": {
"url": "https://github.com/devkitshq/notifkit/issues"
},
"publishConfig": {
"access": "public"
}
}
+305
View File
@@ -0,0 +1,305 @@
import type { DeliveryResult, Transport } from "notifkit";
import type { NotificationDispatchedPayload, Logger } from "notifkit";
import type { WebhookEvent } from "notifkit";
import type { Twilio } from "twilio";
/**
* Codes `messages.create` rejects with for a destination that is permanently
* bad, as opposed to one that is merely temporarily rejected (rate limits,
* account issues, Twilio outages). Only these suppress the contact — anything
* else is worth retrying.
* https://www.twilio.com/docs/api/errors
*/
const INVALID_DESTINATION_CODES = new Set([
21211, // Invalid 'To' phone number
21610, // Attempt to send to unsubscribed recipient (replied STOP)
21612, // Message cannot be sent with this To/From combination
21614, // 'To' number is not a valid mobile number
]);
/**
* Delivery-time codes that arrive on an `undelivered`/`failed` status callback
* and mean the number itself is gone, not that this one attempt lost.
* Everything unlisted is treated as a soft bounce — an unrecognised code must
* never suppress a number a person still uses.
*/
const HARD_BOUNCE_CODES = new Set([
21211, // Invalid 'To' phone number
21612, // Not reachable via SMS
21614, // Not a valid mobile number
30005, // Unknown destination handset — the number does not exist
30006, // Landline or unreachable carrier
]);
/** The recipient replied STOP. An opt-out, not a delivery failure. */
const OPT_OUT_CODE = 21610;
export interface TwilioTransportOptions {
accountSid: string;
authToken: string;
/**
* Sender used for any template that does not name its own, in E.164
* (`+15551234567`). A template may override it with a `from` in its
* content — see `send`.
*/
from: string;
logger?: Logger;
/**
* Publicly reachable URL of this transport's status-callback route, e.g.
* `https://api.example.com/webhooks/twilio`. Twilio signs the URL it was
* given along with the request body, so signatures can only be checked
* against the exact string configured here — including any query string.
*
* Leave it unset to send without delivery tracking; the webhook route is
* only mounted when it is present, because an unverifiable callback is an
* anonymous, forgeable write into delivery history.
*/
statusCallbackUrl?: string;
limits?: { limit: number; windowSeconds: number };
}
export class TwilioTransport implements Transport {
readonly channel = "sms" as const;
readonly webhookPath?: string;
readonly limits?: { limit: number; windowSeconds: number };
private readonly accountSid: string;
private readonly authToken: string;
private readonly from: string;
private readonly logger?: Logger;
private readonly statusCallbackUrl?: string;
private client: Twilio | null = null;
constructor({
accountSid,
authToken,
from,
logger,
statusCallbackUrl,
// Twilio queues per sender at roughly one message per second on a long
// code, so the ceiling that matters is the sending number's, not the
// account's. This is a conservative default for a single long code —
// callers on a short code or a Messaging Service should raise it.
limits = { limit: 60, windowSeconds: 60 },
}: TwilioTransportOptions) {
this.accountSid = accountSid;
this.authToken = authToken;
this.from = from;
this.logger = logger;
this.statusCallbackUrl = statusCallbackUrl;
this.limits = limits;
// Derive the mounted path from the configured callback URL rather than
// taking both separately: the two drifting apart would mount a route
// Twilio never calls, or verify against a URL it never signed.
if (statusCallbackUrl) {
try {
this.webhookPath = new URL(statusCallbackUrl).pathname;
} catch {
throw new Error(
`TwilioTransport: statusCallbackUrl is not a valid URL: ${statusCallbackUrl}`,
);
}
}
}
/**
* The SDK is a large module, so it is loaded on first send rather than at
* import time — a process that registers this transport but never sends SMS
* should not pay for it.
*/
private async getClient(): Promise<Twilio> {
if (this.client) return this.client;
const { default: twilio } = await import("twilio");
this.client = twilio(this.accountSid, this.authToken);
return this.client;
}
async send(task: NotificationDispatchedPayload): Promise<DeliveryResult> {
// `destination` is optional on the contract, so a malformed task can reach
// a transport without one. Failing here costs one clear error; passing
// `undefined` to Twilio costs an opaque provider rejection and a retry cycle.
const to = task.destination;
if (!to) {
return { success: false, error: "No destination (recipient phone number) on task" };
}
const content = task.renderedContent.content as any;
const body = content.text || content.body;
if (!body) {
return { success: false, error: "No message body on task" };
}
// A template may carry its own sender, so one transport can serve a
// support long code and a marketing short code. Non-strings are ignored
// rather than coerced: `from: 123` reaching Twilio as "123" would fail the
// send with a confusing error.
const from = typeof content.from === "string" && content.from ? content.from : this.from;
try {
const client = await this.getClient();
const message = await client.messages.create({
to,
from,
body,
...(this.statusCallbackUrl ? { statusCallback: this.statusCallbackUrl } : {}),
});
// Twilio accepts the message before a carrier has seen it, so `queued`
// here is a successful handoff, not a delivery. Whether it actually
// landed arrives later on the status callback.
this.logger?.debug(
{ taskId: task.taskId, messageId: message.sid, status: message.status },
"Twilio message accepted",
);
return { success: true, providerMessageId: message.sid };
} catch (err) {
const error = err as Error & { code?: number };
const invalidToken = error.code !== undefined && INVALID_DESTINATION_CODES.has(error.code);
this.logger?.warn(
{ taskId: task.taskId, code: error.code, error: error.message, invalidToken },
"Twilio send failed",
);
return { success: false, invalidToken, error: error.message };
}
}
/**
* Signature gate for the route the API mounts at `webhookPath`.
*
* Required, not optional: the mounted route answers 501 for a transport that
* omits it, so without this `parseWebhook` is never reached at all.
*
* Fail closed. Twilio posts status callbacks as `application/x-www-form-
* urlencoded`, so the signature covers the callback URL plus the sorted form
* fields — a missing signature or an unconfigured URL is a reason to drop
* the event, never a reason to trust it.
*/
async verifyWebhook(
rawBody: string,
headers: Record<string, string | string[] | undefined>,
): Promise<boolean> {
if (!this.statusCallbackUrl) {
this.logger?.error(
"Twilio webhook rejected: statusCallbackUrl is not configured, cannot verify signature",
);
return false;
}
const raw = headers["x-twilio-signature"];
const signature = Array.isArray(raw) ? raw[0] : raw;
if (!signature) {
this.logger?.warn("Twilio webhook rejected: missing x-twilio-signature header");
return false;
}
const { default: twilio } = await import("twilio");
const valid = twilio.validateRequest(
this.authToken,
signature,
this.statusCallbackUrl,
parseFormBody(rawBody),
);
if (!valid) {
this.logger?.warn("Twilio webhook signature verification failed");
}
return valid;
}
async parseWebhook(
_body: any,
rawBody?: string,
headers?: Record<string, string | string[] | undefined>,
): Promise<WebhookEvent[]> {
// Verify again rather than trusting the caller. This method is public and
// may be called directly, and an unverified callback is a forgeable write
// into delivery history.
if (rawBody === undefined || !headers || !(await this.verifyWebhook(rawBody, headers))) {
return [];
}
// The mounted route JSON-parses the body before handing it over, which
// yields nothing for a form-encoded post — so read the raw body instead.
const params = parseFormBody(rawBody);
const providerMessageId = params["MessageSid"] ?? params["SmsSid"];
const messageStatus = params["MessageStatus"] ?? params["SmsStatus"];
this.logger?.debug({ providerMessageId, messageStatus }, "Twilio status callback received");
if (!providerMessageId || !messageStatus) {
this.logger?.warn("Twilio status callback malformed or missing MessageSid");
return [];
}
const recipient = params["To"];
const errorCode = params["ErrorCode"] ? Number(params["ErrorCode"]) : undefined;
let status: WebhookEvent["status"];
let bounceType: WebhookEvent["bounceType"] | undefined;
switch (messageStatus) {
case "failed":
case "undelivered":
// A STOP reply is a withdrawal of consent, not a broken number, and
// the two want different handling: one should never be messaged
// again, the other may come back when the handset does.
if (errorCode === OPT_OUT_CODE) {
status = "unsubscribed";
} else {
status = "bounced";
bounceType =
errorCode !== undefined && HARD_BOUNCE_CODES.has(errorCode) ? "hard" : "soft";
}
break;
case "read":
// RCS and WhatsApp report a read receipt; SMS never does.
status = "opened";
break;
default:
// queued, sending, sent, delivered, accepted, scheduled, canceled —
// progress reports the delivery log already knows how to infer.
this.logger?.debug(
{ messageStatus },
"Twilio status callback ignored (untracked message status)",
);
return [];
}
this.logger?.info(
{ providerMessageId, status, messageStatus, errorCode },
"Twilio status callback mapped to notifkit status",
);
const metadata: Record<string, unknown> = { messageStatus };
if (errorCode !== undefined) metadata["errorCode"] = errorCode;
if (params["ChannelStatusMessage"]) {
metadata["channelStatusMessage"] = params["ChannelStatusMessage"];
}
return [
{
providerMessageId,
status,
timestamp: new Date(),
...(recipient ? { recipient } : {}),
...(bounceType ? { bounceType } : {}),
metadata,
},
];
}
}
/**
* Twilio posts status callbacks form-encoded, and signs every field it sent —
* including ones added after this was written — so collect all of them rather
* than only the ones read below, or verification fails on Twilio's next
* addition.
*/
function parseFormBody(rawBody: string): Record<string, string> {
const params: Record<string, string> = {};
for (const [key, value] of new URLSearchParams(rawBody)) {
params[key] = value;
}
return params;
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"]
}
+461
View File
@@ -492,3 +492,464 @@ describe("ConsoleTransport (Development Provider)", () => {
});
});
});
// ─── Twilio ──────────────────────────────────────────────────────────────────
import { TwilioTransport } from "../packages/provider-twilio/src/index.js";
const mockCreateMessage = vi.fn();
// Mock only the client factory. `validateRequest` stays real, so the webhook
// tests exercise Twilio's actual HMAC-SHA1 check rather than a stub of it.
vi.mock("twilio", async (importOriginal) => {
const actual = (await importOriginal()) as any;
const real = actual.default ?? actual;
const client = vi.fn(() => ({ messages: { create: mockCreateMessage } }));
return { default: Object.assign(client, real) };
});
const ACCOUNT_SID = "ACtest00000000000000000000000000";
const AUTH_TOKEN = "test-auth-token";
const CALLBACK_URL = "https://api.example.com/webhooks/twilio";
function twilioTransport(overrides: Record<string, unknown> = {}) {
return new TwilioTransport({
accountSid: ACCOUNT_SID,
authToken: AUTH_TOKEN,
from: "+15550000001",
statusCallbackUrl: CALLBACK_URL,
...overrides,
} as any);
}
function smsTask(overrides: Record<string, unknown> = {}): any {
return {
taskId: "task-uuid-sms-1",
destination: "+15550000002",
renderedContent: { content: { body: "Your code is 123456" } },
...overrides,
};
}
describe("TwilioTransport (SMS Provider)", () => {
beforeEach(() => {
mockCreateMessage.mockReset();
});
it("registers against the sms channel", () => {
expect(twilioTransport().channel).toBe("sms");
});
it("sends an SMS via the Twilio SDK and returns the message sid", async () => {
mockCreateMessage.mockResolvedValueOnce({ sid: "SM123", status: "queued" });
const result = await twilioTransport().send(smsTask());
expect(mockCreateMessage).toHaveBeenCalledWith({
to: "+15550000002",
from: "+15550000001",
body: "Your code is 123456",
statusCallback: CALLBACK_URL,
});
expect(result).toEqual({ success: true, providerMessageId: "SM123" });
});
it("omits statusCallback when no callback URL is configured", async () => {
mockCreateMessage.mockResolvedValueOnce({ sid: "SM124", status: "queued" });
await twilioTransport({ statusCallbackUrl: undefined }).send(smsTask());
expect(mockCreateMessage).toHaveBeenCalledWith({
to: "+15550000002",
from: "+15550000001",
body: "Your code is 123456",
});
});
it("lets a template pin its own sender, ignoring a non-string one", async () => {
mockCreateMessage.mockResolvedValue({ sid: "SM125", status: "queued" });
await twilioTransport().send(
smsTask({ renderedContent: { content: { body: "hi", from: "+15559999999" } } }),
);
expect(mockCreateMessage.mock.calls[0]?.[0].from).toBe("+15559999999");
await twilioTransport().send(
smsTask({ renderedContent: { content: { body: "hi", from: 123 } } }),
);
expect(mockCreateMessage.mock.calls[1]?.[0].from).toBe("+15550000001");
});
it("fails without reaching Twilio when the task carries no destination", async () => {
const result = await twilioTransport().send(smsTask({ destination: undefined }));
expect(mockCreateMessage).not.toHaveBeenCalled();
expect(result).toEqual({
success: false,
error: "No destination (recipient phone number) on task",
});
});
it("fails without reaching Twilio when the task carries no body", async () => {
const result = await twilioTransport().send(
smsTask({ renderedContent: { content: { subject: "no body here" } } }),
);
expect(mockCreateMessage).not.toHaveBeenCalled();
expect(result).toEqual({ success: false, error: "No message body on task" });
});
it.each([
[21211, "Invalid 'To' Phone Number"],
[21610, "Attempt to send to unsubscribed recipient"],
[21612, "Not reachable via SMS"],
[21614, "'To' number is not a valid mobile number"],
])("flags a dead destination (%i) as an invalid token", async (code, message) => {
mockCreateMessage.mockRejectedValueOnce(Object.assign(new Error(message), { code }));
await expect(twilioTransport().send(smsTask())).resolves.toEqual({
success: false,
invalidToken: true,
error: message,
});
});
it("leaves a retryable failure retryable rather than suppressing the number", async () => {
// 20429 is Twilio's rate limit: the number is fine, this attempt was not.
mockCreateMessage.mockRejectedValueOnce(
Object.assign(new Error("Too Many Requests"), { code: 20429 }),
);
await expect(twilioTransport().send(smsTask())).resolves.toEqual({
success: false,
invalidToken: false,
error: "Too Many Requests",
});
});
it("survives an error carrying no Twilio code at all", async () => {
mockCreateMessage.mockRejectedValueOnce(new Error("socket hang up"));
await expect(twilioTransport().send(smsTask())).resolves.toEqual({
success: false,
invalidToken: false,
error: "socket hang up",
});
});
});
describe("TwilioTransport (Status callbacks)", () => {
/** Produce a body + headers Twilio's own validator accepts. */
async function sign(params: Record<string, string>, url = CALLBACK_URL) {
const twilio = ((await import("twilio")) as any).default;
const rawBody = new URLSearchParams(params).toString();
return {
rawBody,
headers: {
"x-twilio-signature": twilio.getExpectedTwilioSignature(AUTH_TOKEN, url, params),
} as Record<string, string>,
};
}
const undelivered = {
MessageSid: "SM123",
MessageStatus: "undelivered",
To: "+15550000002",
From: "+15550000001",
ErrorCode: "30005",
};
it("mounts its route at the path of the configured callback URL", () => {
const transport = twilioTransport();
// The API only mounts a webhook route for a transport carrying all three.
expect(transport.webhookPath).toBe("/webhooks/twilio");
expect(typeof transport.verifyWebhook).toBe("function");
expect(typeof transport.parseWebhook).toBe("function");
});
it("advertises no route when no callback URL is configured", () => {
expect(twilioTransport({ statusCallbackUrl: undefined }).webhookPath).toBeUndefined();
});
it("refuses to construct with a callback URL that is not a URL", () => {
expect(() => twilioTransport({ statusCallbackUrl: "not-a-url" })).toThrow(/not a valid URL/);
});
it("accepts a correctly signed callback", async () => {
const { rawBody, headers } = await sign(undelivered);
await expect(twilioTransport().verifyWebhook(rawBody, headers)).resolves.toBe(true);
});
it("rejects a callback whose body was tampered with after signing", async () => {
const { headers } = await sign(undelivered);
const forged = new URLSearchParams({
...undelivered,
MessageSid: "someone-elses-message",
}).toString();
await expect(twilioTransport().verifyWebhook(forged, headers)).resolves.toBe(false);
});
it("rejects a callback signed for a different URL", async () => {
const { rawBody, headers } = await sign(
undelivered,
"https://evil.example.com/webhooks/twilio",
);
await expect(twilioTransport().verifyWebhook(rawBody, headers)).resolves.toBe(false);
});
it("rejects a callback with no signature header", async () => {
const { rawBody } = await sign(undelivered);
await expect(twilioTransport().verifyWebhook(rawBody, {})).resolves.toBe(false);
});
it("rejects everything when no callback URL is configured", async () => {
const { rawBody, headers } = await sign(undelivered);
await expect(
twilioTransport({ statusCallbackUrl: undefined }).verifyWebhook(rawBody, headers),
).resolves.toBe(false);
});
it("reads the form-encoded body the mounted route could not JSON-parse", async () => {
const { rawBody, headers } = await sign(undelivered);
// The route hands over `{}` for a form post; the raw body is the payload.
const events = await twilioTransport().parseWebhook({}, rawBody, headers);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
providerMessageId: "SM123",
status: "bounced",
bounceType: "hard",
recipient: "+15550000002",
});
});
it("treats an unrecognised failure code as a soft bounce", async () => {
const { rawBody, headers } = await sign({
...undelivered,
MessageStatus: "failed",
ErrorCode: "30008",
});
const events = await twilioTransport().parseWebhook({}, rawBody, headers);
expect(events[0]).toMatchObject({ status: "bounced", bounceType: "soft" });
});
it("treats a failure with no code at all as a soft bounce", async () => {
const { MessageSid, MessageStatus, To, From } = undelivered;
const { rawBody, headers } = await sign({ MessageSid, MessageStatus, To, From });
const events = await twilioTransport().parseWebhook({}, rawBody, headers);
expect(events[0]).toMatchObject({ status: "bounced", bounceType: "soft" });
});
it("maps a STOP reply to an unsubscribe, not a bounce", async () => {
const { rawBody, headers } = await sign({ ...undelivered, ErrorCode: "21610" });
const events = await twilioTransport().parseWebhook({}, rawBody, headers);
expect(events[0]).toMatchObject({
providerMessageId: "SM123",
status: "unsubscribed",
recipient: "+15550000002",
});
expect(events[0]?.bounceType).toBeUndefined();
});
it("maps an RCS/WhatsApp read receipt to opened", async () => {
const { rawBody, headers } = await sign({
MessageSid: "SM123",
MessageStatus: "read",
To: "+15550000002",
});
const events = await twilioTransport().parseWebhook({}, rawBody, headers);
expect(events[0]).toMatchObject({ status: "opened" });
});
it.each(["queued", "sending", "sent", "delivered", "accepted", "canceled"])(
"ignores the untracked %s status",
async (MessageStatus) => {
const { rawBody, headers } = await sign({ MessageSid: "SM123", MessageStatus });
await expect(twilioTransport().parseWebhook({}, rawBody, headers)).resolves.toEqual([]);
},
);
it("falls back to the SmsSid/SmsStatus aliases Twilio sends for SMS", async () => {
const { rawBody, headers } = await sign({
SmsSid: "SM999",
SmsStatus: "undelivered",
To: "+15550000002",
ErrorCode: "30006",
});
const events = await twilioTransport().parseWebhook({}, rawBody, headers);
expect(events[0]).toMatchObject({ providerMessageId: "SM999", bounceType: "hard" });
});
it("ignores a verified callback that carries no message sid", async () => {
const { rawBody, headers } = await sign({ MessageStatus: "undelivered" });
await expect(twilioTransport().parseWebhook({}, rawBody, headers)).resolves.toEqual([]);
});
it("does not trust a body that fails verification, even if it is well formed", async () => {
// Valid-looking payload, signature for a different body.
const { headers } = await sign({ MessageSid: "other", MessageStatus: "delivered" });
const rawBody = new URLSearchParams(undelivered).toString();
await expect(twilioTransport().parseWebhook({}, rawBody, headers)).resolves.toEqual([]);
});
it("ignores a callback that arrives without a raw body to verify", async () => {
const { headers } = await sign(undelivered);
await expect(twilioTransport().parseWebhook({}, undefined, headers)).resolves.toEqual([]);
});
it("keeps the provider detail worth correlating on in metadata", async () => {
const { rawBody, headers } = await sign({
...undelivered,
ChannelStatusMessage: "Unknown destination handset",
});
const events = await twilioTransport().parseWebhook({}, rawBody, headers);
expect(events[0]?.metadata).toEqual({
messageStatus: "undelivered",
errorCode: 30005,
channelStatusMessage: "Unknown destination handset",
});
});
});
describe("TwilioTransport (Client and logging)", () => {
const logger = () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }) as any;
beforeEach(() => {
mockCreateMessage.mockReset();
});
it("builds the SDK client once and reuses it across sends", async () => {
const twilio = ((await import("twilio")) as any).default;
twilio.mockClear();
mockCreateMessage.mockResolvedValue({ sid: "SM126", status: "queued" });
const transport = twilioTransport();
await transport.send(smsTask());
await transport.send(smsTask());
expect(mockCreateMessage).toHaveBeenCalledTimes(2);
expect(twilio).toHaveBeenCalledTimes(1);
expect(twilio).toHaveBeenCalledWith(ACCOUNT_SID, AUTH_TOKEN);
});
it("logs an accepted send and a failed one", async () => {
const log = logger();
mockCreateMessage.mockResolvedValueOnce({ sid: "SM127", status: "queued" });
await twilioTransport({ logger: log }).send(smsTask());
expect(log.debug).toHaveBeenCalledWith(
{ taskId: "task-uuid-sms-1", messageId: "SM127", status: "queued" },
"Twilio message accepted",
);
mockCreateMessage.mockRejectedValueOnce(Object.assign(new Error("nope"), { code: 21211 }));
await twilioTransport({ logger: log }).send(smsTask());
expect(log.warn).toHaveBeenCalledWith(
{ taskId: "task-uuid-sms-1", code: 21211, error: "nope", invalidToken: true },
"Twilio send failed",
);
});
it("reads a signature header that arrived as an array", async () => {
const rawBody = new URLSearchParams({ MessageSid: "SM128", MessageStatus: "read" }).toString();
const twilio = ((await import("twilio")) as any).default;
const signature = twilio.getExpectedTwilioSignature(AUTH_TOKEN, CALLBACK_URL, {
MessageSid: "SM128",
MessageStatus: "read",
});
await expect(
twilioTransport().verifyWebhook(rawBody, { "x-twilio-signature": [signature] }),
).resolves.toBe(true);
});
it("logs each reason a callback is dropped", async () => {
const log = logger();
const rawBody = new URLSearchParams({ MessageSid: "SM129", MessageStatus: "sent" }).toString();
await twilioTransport({ logger: log, statusCallbackUrl: undefined }).verifyWebhook(rawBody, {});
expect(log.error).toHaveBeenCalledWith(
"Twilio webhook rejected: statusCallbackUrl is not configured, cannot verify signature",
);
await twilioTransport({ logger: log }).verifyWebhook(rawBody, {});
expect(log.warn).toHaveBeenCalledWith(
"Twilio webhook rejected: missing x-twilio-signature header",
);
await twilioTransport({ logger: log }).verifyWebhook(rawBody, {
"x-twilio-signature": "not-the-signature",
});
expect(log.warn).toHaveBeenCalledWith("Twilio webhook signature verification failed");
});
it("logs a mapped callback, an ignored one, and a malformed one", async () => {
const log = logger();
const transport = twilioTransport({ logger: log });
async function deliver(params: Record<string, string>) {
const twilio = ((await import("twilio")) as any).default;
const rawBody = new URLSearchParams(params).toString();
return transport.parseWebhook({}, rawBody, {
"x-twilio-signature": twilio.getExpectedTwilioSignature(AUTH_TOKEN, CALLBACK_URL, params),
});
}
await deliver({ MessageSid: "SM130", MessageStatus: "undelivered", ErrorCode: "30005" });
expect(log.info).toHaveBeenCalledWith(
{
providerMessageId: "SM130",
status: "bounced",
messageStatus: "undelivered",
errorCode: 30005,
},
"Twilio status callback mapped to notifkit status",
);
await deliver({ MessageSid: "SM131", MessageStatus: "delivered" });
expect(log.debug).toHaveBeenCalledWith(
{ messageStatus: "delivered" },
"Twilio status callback ignored (untracked message status)",
);
await deliver({ MessageStatus: "undelivered" });
expect(log.warn).toHaveBeenCalledWith("Twilio status callback malformed or missing MessageSid");
});
it("omits recipient when the callback carries no To", async () => {
const params = { MessageSid: "SM132", MessageStatus: "undelivered", ErrorCode: "30005" };
const twilio = ((await import("twilio")) as any).default;
const events = await twilioTransport().parseWebhook(
{},
new URLSearchParams(params).toString(),
{
"x-twilio-signature": twilio.getExpectedTwilioSignature(AUTH_TOKEN, CALLBACK_URL, params),
},
);
expect(events[0]).toMatchObject({ providerMessageId: "SM132", status: "bounced" });
expect(events[0]).not.toHaveProperty("recipient");
});
});