stacks-sms

$npx mdskill add stacksjs/stacks/stacks-sms

Send SMS messages with Twilio or Vonage using a fluent builder API.

  • Sends text messages, OTPs, and bulk SMS via Twilio or Vonage drivers.
  • Depends on Twilio and Vonage SDKs, configured in config/sms.ts.
  • Selects driver based on configuration; uses SmsBuilder for fluent construction.
  • Returns send results, verification status, or formatted phone numbers.

SKILL.md

.github/skills/stacks-smsView on GitHub ↗
---
name: stacks-sms
description: Use when implementing SMS in Stacks — sending text messages, the SmsBuilder fluent API, SMS templates, phone verification (OTP/2FA), bulk sending, Twilio/Vonage drivers, E.164 formatting, or the SMS facade. Covers @stacksjs/sms and config/sms.ts.
license: MIT
compatibility: Bun >= 1.3.0, TypeScript
allowed-tools: Read Edit Write Bash Grep Glob
---

# Stacks SMS

Multi-driver SMS system with verification (OTP), templates, bulk sending, and a fluent builder API. Two fully implemented drivers: Twilio and Vonage.

## Key Paths
- Core package: `storage/framework/core/sms/src/`
- SMS facade and builder: `storage/framework/core/sms/src/sms.ts`
- Twilio driver: `storage/framework/core/sms/src/drivers/twilio.ts`
- Vonage driver: `storage/framework/core/sms/src/drivers/vonage.ts`
- Drivers index: `storage/framework/core/sms/src/drivers/index.ts`
- Configuration: `config/sms.ts`

## Package Exports

```typescript
// Main facade and functions
import SMS, { sms, send, sendSms, sendBulk, sendTemplate } from '@stacksjs/sms'
import { startVerification, checkVerification, cancelVerification } from '@stacksjs/sms'
import { formatE164, isValidPhoneNumber, isEnabled, getConfig, configure, getDriver, init } from '@stacksjs/sms'
import { SmsBuilder } from '@stacksjs/sms'

// Drivers
import { TwilioDriver, createTwilioDriver } from '@stacksjs/sms'
import { VonageDriver, createVonageDriver } from '@stacksjs/sms'
```

## SMS Facade Object

The `SMS` default export aggregates all functions:

```typescript
SMS.init()                    // Load config from config/sms.ts
SMS.configure(config)         // Override config at runtime
SMS.isEnabled()               // Check if SMS is enabled (config.enabled)
SMS.getConfig()               // Get current config (returns copy)

SMS.send(message)             // Send single SMS
SMS.sendSms(message)          // Alias for send
SMS.sendBulk(messages)        // Send multiple SMS
SMS.sendTemplate(to, name, vars)  // Send using template

SMS.getStatus(messageId)      // Get delivery status
SMS.getBalance()              // Get account balance
SMS.verifyNumber(phone)       // Lookup/verify phone number

SMS.startVerification(req)    // Start OTP flow
SMS.checkVerification(req)    // Verify OTP code
SMS.cancelVerification(id)    // Cancel pending verification

SMS.formatE164(phone, cc?)    // Format to E.164
SMS.isValidPhoneNumber(phone) // Validate E.164 format

SMS.sms()                     // Create SmsBuilder instance
SMS.getDriver(provider?)      // Get driver instance
SMS.getVerificationDriver(p?) // Get verification driver
```

## SmsBuilder (Fluent API)

```typescript
import { sms } from '@stacksjs/sms'

// Basic SMS
await sms()
  .to('+1234567890')
  .body('Your code is 123456')
  .from('+0987654321')
  .send()

// With provider selection
await sms()
  .to('+1234567890')
  .body('Hello!')
  .via('vonage')
  .send()

// MMS with media and callback
await sms()
  .to(['+1111111111', '+2222222222'])
  .body('Check this out')
  .media(['https://example.com/image.jpg'])
  .callback('https://myapp.com/sms/status')
  .send()

// .text() is an alias for .body()
await sms()
  .to('+1234567890')
  .text('Hello!')
  .send()
```

The builder validates that `to` and `body` are set before sending. If missing, it returns a failed `SmsSendResult` without throwing. If `.via(provider)` is set, it creates a new driver for that provider; otherwise uses the default driver.

## Direct Send Functions

