{"id":50896320,"url":"https://github.com/elghaied/payload-plugin-sms","last_synced_at":"2026-06-16T00:04:30.966Z","repository":{"id":360533627,"uuid":"1249769132","full_name":"elghaied/payload-plugin-sms","owner":"elghaied","description":"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.","archived":false,"fork":false,"pushed_at":"2026-06-03T04:22:13.000Z","size":416,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-03T05:24:27.844Z","etag":null,"topics":["aws-sns","payload-plugin","payloadcms","payloadcms-v3","sms-providers","telnyx","twilio","vonage"],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/elghaied.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-26T02:51:17.000Z","updated_at":"2026-06-03T04:22:15.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/elghaied/payload-plugin-sms","commit_stats":null,"previous_names":["elghaied/payload-plugin-sms"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/elghaied/payload-plugin-sms","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/elghaied%2Fpayload-plugin-sms","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/elghaied%2Fpayload-plugin-sms/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/elghaied%2Fpayload-plugin-sms/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/elghaied%2Fpayload-plugin-sms/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/elghaied","download_url":"https://codeload.github.com/elghaied/payload-plugin-sms/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/elghaied%2Fpayload-plugin-sms/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34385032,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-15T02:00:07.085Z","response_time":63,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["aws-sns","payload-plugin","payloadcms","payloadcms-v3","sms-providers","telnyx","twilio","vonage"],"created_at":"2026-06-16T00:04:30.165Z","updated_at":"2026-06-16T00:04:30.960Z","avatar_url":"https://github.com/elghaied.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# @elghaied/payload-plugin-sms\n\n[![npm version](https://img.shields.io/npm/v/@elghaied/payload-plugin-sms.svg)](https://www.npmjs.com/package/@elghaied/payload-plugin-sms)\n[![npm downloads](https://img.shields.io/npm/dm/@elghaied/payload-plugin-sms.svg)](https://www.npmjs.com/package/@elghaied/payload-plugin-sms)\n[![license](https://img.shields.io/npm/l/@elghaied/payload-plugin-sms.svg)](https://github.com/elghaied/payload-plugin-sms/blob/main/LICENSE)\n\nMulti-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.\n\nArchitecture 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.\n\n## Install\n\n```bash\npnpm add @elghaied/payload-plugin-sms\n```\n\nThen install the SDK for your chosen provider:\n\n```bash\npnpm add twilio                  # Twilio\npnpm add telnyx                  # Telnyx\npnpm add plivo                   # Plivo\npnpm add @vonage/server-sdk      # Vonage\npnpm add @aws-sdk/client-sns     # AWS SNS\n```\n\n## Quick start (Twilio)\n\n```ts\nimport { buildConfig } from 'payload'\nimport { smsPlugin } from '@elghaied/payload-plugin-sms'\nimport { twilioAdapter } from '@elghaied/payload-plugin-sms/twilio'\n\nexport default buildConfig({\n  plugins: [\n    smsPlugin({\n      adapter: twilioAdapter({\n        accountSid: process.env.TWILIO_ACCOUNT_SID!,\n        authToken: process.env.TWILIO_AUTH_TOKEN!,\n        defaultFrom: process.env.TWILIO_FROM!,\n      }),\n      collections: { logs: true },\n      widgets: true,\n    }),\n  ],\n})\n```\n\n## Sending SMS\n\n```ts\nimport type { CollectionConfig } from 'payload'\n\nexport const Users: CollectionConfig = {\n  slug: 'users',\n  auth: true,\n  fields: [{ name: 'phone', type: 'text' }],\n  hooks: {\n    afterChange: [\n      async ({ doc, req, operation }) =\u003e {\n        if (operation === 'create' \u0026\u0026 doc.phone) {\n          await req.payload.sendSMS({\n            to: doc.phone,\n            body: `Welcome ${doc.email}!`,\n          })\n        }\n      },\n    ],\n  },\n}\n```\n\n`to` must be E.164 (`+` then 1–15 digits). `from` falls back to plugin `defaultFrom`, then adapter `defaultFrom`.\n\n## Adapters\n\n### Twilio\n\n```ts\nimport { twilioAdapter } from '@elghaied/payload-plugin-sms/twilio'\n\ntwilioAdapter({\n  accountSid: '...',\n  authToken: '...',\n  defaultFrom: '+15551234567',\n  messagingServiceSid: 'MG...', // optional; supersedes `from`\n})\n```\n\n### Telnyx\n\n```ts\nimport { telnyxAdapter } from '@elghaied/payload-plugin-sms/telnyx'\n\ntelnyxAdapter({\n  apiKey: '...',\n  defaultFrom: '+15551234567',\n  messagingProfileId: '...', // optional\n})\n```\n\n### Plivo\n\n```ts\nimport { plivoAdapter } from '@elghaied/payload-plugin-sms/plivo'\n\nplivoAdapter({\n  authId: '...',\n  authToken: '...',\n  defaultFrom: '+15551234567',\n})\n```\n\n### Vonage\n\nUses the legacy SMS API (key + secret). The Messages API (JWT-authenticated) is on the roadmap.\n\n```ts\nimport { vonageAdapter } from '@elghaied/payload-plugin-sms/vonage'\n\nvonageAdapter({\n  apiKey: '...',\n  apiSecret: '...',\n  defaultFrom: '+15551234567',\n})\n```\n\n### AWS SNS\n\nSNS has no per-message `from`. `defaultFrom` is sent as the `AWS.SNS.SMS.SenderID` attribute (region-dependent support).\n\n```ts\nimport { awsSnsAdapter } from '@elghaied/payload-plugin-sms/aws-sns'\n\nawsSnsAdapter({\n  region: 'us-east-1',\n  credentials: {                    // optional; falls back to default AWS credential chain\n    accessKeyId: '...',\n    secretAccessKey: '...',\n  },\n  defaultFrom: 'MYBRAND',           // sender ID\n  smsType: 'Transactional',         // or 'Promotional'\n})\n```\n\n### Mock (tests)\n\n```ts\nimport { mockAdapter } from '@elghaied/payload-plugin-sms/mock'\n\nconst adapter = mockAdapter({ defaultFrom: '+15550000000' })\nadapter.messages  // array of sent messages\nadapter.reset()   // clears it\n```\n\n## Plugin options\n\n| Option              | Type                                                       | Default       | Notes                                                     |\n| ------------------- | ---------------------------------------------------------- | ------------- | --------------------------------------------------------- |\n| `adapter`           | `SMSAdapter`                                               | —             | Required at runtime. Missing adapter → `sendSMS` throws.  |\n| `defaultFrom`       | `string`                                                   | —             | Falls back to adapter's `defaultFrom`.                    |\n| `disabled`          | `boolean`                                                  | `false`       | Skips registration; logs a warning.                       |\n| `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. |\n| `widgets`           | `boolean`                                                  | `true`        | Registers the dashboard widget when logs enabled.         |\n| `tenantScoping`     | `{ field?: string; cookie?: string }`                      | — (off)       | Opt-in. Scopes the dashboard widget to the host's selected tenant. See [Dashboard widget](#dashboard-widget). |\n| `webhooks`          | `{ enabled; basePath?; statusCallbackUrl?; trustProxy?; verifySignature? }` | — | Enables the delivery-status receiver + auto-derived callback. See [Delivery-status webhooks](#delivery-status-webhooks). |\n| `onSend`            | `(args) =\u003e void \\| Promise\u003cvoid\u003e`                          | —             | Called after every successful send.                       |\n| `onError`           | `(args) =\u003e void \\| Promise\u003cvoid\u003e`                          | —             | Called when send fails. Original error is re-thrown.      |\n| `onStatus`          | `(args) =\u003e void \\| Promise\u003cvoid\u003e`                          | —             | Called on every delivery-status webhook event (requires `webhooks.enabled`). |\n\n## Logs collection\n\nEnable with `collections: { logs: true }`. Schema:\n\n| Field               | Type     | Notes                                          |\n| ------------------- | -------- | ---------------------------------------------- |\n| `to`                | text     | Recipient                                      |\n| `from`              | text     | Sender                                         |\n| `body`              | textarea | Message body                                   |\n| `provider`          | text     | Adapter name                                   |\n| `status`            | select   | `queued`/`sent`/`delivered`/`failed`/`unknown` |\n| `providerMessageId` | text     | Provider's message id                          |\n| `cost`              | group    | `{ amount, currency }` when reported           |\n| `error`             | textarea | Adapter error message (if any)                 |\n| `errorCode`         | text     | Provider error code (from a failed-status webhook) |\n| `sentAt`            | date     | Server timestamp                               |\n| `deliveredAt`       | date     | Set when a webhook reports `delivered`         |\n| `failedAt`          | date     | Set when a webhook reports `failed`            |\n| `context`           | json     | Per-send metadata — only with `includeContext: true` |\n| `statusHistory`     | array    | Append-only `{ status, occurredAt, errorCode }` — only with `statusHistory: true` |\n\n`deliveredAt` / `failedAt` / `errorCode` / `statusHistory` are populated by [delivery-status webhooks](#delivery-status-webhooks).\n\nRead access requires a logged-in admin user. Create/update/delete from the admin panel are blocked — the plugin is the only writer.\n\nOverride the slug:\n\n```ts\ncollections: { logs: { slug: 'audit-sms', admin: { group: 'Audit' } } }\n```\n\nNote: if you override the slug, set `widgets: false` — the bundled widget reads from `sms-logs` only.\n\n## Dashboard widget\n\nWhen `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.\n\nDisable with `widgets: false`.\n\n### Multi-tenant scoping\n\nThe 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.\n\nOpt in with `tenantScoping` to make the widget honor the host's tenant selector:\n\n```ts\nsmsPlugin({\n  adapter,\n  collections: { logs: true },\n  tenantScoping: { field: 'tenant', cookie: 'payload-tenant' }, // both optional; these are the defaults\n})\n```\n\nWhen enabled **and** the logs collection actually has the configured `field`, the widget:\n\n- reads the selected tenant id from the `cookie` and adds `where[field][equals]=\u003cid\u003e` to both the 24h count and the recent-5 list, and\n- runs the queries with `overrideAccess: false` and the real request/user, so any read access control the host added to `sms-logs` is respected.\n\nWith 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.\n\n## Router adapter (multi-provider)\n\nFor multi-tenant SaaS or geo-routing, wrap multiple adapters in a `routerAdapter` and decide per-send which one handles the message.\n\n```ts\nimport { smsPlugin } from '@elghaied/payload-plugin-sms'\nimport { twilioAdapter } from '@elghaied/payload-plugin-sms/twilio'\nimport { telnyxAdapter } from '@elghaied/payload-plugin-sms/telnyx'\nimport { routerAdapter, byTenantLookup } from '@elghaied/payload-plugin-sms/router'\n\nsmsPlugin({\n  adapter: routerAdapter({\n    providers: {\n      twilio: twilioAdapter({ accountSid, authToken }),\n      telnyx: telnyxAdapter({ apiKey }),\n    },\n    route: byTenantLookup({\n      collection: 'tenants',\n      providerField: 'smsProvider',\n      cacheMs: 60_000,\n    }),\n  }),\n  collections: { logs: { includeContext: true } },\n})\n```\n\nAt the call site, attach the tenant id (and any per-tenant `from`):\n\n```ts\nawait req.payload.sendSMS({\n  to: customer.phone,\n  from: tenant.smsFromNumber,\n  body: '...',\n  context: { tenantId: tenant.id },\n})\n```\n\nThe router is just another `SMSAdapter`. Single-provider users (`smsPlugin({ adapter: twilioAdapter(...) })`) are unaffected.\n\n### Route helpers\n\n| Helper | Use |\n|---|---|\n| `byTenantLookup({ collection, providerField, contextKey?, cacheMs?, fallback? })` | SaaS: read tenant id from `message.context`, fetch the doc, return its provider field |\n| `byCountryPrefix({ '+1': 'twilio', '+33': 'telnyx' }, { fallback? })` | Geo route by E.164 prefix; longest-prefix wins |\n| `byRoundRobin(['twilio-a', 'twilio-b'])` | Cycle across duplicate accounts |\n| `byRandom(['twilio-a', 'twilio-b'])` | Uniform random pick |\n| `withFailover(inner, ['fallback-a', 'fallback-b'])` | Wrap any route; on `SMSProviderError`, try the next provider in order |\n\nYou can also write a `route` callback directly — it's just `(args) =\u003e string \\| string[]` (or async).\n\n### `context`\n\n`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.\n\n### Failover semantics\n\nA 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.\n\n## Hooks (onSend, onError)\n\n```ts\nsmsPlugin({\n  adapter: twilioAdapter({ ... }),\n  onSend: async ({ result, req }) =\u003e {\n    console.log(`Sent ${result.id} via ${result.provider} to ${result.to}`)\n  },\n  onError: async ({ error, message, req }) =\u003e {\n    console.error(`Failed to send to ${message.to}:`, error)\n  },\n})\n```\n\nHook 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).\n\n## Errors\n\n```ts\nimport { SMSValidationError, SMSProviderError } from '@elghaied/payload-plugin-sms'\n\ntry {\n  await payload.sendSMS({ to: 'not-e164', body: 'hi' })\n} catch (err) {\n  if (err instanceof SMSValidationError) {\n    // Bad input (E.164 fail, missing from, missing adapter)\n  } else if (err instanceof SMSProviderError) {\n    // Adapter call failed; `err.cause` has the original SDK error\n  }\n}\n```\n\n## Delivery-status webhooks\n\nEnable 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.)\n\n```ts\nimport { smsPlugin } from '@elghaied/payload-plugin-sms'\nimport { twilioAdapter } from '@elghaied/payload-plugin-sms/twilio'\n\nexport default buildConfig({\n  // ...\n  plugins: [\n    smsPlugin({\n      adapter: twilioAdapter({\n        accountSid: process.env.TWILIO_ACCOUNT_SID!,\n        authToken: process.env.TWILIO_AUTH_TOKEN!,\n        defaultFrom: process.env.TWILIO_FROM,\n        webhook: { trustProxy: true }, // optional, if behind a proxy\n      }),\n      collections: { logs: { statusHistory: true } },\n      webhooks: { enabled: true },\n      onStatus: ({ event, log }) =\u003e {\n        // optional: notify your app on every status transition\n      },\n    }),\n  ],\n})\n```\n\nWebhook URLs (Payload prepends `/api`):\n\n| Provider | URL                              | Required adapter `webhook` opts                                                         |\n| -------- | -------------------------------- | --------------------------------------------------------------------------------------- |\n| Twilio   | `/api/sms/webhooks/twilio`       | (none — uses `authToken`)                                                               |\n| Telnyx   | `/api/sms/webhooks/telnyx`       | `publicKey` (Ed25519 PEM, from Telnyx portal)                                           |\n| Plivo    | `/api/sms/webhooks/plivo`        | (none — uses `authToken`)                                                               |\n| Vonage   | `/api/sms/webhooks/vonage`       | `signatureSecret`, `signatureMethod: 'sha256hash' \\| 'sha512hash'`                      |\n| AWS SNS  | `/api/sms/webhooks/aws-sns`      | (none — verified via `SigningCertURL`; auto-confirms `SubscriptionConfirmation`)        |\n\nNotes:\n\n- 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).\n- 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.\n- Signature verification is **on by default**. Set `webhooks.verifySignature: false` only for local testing.\n- Status updates are gated by rank (`queued → sent → delivered`, with `failed` terminal), so out-of-order or duplicate webhooks are dropped silently.\n- Set `collections.logs.statusHistory: true` to keep an append-only history of every event.\n- Vonage plain MD5 signing is **not supported** (insecure). Use `sha256hash` or `sha512hash`.\n- Twilio and Plivo do not sign timestamps — those providers cannot prevent replay attacks at the signature layer.\n\n## Internationalization\n\nThe `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.\n\nOverride any string — or add a language — via `i18n.translations.{lang}.sms`:\n\n```ts\nbuildConfig({\n  i18n: {\n    translations: {\n      fr: { sms: { fieldTo: 'Destinataire' } },\n      // add a new language by supplying its sms.* keys\n    },\n  },\n  // ...\n})\n```\n\nThe raw translation tables are also available directly via the `@elghaied/payload-plugin-sms/translations` export.\n\n## Roadmap\n\n- Bulk / batch send\n- Templating (variable substitution, localization)\n- Per-recipient rate limiting\n- Inbound SMS handling\n- Vonage Messages API (JWT, MMS, WhatsApp)\n- MessageBird/Bird, Sinch, Infobip adapters\n\nPRs welcome.\n\n## Compatibility\n\n- Payload `^3.0.0` (tested against `3.84.1`)\n- Next.js `^16.0.0`\n- Node `^18.20.2 || \u003e=20.9.0`\n- React 19 is an optional peer dependency — required only if you use the dashboard widget (`@elghaied/payload-plugin-sms/rsc`).\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Felghaied%2Fpayload-plugin-sms","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Felghaied%2Fpayload-plugin-sms","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Felghaied%2Fpayload-plugin-sms/lists"}