npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@elghaied/payload-plugin-sms

v0.5.0

Published

Multi-provider SMS plugin for Payload CMS 3.x with Twilio, Telnyx, Plivo, Vonage, and AWS SNS adapters.

Readme

@elghaied/payload-plugin-sms

npm version npm downloads license

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.

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

pnpm add @elghaied/payload-plugin-sms

Then install the SDK for your chosen provider:

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)

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

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

import { twilioAdapter } from '@elghaied/payload-plugin-sms/twilio'

twilioAdapter({
  accountSid: '...',
  authToken: '...',
  defaultFrom: '+15551234567',
  messagingServiceSid: 'MG...', // optional; supersedes `from`
})

Telnyx

import { telnyxAdapter } from '@elghaied/payload-plugin-sms/telnyx'

telnyxAdapter({
  apiKey: '...',
  defaultFrom: '+15551234567',
  messagingProfileId: '...', // optional
})

Plivo

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.

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).

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)

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. | | webhooks | { enabled; basePath?; statusCallbackUrl?; trustProxy?; verifySignature? } | — | Enables the delivery-status receiver + auto-derived callback. See Delivery-status webhooks. | | onSend | (args) => void \| Promise<void> | — | Called after every successful send. | | onError | (args) => void \| Promise<void> | — | Called when send fails. Original error is re-thrown. | | onStatus | (args) => void \| Promise<void> | — | 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.

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:

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:

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]=<id> 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.

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):

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)

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

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.)

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:

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