```typescript
import { send, sendBulk } from '@stacksjs/sms'

// SmsMessage shape
await send({
  to: '+1234567890',         // string or string[]
  body: 'Hello!',
  from: '+0987654321',       // optional, falls back to config
  mediaUrls: ['url'],        // optional, for MMS
  statusCallback: 'url',     // optional, webhook for status updates
})

// Bulk send
await sendBulk([msg1, msg2, msg3])  // returns SmsSendResult[]
```

`send()` and all sending functions call `ensureConfig()` first, which lazily loads `config/sms.ts` via dynamic import.

## Message Status & Info

```typescript
import { getStatus, verifyNumber, getBalance } from '@stacksjs/sms'

// Get delivery status for a message
const status = await getStatus('SM_message_id')
// Returns SmsStatusUpdate | null (null if driver doesn't support it)

// Verify/lookup a phone number
const info = await verifyNumber('+1234567890')
// Returns { valid: boolean, carrier?: string, type?: string }

// Get account balance
const balance = await getBalance()
// Returns { balance: number, currency: string } | null
```

## Phone Verification (OTP/2FA)

```typescript
import { startVerification, checkVerification, cancelVerification } from '@stacksjs/sms'

// Start -- sends OTP code via SMS (or 'call', 'email' for Twilio; 'sms', 'whatsapp' for Vonage)
const result = await startVerification({
  to: '+1234567890',
  channel: 'sms',        // default: 'sms'
  codeLength: 6,          // optional
  locale: 'en',           // optional
  customMessage: 'Your code: {code}',  // optional, Twilio only
})
// Returns { success: boolean, verificationId?: string, status: 'pending' | 'denied', error?: string }

// Check -- verify the code the user entered
const check = await checkVerification({
  to: '+1234567890',           // Twilio requires 'to'
  verificationId: result.verificationId,  // Vonage requires this
  code: '123456',
})
// Returns { success: boolean, verificationId?: string, status: 'approved' | 'denied', error?: string }

// Cancel -- cancel a pending verification
const cancelled = await cancelVerification(result.verificationId!)
// Returns boolean
```

**Twilio** uses the Verify service (`verify.twilio.com/v2`) and requires `verifyServiceSid` passed to the TwilioDriver constructor.

**Vonage** uses the Verify V2 API (`api.nexmo.com/v2/verify`). Whatsapp channel maps to `whatsapp_interactive`.

## SMS Templates

```typescript
import { sendTemplate } from '@stacksjs/sms'

await sendTemplate('+1234567890', 'order-confirmation', {
  orderNumber: '#12345',
  total: '$29.99',
})
```

Templates are defined in `config/sms.ts` under `templates[]`. Each template has `name`, `body`, and optional `variables`. Variables use single-brace syntax `{variableName}` in the template body. The function finds the template by name, replaces variables, and sends via the default driver.

Returns a failed result (without throwing) if the template is not found.

## Phone Number Utilities

```typescript
import { formatE164, isValidPhoneNumber } from '@stacksjs/sms'

// Normalize to E.164 format
formatE164('+1 (234) 567-890')        // '+12345678900'
formatE164('2345678900', '1')          // '+12345678900'
formatE164('002345678900')             // '+2345678900' (00 prefix stripped)

// Validate E.164 format (regex: /^\+[1-9]\d{6,14}$/)
isValidPhoneNumber('+1234567890')      // true
isValidPhoneNumber('1234567890')       // true (formatted first, then validated)
```

`formatE164()` strips spaces, dashes, and parentheses. If the number starts with `+`, returns as-is. If it starts with `00`, replaces with `+`. Otherwise prepends `+` and the default country code (from `config.defaultCountryCode` or `'1'` for US).

## Twilio Driver

```typescript
import { TwilioDriver, createTwilioDriver } from '@stacksjs/sms'

const driver = new TwilioDriver({
  accountSid: 'AC...',
  authToken: '...',
  from: '+1234567890',
  messagingServiceSid: 'MG...',    // optional, used instead of 'from'
  statusCallback: 'https://...',   // optional, default callback URL
}, 'VA_verify_service_sid')         // optional second arg for verification

// Or use factory
const driver = createTwilioDriver(config, verifyServiceSid?)
```

Uses REST API at `https://api.twilio.com/2010-04-01`. Auth via HTTP Basic (`accountSid:authToken`).

Features:
- `send(message)` -- sends via Messages API, supports MMS (mediaUrls), statusCallback, messagingServiceSid
- `sendBulk(messages)` -- sends in parallel via `Promise.all`
- `getStatus(messageId)` -- retrieves message status
- `verify(phoneNumber)` -- uses Twilio Lookup API v2 with line_type_intelligence
- `getBalance()` -- retrieves account balance
- `startVerification(req)` -- Twilio Verify service
- `checkVerification(req)` -- check OTP code
- `cancelVerification(id)` -- cancel pending verification

