https://github.com/elghaied/payload-plugin-sms
Multi-provider SMS plugin for Payload CMS 3.x. Send SMS through Twilio, Telnyx, Plivo, Vonage, or AWS SNS — and call payload.sendSMS(...) from anywhere.
https://github.com/elghaied/payload-plugin-sms
aws-sns payload-plugin payloadcms payloadcms-v3 sms-providers telnyx twilio vonage
Last synced: 11 days ago
JSON representation
Multi-provider SMS plugin for Payload CMS 3.x. Send SMS through Twilio, Telnyx, Plivo, Vonage, or AWS SNS — and call payload.sendSMS(...) from anywhere.
- Host: GitHub
- URL: https://github.com/elghaied/payload-plugin-sms
- Owner: elghaied
- Created: 2026-05-26T02:51:17.000Z (about 1 month ago)
- Default Branch: main
- Last Pushed: 2026-06-03T04:22:13.000Z (24 days ago)
- Last Synced: 2026-06-03T05:24:27.844Z (24 days ago)
- Topics: aws-sns, payload-plugin, payloadcms, payloadcms-v3, sms-providers, telnyx, twilio, vonage
- Language: TypeScript
- Homepage:
- Size: 406 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
Awesome Lists containing this project
README
# @elghaied/payload-plugin-sms
[](https://www.npmjs.com/package/@elghaied/payload-plugin-sms)
[](https://www.npmjs.com/package/@elghaied/payload-plugin-sms)
[](https://github.com/elghaied/payload-plugin-sms/blob/main/LICENSE)
Multi-provider SMS plugin for [Payload CMS](https://payloadcms.com) 3.x. Send SMS through Twilio, Telnyx, Plivo, Vonage, or AWS SNS — and call `payload.sendSMS(...)` from anywhere.
Architecture mirrors `@payloadcms/email-nodemailer`: a thin core defines an adapter interface, and each provider ships as a separate subpath export with the SDK declared as an optional peer dependency.
## Install
```bash
pnpm add @elghaied/payload-plugin-sms
```
Then install the SDK for your chosen provider:
```bash
pnpm add twilio # Twilio
pnpm add telnyx # Telnyx
pnpm add plivo # Plivo
pnpm add @vonage/server-sdk # Vonage
pnpm add @aws-sdk/client-sns # AWS SNS
```
## Quick start (Twilio)
```ts
import { buildConfig } from 'payload'
import { smsPlugin } from '@elghaied/payload-plugin-sms'
import { twilioAdapter } from '@elghaied/payload-plugin-sms/twilio'
export default buildConfig({
plugins: [
smsPlugin({
adapter: twilioAdapter({
accountSid: process.env.TWILIO_ACCOUNT_SID!,
authToken: process.env.TWILIO_AUTH_TOKEN!,
defaultFrom: process.env.TWILIO_FROM!,
}),
collections: { logs: true },
widgets: true,
}),
],
})
```
## Sending SMS
```ts
import type { CollectionConfig } from 'payload'
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
fields: [{ name: 'phone', type: 'text' }],
hooks: {
afterChange: [
async ({ doc, req, operation }) => {
if (operation === 'create' && doc.phone) {
await req.payload.sendSMS({
to: doc.phone,
body: `Welcome ${doc.email}!`,
})
}
},
],
},
}
```
`to` must be E.164 (`+` then 1–15 digits). `from` falls back to plugin `defaultFrom`, then adapter `defaultFrom`.
## Adapters
### Twilio
```ts
import { twilioAdapter } from '@elghaied/payload-plugin-sms/twilio'
twilioAdapter({
accountSid: '...',
authToken: '...',
defaultFrom: '+15551234567',
messagingServiceSid: 'MG...', // optional; supersedes `from`
})
```
### Telnyx
```ts
import { telnyxAdapter } from '@elghaied/payload-plugin-sms/telnyx'
telnyxAdapter({
apiKey: '...',
defaultFrom: '+15551234567',
messagingProfileId: '...', // optional
})
```
### Plivo
```ts
import { plivoAdapter } from '@elghaied/payload-plugin-sms/plivo'
plivoAdapter({
authId: '...',
authToken: '...',
defaultFrom: '+15551234567',
})
```
### Vonage
Uses the legacy SMS API (key + secret). The Messages API (JWT-authenticated) is on the roadmap.
```ts
import { vonageAdapter } from '@elghaied/payload-plugin-sms/vonage'
vonageAdapter({
apiKey: '...',
apiSecret: '...',
defaultFrom: '+15551234567',
})
```
### AWS SNS
SNS has no per-message `from`. `defaultFrom` is sent as the `AWS.SNS.SMS.SenderID` attribute (region-dependent support).
```ts
import { awsSnsAdapter } from '@elghaied/payload-plugin-sms/aws-sns'
awsSnsAdapter({
region: 'us-east-1',
credentials: { // optional; falls back to default AWS credential chain
accessKeyId: '...',
secretAccessKey: '...',
},
defaultFrom: 'MYBRAND', // sender ID
smsType: 'Transactional', // or 'Promotional'
})
```
### Mock (tests)
```ts
import { mockAdapter } from '@elghaied/payload-plugin-sms/mock'
const adapter = mockAdapter({ defaultFrom: '+15550000000' })
adapter.messages // array of sent messages
adapter.reset() // clears it
```
## Plugin options
| Option | Type | Default | Notes |
| ------------------- | ---------------------------------------------------------- | ------------- | --------------------------------------------------------- |
| `adapter` | `SMSAdapter` | — | Required at runtime. Missing adapter → `sendSMS` throws. |
| `defaultFrom` | `string` | — | Falls back to adapter's `defaultFrom`. |
| `disabled` | `boolean` | `false` | Skips registration; logs a warning. |
| `collections.logs` | `boolean \| { slug?; admin?; includeContext?; statusHistory? }` | `false` | Creates an `sms-logs` collection. `includeContext` adds a JSON `context` field; `statusHistory` adds an append-only status-event array. |
| `widgets` | `boolean` | `true` | Registers the dashboard widget when logs enabled. |
| `tenantScoping` | `{ field?: string; cookie?: string }` | — (off) | Opt-in. Scopes the dashboard widget to the host's selected tenant. See [Dashboard widget](#dashboard-widget). |
| `webhooks` | `{ enabled; basePath?; statusCallbackUrl?; trustProxy?; verifySignature? }` | — | Enables the delivery-status receiver + auto-derived callback. See [Delivery-status webhooks](#delivery-status-webhooks). |
| `onSend` | `(args) => void \| Promise` | — | Called after every successful send. |
| `onError` | `(args) => void \| Promise` | — | Called when send fails. Original error is re-thrown. |
| `onStatus` | `(args) => void \| Promise` | — | Called on every delivery-status webhook event (requires `webhooks.enabled`). |
## Logs collection
Enable with `collections: { logs: true }`. Schema:
| Field | Type | Notes |
| ------------------- | -------- | ---------------------------------------------- |
| `to` | text | Recipient |
| `from` | text | Sender |
| `body` | textarea | Message body |
| `provider` | text | Adapter name |
| `status` | select | `queued`/`sent`/`delivered`/`failed`/`unknown` |
| `providerMessageId` | text | Provider's message id |
| `cost` | group | `{ amount, currency }` when reported |
| `error` | textarea | Adapter error message (if any) |
| `errorCode` | text | Provider error code (from a failed-status webhook) |
| `sentAt` | date | Server timestamp |
| `deliveredAt` | date | Set when a webhook reports `delivered` |
| `failedAt` | date | Set when a webhook reports `failed` |
| `context` | json | Per-send metadata — only with `includeContext: true` |
| `statusHistory` | array | Append-only `{ status, occurredAt, errorCode }` — only with `statusHistory: true` |
`deliveredAt` / `failedAt` / `errorCode` / `statusHistory` are populated by [delivery-status webhooks](#delivery-status-webhooks).
Read access requires a logged-in admin user. Create/update/delete from the admin panel are blocked — the plugin is the only writer.
Override the slug:
```ts
collections: { logs: { slug: 'audit-sms', admin: { group: 'Audit' } } }
```
Note: if you override the slug, set `widgets: false` — the bundled widget reads from `sms-logs` only.
## Dashboard widget
When `widgets: true` and logs are enabled, the plugin registers an `admin.dashboard.widgets` entry that shows a 24h send count, the last 5 entries, and a link to the logs collection.
Disable with `widgets: false`.
### Multi-tenant scoping
The plugin is single-tenant by design, so by default the widget queries `sms-logs` with the Local API's implicit `overrideAccess: true` and no tenant filter — it shows **every** row. In a multi-tenant host (e.g. `@payloadcms/plugin-multi-tenant` injecting a `tenant` field into `sms-logs`) that leaks other tenants' logs into the dashboard.
Opt in with `tenantScoping` to make the widget honor the host's tenant selector:
```ts
smsPlugin({
adapter,
collections: { logs: true },
tenantScoping: { field: 'tenant', cookie: 'payload-tenant' }, // both optional; these are the defaults
})
```
When enabled **and** the logs collection actually has the configured `field`, the widget:
- reads the selected tenant id from the `cookie` and adds `where[field][equals]=` to both the 24h count and the recent-5 list, and
- runs the queries with `overrideAccess: false` and the real request/user, so any read access control the host added to `sms-logs` is respected.
With no tenant selected ("all tenants"), it drops the tenant filter but still runs with `overrideAccess: false`, so the dashboard shows everything the current user is allowed to read. Omit `tenantScoping`, or leave the field off the collection, and the widget behaves exactly as before.
## Router adapter (multi-provider)
For multi-tenant SaaS or geo-routing, wrap multiple adapters in a `routerAdapter` and decide per-send which one handles the message.
```ts
import { smsPlugin } from '@elghaied/payload-plugin-sms'
import { twilioAdapter } from '@elghaied/payload-plugin-sms/twilio'
import { telnyxAdapter } from '@elghaied/payload-plugin-sms/telnyx'
import { routerAdapter, byTenantLookup } from '@elghaied/payload-plugin-sms/router'
smsPlugin({
adapter: routerAdapter({
providers: {
twilio: twilioAdapter({ accountSid, authToken }),
telnyx: telnyxAdapter({ apiKey }),
},
route: byTenantLookup({
collection: 'tenants',
providerField: 'smsProvider',
cacheMs: 60_000,
}),
}),
collections: { logs: { includeContext: true } },
})
```
At the call site, attach the tenant id (and any per-tenant `from`):
```ts
await req.payload.sendSMS({
to: customer.phone,
from: tenant.smsFromNumber,
body: '...',
context: { tenantId: tenant.id },
})
```
The router is just another `SMSAdapter`. Single-provider users (`smsPlugin({ adapter: twilioAdapter(...) })`) are unaffected.
### Route helpers
| Helper | Use |
|---|---|
| `byTenantLookup({ collection, providerField, contextKey?, cacheMs?, fallback? })` | SaaS: read tenant id from `message.context`, fetch the doc, return its provider field |
| `byCountryPrefix({ '+1': 'twilio', '+33': 'telnyx' }, { fallback? })` | Geo route by E.164 prefix; longest-prefix wins |
| `byRoundRobin(['twilio-a', 'twilio-b'])` | Cycle across duplicate accounts |
| `byRandom(['twilio-a', 'twilio-b'])` | Uniform random pick |
| `withFailover(inner, ['fallback-a', 'fallback-b'])` | Wrap any route; on `SMSProviderError`, try the next provider in order |
You can also write a `route` callback directly — it's just `(args) => string \| string[]` (or async).
### `context`
`SMSMessage.context` is an opaque per-send map. It flows to the route function, the `onSend`/`onError` hooks (via `args.message`), and — when `collections.logs.includeContext: true` — into a `context` (JSON) field on the `sms-logs` collection.
### Failover semantics
A route returning a single provider name calls that provider once. Returning an array `['a', 'b']` tries each in order on `SMSProviderError`; if all fail the router throws a single `SMSProviderError` whose `.cause` is an array of the individual errors. `SMSValidationError` is never retried.
## Hooks (onSend, onError)
```ts
smsPlugin({
adapter: twilioAdapter({ ... }),
onSend: async ({ result, req }) => {
console.log(`Sent ${result.id} via ${result.provider} to ${result.to}`)
},
onError: async ({ error, message, req }) => {
console.error(`Failed to send to ${message.to}:`, error)
},
})
```
Hook failures are logged but do not affect the SMS send result. `req` is `undefined` when called via `payload.sendSMS` (Payload's local API does not thread `req` through dynamic methods).
## Errors
```ts
import { SMSValidationError, SMSProviderError } from '@elghaied/payload-plugin-sms'
try {
await payload.sendSMS({ to: 'not-e164', body: 'hi' })
} catch (err) {
if (err instanceof SMSValidationError) {
// Bad input (E.164 fail, missing from, missing adapter)
} else if (err instanceof SMSProviderError) {
// Adapter call failed; `err.cause` has the original SDK error
}
}
```
## Delivery-status webhooks
Enable provider webhooks to keep `sms-logs` rows in sync with real delivery state. With `webhooks: { enabled: true }`, the Twilio/Telnyx/Plivo adapters are also handed a per-message delivery-status callback URL automatically — derived from `config.serverURL` + the webhook base path (e.g. `https://app.com/api/sms/webhooks/twilio`). No Messaging-Service or provider-portal step is required for a plain sender. (Vonage and AWS SNS have no per-message callback and are unaffected.)
```ts
import { smsPlugin } from '@elghaied/payload-plugin-sms'
import { twilioAdapter } from '@elghaied/payload-plugin-sms/twilio'
export default buildConfig({
// ...
plugins: [
smsPlugin({
adapter: twilioAdapter({
accountSid: process.env.TWILIO_ACCOUNT_SID!,
authToken: process.env.TWILIO_AUTH_TOKEN!,
defaultFrom: process.env.TWILIO_FROM,
webhook: { trustProxy: true }, // optional, if behind a proxy
}),
collections: { logs: { statusHistory: true } },
webhooks: { enabled: true },
onStatus: ({ event, log }) => {
// optional: notify your app on every status transition
},
}),
],
})
```
Webhook URLs (Payload prepends `/api`):
| Provider | URL | Required adapter `webhook` opts |
| -------- | -------------------------------- | --------------------------------------------------------------------------------------- |
| Twilio | `/api/sms/webhooks/twilio` | (none — uses `authToken`) |
| Telnyx | `/api/sms/webhooks/telnyx` | `publicKey` (Ed25519 PEM, from Telnyx portal) |
| Plivo | `/api/sms/webhooks/plivo` | (none — uses `authToken`) |
| Vonage | `/api/sms/webhooks/vonage` | `signatureSecret`, `signatureMethod: 'sha256hash' \| 'sha512hash'` |
| AWS SNS | `/api/sms/webhooks/aws-sns` | (none — verified via `SigningCertURL`; auto-confirms `SubscriptionConfirmation`) |
Notes:
- The auto-derived callback URL is skipped when `serverURL` is missing or points at `localhost`/`127.0.0.1` (dev). Override it with `webhooks.statusCallbackUrl` (applies to Twilio/Telnyx/Plivo).
- The callback URL handed to the provider **must match** the URL the receiver reconstructs for signature verification. Behind a reverse proxy, set the adapter's `webhook: { trustProxy: true }` so the host/protocol are read from forwarded headers — or use the `webhooks.statusCallbackUrl` override to pin both ends explicitly.
- Signature verification is **on by default**. Set `webhooks.verifySignature: false` only for local testing.
- Status updates are gated by rank (`queued → sent → delivered`, with `failed` terminal), so out-of-order or duplicate webhooks are dropped silently.
- Set `collections.logs.statusHistory: true` to keep an append-only history of every event.
- Vonage plain MD5 signing is **not supported** (insecure). Use `sha256hash` or `sha512hash`.
- Twilio and Plivo do not sign timestamps — those providers cannot prevent replay attacks at the signature layer.
## Internationalization
The `sms-logs` collection (labels, field labels, status options) and the dashboard widget ship with bundled `en` and `fr` translations, deep-merged into `config.i18n.translations` under an `sms` namespace. Your existing translations always win on conflict.
Override any string — or add a language — via `i18n.translations.{lang}.sms`:
```ts
buildConfig({
i18n: {
translations: {
fr: { sms: { fieldTo: 'Destinataire' } },
// add a new language by supplying its sms.* keys
},
},
// ...
})
```
The raw translation tables are also available directly via the `@elghaied/payload-plugin-sms/translations` export.
## Roadmap
- Bulk / batch send
- Templating (variable substitution, localization)
- Per-recipient rate limiting
- Inbound SMS handling
- Vonage Messages API (JWT, MMS, WhatsApp)
- MessageBird/Bird, Sinch, Infobip adapters
PRs welcome.
## Compatibility
- Payload `^3.0.0` (tested against `3.84.1`)
- Next.js `^16.0.0`
- Node `^18.20.2 || >=20.9.0`
- React 19 is an optional peer dependency — required only if you use the dashboard widget (`@elghaied/payload-plugin-sms/rsc`).
## License
MIT