Status mapping: queued, sending, sent, delivered, undelivered -> `SmsStatus`; canceled -> 'failed'.

## Vonage Driver

```typescript
import { VonageDriver, createVonageDriver } from '@stacksjs/sms'

const driver = new VonageDriver({
  apiKey: '...',
  apiSecret: '...',
  from: 'MyApp',
  applicationId: '...',    // optional, for JWT auth
  privateKey: '...',        // optional, for JWT auth
}, true)                    // optional: useMessagesApi (default: false)

// Or use factory
const driver = createVonageDriver(config, useMessagesApi?)
```

Two send modes:
- **SMS API** (legacy, default) -- `https://rest.nexmo.com/sms/json`, uses API key/secret in body
- **Messages API** (newer) -- `https://api.nexmo.com/v1/messages`, uses Basic auth or JWT

Features:
- `send(message)` -- sends via chosen API mode
- `sendBulk(messages)` -- sends in parallel
- `getStatus(messageId)` -- Messages API only
- `verify(phoneNumber)` -- Vonage Number Insight API (basic)
- `getBalance()` -- account balance in EUR
- `startVerification(req)` -- Vonage Verify V2 API
- `checkVerification(req)` -- requires `verificationId`
- `cancelVerification(id)` -- DELETE request to cancel

The `nexmo` export is a legacy alias for `vonage`.

## Other Drivers (Commented Out / Placeholder)

The `drivers/` directory contains placeholder files for: `gupshup`, `nexmo` (alias for vonage), `plivo`, `sms77`, `sns`, `telnyx`, `termii`. These are all commented out and not functional. Only Twilio and Vonage are active.

## config/sms.ts

```typescript
{
  enabled: false,                    // Must explicitly enable
  provider: 'twilio',               // 'twilio' | 'vonage'
  from: env.SMS_FROM_NUMBER || '',
  defaultCountryCode: 'US',
  messageType: 'TRANSACTIONAL',     // TRANSACTIONAL or PROMOTIONAL

  drivers: {
    twilio: {
      accountSid: env.TWILIO_ACCOUNT_SID || '',
      authToken: env.TWILIO_AUTH_TOKEN || '',
      from: env.TWILIO_FROM_NUMBER || '',
      messagingServiceSid: env.TWILIO_MESSAGING_SERVICE_SID || '',
    },
    vonage: {
      apiKey: env.VONAGE_API_KEY || '',
      apiSecret: env.VONAGE_API_SECRET || '',
      from: env.VONAGE_FROM_NUMBER || '',
    },
    pinpoint: {
      region: env.AWS_REGION || 'us-east-1',
      accessKeyId: env.AWS_ACCESS_KEY_ID || '',
      secretAccessKey: env.AWS_SECRET_ACCESS_KEY || '',
      senderId: env.SMS_SENDER_ID || '',
      originationNumber: env.SMS_ORIGINATION_NUMBER || '',
    },
  },

  maxSpendPerMonth: 100,
  optOut: {
    enabled: true,
    keywords: ['STOP', 'UNSUBSCRIBE', 'CANCEL', 'END', 'QUIT'],
  },
  templates: [],                     // Array of { name, body, variables }
  twoWay: {
    enabled: false,
  },
} satisfies SmsConfig
```

## Type Interfaces (from @stacksjs/types)

```typescript
interface SmsMessage {
  to: string | string[]
  body: string
  from?: string
  mediaUrls?: string[]
  statusCallback?: string
}

interface SmsSendResult {
  success: boolean
  messageId?: string
  status?: SmsStatus
  to: string
  provider: string
  error?: string
  segments?: number
  price?: number
  currency?: string
}

type SmsStatus = 'queued' | 'sending' | 'sent' | 'delivered' | 'undelivered' | 'failed' | 'unknown'

interface SmsStatusUpdate {
  messageId: string
  to: string
  status: SmsStatus
  timestamp: Date
  errorCode?: string
  errorMessage?: string
}

type SmsProvider = 'twilio' | 'vonage' | 'pinpoint'

interface VerificationRequest {
  to: string
  channel?: string        // 'sms' | 'call' | 'email' | 'whatsapp'
  codeLength?: number
  locale?: string
  customMessage?: string  // Twilio only
}

interface VerificationCheckRequest {
  to: string
  code: string
  verificationId?: string
}

interface VerificationResult {
  success: boolean
  verificationId?: string
  status: 'pending' | 'approved' | 'denied'
  error?: string
}
```

## Gotchas
- SMS is **disabled by default** (`enabled: false`) -- must set to `true` in `config/sms.ts`
- Provider API keys go in `.env`, not config files
- Config is loaded lazily via dynamic import on first `send()`/operation -- `init()` pre-loads it
- Only Twilio and Vonage drivers are fully implemented; other drivers (gupshup, plivo, sns, telnyx, etc.) are commented-out placeholders
- `pinpoint` appears in config but has no driver implementation in `getDriver()` -- using it throws `'Unsupported SMS provider: pinpoint'`
- The `defaultCountryCode` in config is `'US'` but `formatE164()` uses it as a numeric code prefix (the fallback is `'1'`), so the actual behavior uses the number `'1'` for US
- Template variables use single-brace syntax `{variableName}`, NOT double-brace
- `sendBulk()` sends all messages in parallel via `Promise.all` -- no rate limiting
- Twilio verification requires a `verifyServiceSid` passed to the driver constructor, which is not part of the standard config structure
- Vonage JWT authentication is a simplified placeholder -- real RS256 signing is not fully implemented
- `getDriver()` creates a new driver instance each time unless accessed through the cached `getDefaultDriver()`
- `sendTemplate()` returns a failed result (not an exception) if the template name is not found

More from stacksjs/stacks

SkillDescription
stacks-actionsUse when working with Stacks server actions — creating actions in app/Actions/, auto-generated API actions from the useApi model trait, the 80+ default framework actions (auth, dashboard, commerce, content, deployment, jobs), action request/response handling, or action registration. Covers @stacksjs/actions and storage/framework/defaults/actions/.
stacks-aiUse when integrating AI capabilities into a Stacks application — using Anthropic/OpenAI/Ollama/AWS Bedrock drivers, image generation (DALL-E), vision analysis, RAG/vector search, embeddings, MCP (Model Context Protocol) clients, text summarization, sentiment analysis, content classification, personalization, or the buddy AI assistant. Covers @stacksjs/ai and config/ai.ts.
stacks-aliasUse when working with path aliases in a Stacks project — import resolution, module aliasing, or debugging import paths. Covers @stacksjs/alias which defines 260+ path mappings for the entire framework.
stacks-analyticsUse when adding analytics to a Stacks application — configuring Fathom or self-hosted analytics, generating tracking scripts, privacy-friendly analytics setup, or the analytics configuration. Covers @stacksjs/analytics and config/analytics.ts.
stacks-apiUse when building, modifying, or debugging API endpoints in a Stacks application — defining routes, handling requests, API middleware, working with the API server, HTTP client (fetcher), API resources, or OpenAPI generation. Covers both @stacksjs/api utilities and the stacks-api server implementation.
stacks-arraysUse when working with array utilities in Stacks — statistical operations (average, median, mode, standard deviation, z-score, percentile, covariance), array manipulation (unique, flatten, partition, shuffle, sample, move), containment checks, or the Arr facade. Covers @stacksjs/arrays.
stacks-authUse when implementing authentication, authorization, passkeys, TOTP/2FA, RBAC, gates, policies, session auth, token management, email verification, password resets, or rate limiting in a Stacks application. Covers the @stacksjs/auth package, config/auth.ts, app/Gates.ts, and app/Middleware/.
stacks-auto-importsUse when working with the Stacks auto-import system — understanding how browser and server auto-imports work, configuring auto-imported functions/models/composables, the auto-import manifests, type generation, or how globals are injected. Covers the auto-import pipeline at storage/framework/auto-imports/.
stacks-brandkitUse when defining or exploring a brand identity for a Stacks project and you want premium brand-kit REFERENCE IMAGES first - logo directions, palettes, typography specimens, identity boards, and application/mockup decks. Generates IMAGES ONLY, never code. Trained for minimalist, cinematic, editorial, dark-tech, luxury, cultural, security, gaming, developer-tool, and consumer-app brand systems with intentional logo concepting, sparse type, strong symbolic meaning, and disciplined palettes a coding step can build against.
stacks-browseUse for headless browser QA on Stacks applications — navigation, screenshots, responsive testing, console/network monitoring, and accessibility snapshots. Dependency-free: drives a system browser over the Chrome DevTools Protocol using only Bun (no Playwright/Puppeteer). Invoke with /stacks-browse.