This guide provides a complete walkthrough for building a backend system capable of sending bulk SMS marketing messages using Node.js, Express, and the Plivo Communications API. We'll cover everything from initial project setup to deployment considerations, focusing on creating a robust and scalable foundation.
By the end of this tutorial, you'll have a functional Express application with an API endpoint that accepts a list of phone numbers and a message body, then uses Plivo to send the SMS messages concurrently for efficient campaign execution. This solves the common need for businesses to programmatically reach their audience via SMS for marketing, notifications, or alerts.
Project Overview and Goals
Goal: To create a simple yet robust Node.js service that can send SMS messages in bulk via an API endpoint, leveraging Plivo for SMS delivery.
Problem Solved: Automates the process of sending marketing or notification SMS messages to a list of recipients, handling concurrency and basic error logging.
Technologies:
- Node.js: A JavaScript runtime environment ideal for building scalable, non-blocking I/O applications like API servers.
- Express.js: A minimal and flexible Node.js web application framework providing robust features for web and mobile applications (API routing, middleware).
- Plivo: A cloud communications platform providing APIs for SMS, Voice, and more. We'll use their Node.js SDK.
- dotenv: A zero-dependency module that loads environment variables from a
.env
file intoprocess.env
. Essential for managing sensitive credentials.
System Architecture:
The system follows this basic flow:
- A Client/Frontend sends a
POST
request to the/api/campaigns
endpoint of the Node.js/Express App. - The Node.js/Express App processes the request and sends individual SMS requests to the Plivo SMS API.
- The Plivo SMS API handles the delivery of the SMS messages to the End User Mobiles.
- The Node.js/Express App logs the results of the send attempts (e.g., to the console or a log file).
Expected Outcome:
- A running Express application.
- An API endpoint (
POST /api/campaigns
) that accepts JSON payload:{ ""recipients"": [""+1..."", ""+1...""], ""message"": ""Your message here"" }
. - Successful dispatch of SMS messages to the provided recipients via Plivo.
- Basic logging of successful sends and errors.
Prerequisites:
- A Plivo Account: Sign up here.
- Node.js and npm (or yarn) installed locally. Download Node.js.
- Basic understanding of JavaScript, Node.js, and REST APIs.
- A text editor or IDE (like VS Code).
- Access to a command line/terminal.
ngrok
(Optional, but recommended for testing incoming messages if needed later, e.g., for handling replies or delivery reports via webhooks, which is beyond the scope of this initial guide): Install ngrok.
1. Setting up the Project
Let's initialize our Node.js project and install the necessary dependencies.
-
Create Project Directory: Open your terminal and create a new directory for your project, then navigate into it.
mkdir plivo-sms-campaign cd plivo-sms-campaign
-
Initialize Node.js Project: This command creates a
package.json
file to manage project dependencies and scripts.npm init -y
(The
-y
flag accepts the default settings.) -
Install Dependencies: We need Express for the web server, Plivo's Node.js SDK to interact with their API, and
dotenv
for managing environment variables.npm install express plivo dotenv
(Note: Modern Express versions include body-parsing capabilities, so
body-parser
is often not needed separately). -
Create Project Structure: A good structure helps maintainability. Let's create some basic directories and files.
# On macOS/Linux mkdir src config routes services touch src/app.js src/server.js config/plivo.js routes/campaign.js services/smsService.js .env .gitignore # On Windows (Command Prompt) mkdir src config routes services echo. > src/app.js echo. > src/server.js echo. > config/plivo.js echo. > routes/campaign.js echo. > services/smsService.js echo. > .env echo. > .gitignore # On Windows (PowerShell) mkdir src, config, routes, services New-Item src/app.js, src/server.js, config/plivo.js, routes/campaign.js, services/smsService.js, .env, .gitignore -ItemType File
src/
: Contains our main application logic.config/
: For configuration files (like Plivo client setup).routes/
: Defines API endpoints.services/
: Contains business logic (like sending SMS)..env
: Stores environment variables (API keys, etc.). Never commit this file to Git..gitignore
: Specifies intentionally untracked files that Git should ignore.
-
Configure
.gitignore
: Addnode_modules
and.env
to your.gitignore
file to prevent committing dependencies and sensitive credentials.# Dependencies node_modules/ # Environment variables .env # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* # Optional files .DS_Store
-
Set up Environment Variables (
.env
): You need your Plivo Auth ID, Auth Token, and a Plivo phone number (or Sender ID where applicable).- Find Credentials: Log in to your Plivo Console. Your Auth ID and Auth Token are displayed prominently on the dashboard.
- Get a Plivo Number: Navigate to
Phone Numbers
->Buy Numbers
. Search for and purchase an SMS-enabled number suitable for your region (required for sending to US/Canada). If sending outside US/Canada, you might use an Alphanumeric Sender ID (see Caveats section). - Populate
.env
: Open the.env
file and add your credentials:
# Plivo Credentials PLIVO_AUTH_ID=YOUR_PLIVO_AUTH_ID PLIVO_AUTH_TOKEN=YOUR_PLIVO_AUTH_TOKEN PLIVO_SENDER_ID=YOUR_PLIVO_PHONE_NUMBER_OR_SENDER_ID # e.g., +14155551234 # Server Configuration PORT=3000
Replace the placeholder values with your actual credentials and number.
Why
dotenv
? It keeps sensitive information like API keys out of your codebase, making it more secure and easier to manage different configurations for development, staging, and production environments.
2. Implementing Core Functionality
Now, let's implement the core logic for sending SMS messages via Plivo.
-
Configure Plivo Client: We'll centralize the Plivo client initialization.
// config/plivo.js // Note: dotenv.config() should be called once at application entry point (e.g., app.js) const plivo = require('plivo'); const authId = process.env.PLIVO_AUTH_ID; const authToken = process.env.PLIVO_AUTH_TOKEN; if (!authId || !authToken) { console.error( 'Plivo Auth ID or Auth Token not found in environment variables.' ); console.error('Ensure PLIVO_AUTH_ID and PLIVO_AUTH_TOKEN are set in .env and dotenv.config() is called at app start.'); process.exit(1); // Exit if credentials aren't set } const client = new plivo.Client(authId, authToken); module.exports = client;
Why centralize? This ensures we initialize the client only once and makes it easily accessible throughout the application via
require('../config/plivo')
. -
Create SMS Sending Service: This service will contain the function responsible for sending a single SMS and eventually the bulk sending logic.
// services/smsService.js // Note: dotenv.config() should be called once at application entry point (e.g., app.js) const plivoClient = require('../config/plivo'); const senderId = process.env.PLIVO_SENDER_ID; if (!senderId) { console.error('PLIVO_SENDER_ID not found in environment variables. Ensure it is set in .env and dotenv.config() is called at app start.'); process.exit(1); } /** * Sends a single SMS message using Plivo. * @param {string} recipient - The destination phone number in E.164 format (e.g., +12223334444). * @param {string} message - The text message body. * @returns {Promise<object>} - A promise resolving with the Plivo API response on success. * @throws {Error} - Throws an error if the SMS sending fails. */ const sendSingleSms = async (recipient, message) => { console.log(`Attempting to send SMS to ${recipient}`); try { const response = await plivoClient.messages.create( senderId, // src recipient, // dst message // text ); console.log(`SMS sent successfully to ${recipient}:`, response.messageUuid); return response; // Contains message_uuid, api_id, etc. } catch (error) { console.error(`Failed to send SMS to ${recipient}:`, error); // Re-throw the error to be handled by the caller (e.g., the API route) // You might want more specific error handling/logging here in production throw new Error(`Plivo API error sending to ${recipient}: ${error.message}`); } }; /** * Sends SMS messages to multiple recipients concurrently. * @param {string[]} recipients - An array of destination phone numbers in E.164 format. * @param {string} message - The text message body. * @returns {Promise<object>} - A promise resolving with the results of all send attempts. */ const sendBulkSms = async (recipients, message) => { if (!Array.isArray(recipients) || recipients.length === 0) { throw new Error('Recipients array cannot be empty.'); } // Create an array of promises, one for each SMS send operation // Each promise resolves to an object indicating success or failure const sendPromises = recipients.map(recipient => sendSingleSms(recipient, message) .then(response => ({ // Wrap success status: 'success', value: response, recipient: recipient })) .catch(error => ({ // Wrap failure status: 'failed', recipient: recipient, error: error.message, })) ); // Use Promise.allSettled to wait for all promises to resolve or reject // This ensures we process all numbers even if some fail const results = await Promise.allSettled(sendPromises); // Process results to provide a summary const report = { totalAttempted: recipients.length, successCount: 0, failedCount: 0, details: [], }; results.forEach((result, index) => { const recipient = recipients[index]; // Get recipient from original array // Since we used .catch within map, all promises should settle as 'fulfilled' if (result.status === 'fulfilled') { const outcome = result.value; // This is our { status: 'success', ... } or { status: 'failed', ... } object if (outcome.status === 'success') { report.successCount++; report.details.push({ recipient: recipient, status: 'success', // Safely access messageUuid from the Plivo response within outcome.value // Plivo SDK might return messageUuid in an array, hence ?.[] access messageUuid: outcome.value?.messageUuid?.[0] || 'N/A', }); } else { // outcome.status === 'failed' report.failedCount++; report.details.push({ recipient: recipient, status: 'failed', error: outcome.error || 'Unknown failure reason', }); } } else { // This block should ideally not be reached due to the inner .catch, // but handle defensively in case of unexpected promise rejection. report.failedCount++; report.details.push({ recipient: recipient, status: 'failed', error: result.reason?.message || 'Promise rejected unexpectedly', }); } }); console.log(`Bulk send report: ${report.successCount} succeeded, ${report.failedCount} failed.`); return report; }; module.exports = { sendSingleSms, sendBulkSms, };
Why
Promise.allSettled
? For bulk operations, you often want to attempt all actions even if some fail.Promise.all
would reject immediately on the first error.Promise.allSettled
waits for all promises to either fulfill or reject, giving you a complete picture of the outcomes. We map the results to provide a clear success/failure report. Whyasync/await
? It makes asynchronous code (like API calls) look and behave a bit more like synchronous code, improving readability compared to nested.then()
calls.
3. Building the API Layer
Let's create the Express route that will receive campaign requests and trigger the SMS service.
-
Create Campaign Route: This file defines the endpoint for initiating SMS campaigns.
// routes/campaign.js const express = require('express'); const { sendBulkSms } = require('../services/smsService'); const router = express.Router(); // POST /api/campaigns router.post('/', async (req, res) => { const { recipients, message } = req.body; // --- Basic Input Validation --- if (!recipients || !Array.isArray(recipients) || recipients.length === 0) { // Use standard double quotes in the JSON string return res.status(400).json({ error: 'Invalid or missing ""recipients"" array.' }); } if (!message || typeof message !== 'string' || message.trim() === '') { // Use standard double quotes in the JSON string return res.status(400).json({ error: 'Invalid or missing ""message"" string.' }); } // Add more specific validation (e.g., E.164 format check) if needed try { console.log(`Received campaign request: ${recipients.length} recipients.`); // Call the bulk sending service const report = await sendBulkSms(recipients, message); // Respond with the report res.status(200).json({ message: `Campaign processing initiated. Results below.`, report: report, }); } catch (error) { console.error('Error processing campaign request:', error); // Generic error for the client, specific error logged server-side res.status(500).json({ error: 'Failed to process SMS campaign.', details: error.message }); } }); module.exports = router;
- We destructure
recipients
andmessage
from the request body (req.body
). - Basic validation checks if the required fields exist and are of the correct type. More robust validation (e.g., using libraries like
joi
orexpress-validator
) is recommended for production. - The
sendBulkSms
service function is called. - The response includes the detailed report generated by the service.
- We destructure
-
Set up Express App: Configure the main Express application to use middleware and mount the router.
// src/app.js require('dotenv').config(); // Ensure env vars are loaded ONCE at the very start const express = require('express'); const campaignRoutes = require('../routes/campaign'); const app = express(); // --- Middleware --- // Use Express's built-in JSON body parser app.use(express.json()); // Use Express's built-in URL-encoded body parser (optional, for form data) app.use(express.urlencoded({ extended: true })); // Basic Logging Middleware (Example) app.use((req, res, next) => { console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`); next(); // Pass control to the next middleware/route handler }); // --- Routes --- app.get('/health', (req, res) => { res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() }); }); // Mount the campaign routes under the /api/campaigns prefix app.use('/api/campaigns', campaignRoutes); // --- Basic Error Handling Middleware (Catch-all) --- // This should be defined AFTER your routes app.use((err, req, res, next) => { console.error('Unhandled Error:', err.stack || err); res.status(500).json({ error: 'Something went wrong on the server!' }); }); module.exports = app; // Export the configured app
- We import
express
and our campaign router. express.json()
middleware is crucial for parsing the JSON payload sent to our API.- A simple logging middleware shows incoming requests.
- A
/health
endpoint is added for basic monitoring. - The campaign router is mounted at
/api/campaigns
. - A basic catch-all error handler is included.
- We import
-
Create Server Entry Point: This file starts the actual HTTP server.
// src/server.js const app = require('./app'); // Import the configured Express app const PORT = process.env.PORT || 3000; // Use port from .env or default to 3000 app.listen(PORT, () => { console.log(`Server listening on port ${PORT}`); console.log(`Campaign endpoint available at: POST http://localhost:${PORT}/api/campaigns`); console.log(`Health check available at: GET http://localhost:${PORT}/health`); });
-
Add Start Script: Modify your
package.json
to add a convenient start script.// package.json (add within the ""scripts"" object) ""scripts"": { ""start"": ""node src/server.js"", ""test"": ""echo \""Error: no test specified\"" && exit 1"" },
-
Run the Application:
npm start
You should see output indicating the server is running on port 3000.
4. Integrating with Plivo (Deep Dive)
We've already set up the client, but let's reiterate the key integration points:
- Credentials:
PLIVO_AUTH_ID
andPLIVO_AUTH_TOKEN
are mandatory. They authenticate your application with Plivo's API.- Location: Plivo Console Dashboard (console.plivo.com).
- Security: Stored securely in
.env
and accessed viaprocess.env
. Never hardcode them in your source code. Ensuredotenv.config()
is called once at your application's entry point (src/app.js
in this case).
- Sender ID (
PLIVO_SENDER_ID
): This is the 'From' number or ID shown to the recipient.- Format: E.164 format phone number (e.g.,
+14155551234
) is required for sending to US/Canada. Must be a Plivo number you own/rented. - Location: Find/Buy numbers in Plivo Console -> Phone Numbers -> Your Numbers / Buy Numbers.
- Alternatives: For other countries, you might be able to use a registered Alphanumeric Sender ID (e.g.,
""MyBrand""
). See the ""Caveats"" section. Check Plivo's SMS API Coverage page for country-specific rules. - Configuration: Set in
.env
asPLIVO_SENDER_ID
.
- Format: E.164 format phone number (e.g.,
5. Error Handling, Logging, and Retry Mechanisms
Our current setup includes basic error handling and logging. Let's refine it.
- Consistent Error Handling:
- The
smsService
catches errors during the Plivo API call (sendSingleSms
) and logs them with recipient info. sendBulkSms
usesPromise.allSettled
and compiles a detailed report, distinguishing successes from failures.- The API route (
routes/campaign.js
) catches errors during request processing (validation, service calls) and returns appropriate HTTP status codes (400 for bad requests, 500 for server errors). - The global error handler in
app.js
catches any unhandled exceptions.
- The
- Logging:
-
Currently using
console.log
andconsole.error
. For production, use a dedicated logging library likewinston
orpino
. -
Setup (Example with Winston):
npm install winston
Create a logger configuration (e.g.,
config/logger.js
):// config/logger.js const winston = require('winston'); const logger = winston.createLogger({ level: 'info', // Log only info and above by default format: winston.format.combine( winston.format.timestamp(), winston.format.json() // Log in JSON format ), transports: [ // - Write all logs with level `error` and below to `error.log` new winston.transports.File({ filename: 'error.log', level: 'error' }), // - Write all logs with level `info` and below to `combined.log` new winston.transports.File({ filename: 'combined.log' }), ], }); // If we're not in production then log to the `console` with the format: // `${info.level}: ${info.message} JSON.stringify({ ...rest }) ` if (process.env.NODE_ENV !== 'production') { logger.add(new winston.transports.Console({ format: winston.format.simple(), })); } module.exports = logger;
Replace
console.log/error
calls withlogger.info()
,logger.warn()
,logger.error()
. -
Log Analysis: JSON logs are easier to parse by log management systems (like Datadog, Splunk, ELK stack). Log unique identifiers (like
messageUuid
from Plivo) to trace requests.
-
- Retry Mechanisms:
-
Plivo handles some level of retries internally for deliverability.
-
For transient network errors or specific Plivo API errors (e.g., rate limits -
429 Too Many Requests
), you might implement application-level retries. -
Strategy: Use exponential backoff (wait longer between retries).
-
Example Concept (in
sendSingleSms
):// Inside sendSingleSms, conceptual example - use a library for robustness const maxRetries = 3; let attempt = 0; while (attempt < maxRetries) { try { const response = await plivoClient.messages.create(/*...*/); // Success_ break loop return response; } catch (error) { attempt++; // Example: Retry only on specific_ potentially transient errors like rate limits if (error.statusCode === 429 && attempt < maxRetries) { const delay = Math.pow(2_ attempt) * 100; // Exponential backoff (100ms base) console.warn(`Rate limited sending to ${recipient}. Retrying attempt ${attempt}/${maxRetries} after ${delay}ms...`); await new Promise(resolve => setTimeout(resolve, delay)); } else { // Don't retry other errors or if max retries reached console.error(`Failed to send SMS to ${recipient} after ${attempt} attempts:`, error); // Re-throw the original error or a new one summarizing the failure throw new Error(`Plivo API error sending to ${recipient}: ${error.message}`); } } } // If loop finishes without returning/throwing, it means max retries failed throw new Error(`Failed to send SMS to ${recipient} after ${maxRetries} attempts.`);
-
Recommendation: While the above illustrates the concept, using established libraries like
async-retry
orp-retry
is strongly recommended for production code. They handle edge cases and configuration more robustly. -
Caution: Be careful not to retry non-recoverable errors (e.g., invalid number
400
, insufficient funds402
). Check Plivo's API error codes and retry logic documentation. Only retry errors that are likely temporary.
-
6. Database Schema and Data Layer (Conceptual)
While this guide focuses on sending, a real marketing platform needs data persistence.
-
Need: Store contacts/subscribers, campaign details (message, target list, schedule), send status (
messageUuid
, delivered, failed), and potentially track responses or unsubscribes. -
Schema Example (Conceptual - e.g., using PostgreSQL):
CREATE TABLE contacts ( contact_id SERIAL PRIMARY KEY, phone_number VARCHAR(20) UNIQUE NOT NULL, -- E.164 format first_name VARCHAR(100), last_name VARCHAR(100), subscribed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, is_active BOOLEAN DEFAULT TRUE -- Add other relevant fields (tags, groups, etc.) ); CREATE TABLE campaigns ( campaign_id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, message_body TEXT NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, scheduled_at TIMESTAMP WITH TIME ZONE, status VARCHAR(20) DEFAULT 'draft' -- e.g., draft, sending, completed, failed ); CREATE TABLE campaign_sends ( send_id SERIAL PRIMARY KEY, campaign_id INTEGER REFERENCES campaigns(campaign_id) ON DELETE CASCADE, contact_id INTEGER REFERENCES contacts(contact_id) ON DELETE CASCADE, plivo_message_uuid VARCHAR(50) UNIQUE, -- From Plivo response status VARCHAR(20) DEFAULT 'queued', -- e.g., queued, sent, delivered, failed, undelivered status_updated_at TIMESTAMP WITH TIME ZONE, error_message TEXT, sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); -- Index for faster lookups CREATE INDEX idx_campaign_sends_status ON campaign_sends(status); CREATE INDEX idx_campaign_sends_uuid ON campaign_sends(plivo_message_uuid);
(An Entity Relationship Diagram would visually represent these relationships).
-
Data Access: Use an ORM (like Sequelize, Prisma, TypeORM) or a query builder (like Knex.js) to interact with the database from Node.js. This involves setting up database connections, defining models/schemas in code, and writing queries/mutations.
-
Implementation:
- Choose a database and ORM/query builder.
- Install necessary packages (
pg
,sequelize
, etc.). - Configure database connection (likely using
.env
). - Define models matching the schema.
- Implement functions to:
- Fetch contacts for a campaign.
- Create a campaign record.
- Insert
campaign_sends
records before sending. - Update
campaign_sends
withplivo_message_uuid
and status after sending attempts. - (Advanced) Set up a webhook to receive delivery reports from Plivo and update
campaign_sends
status (delivered
,failed
, etc.).
7. Adding Security Features
Security is paramount, especially when handling user data and API keys.
-
Input Validation:
-
Already implemented basic checks in
routes/campaign.js
. -
Enhancement: Use robust libraries like
joi
orexpress-validator
for complex validation (e.g., ensuring phone numbers match E.164 format, checking message length). -
Example (using
express-validator
):npm install express-validator
// routes/campaign.js (modified) const express = require('express'); const { sendBulkSms } = require('../services/smsService'); const { body, validationResult } = require('express-validator'); const router = express.Router(); router.post('/', // Validation middleware chain body('recipients').isArray({ min: 1 }).withMessage('Recipients must be a non-empty array.'), // Example E.164 validation - adjust options as needed for international numbers body('recipients.*').isMobilePhone('any', { strictMode: true }) .withMessage('Each recipient must be a valid phone number, preferably in E.164 format (e.g., +14155551234).') .custom((value) => { // Add explicit '+' check for E.164 enforcement if needed if (!value.startsWith('+')) { throw new Error('Recipient phone number must start with + (E.164 format).'); } return true; }), body('message').isString().trim().notEmpty().withMessage('Message must be a non-empty string.'), async (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ errors: errors.array() }); } // Destructure validated data (req.body is already updated by middleware) const { recipients, message } = req.body; // ... rest of the handler logic using validated recipients and message ... try { console.log(`Received validated campaign request: ${recipients.length} recipients.`); const report = await sendBulkSms(recipients, message); res.status(200).json({ message: `Campaign processing initiated. Results below.`, report: report, }); } catch (error) { console.error('Error processing campaign request:', error); res.status(500).json({ error: 'Failed to process SMS campaign.', details: error.message }); } } ); module.exports = router;
-
-
Sanitization: While less critical for SMS text content itself (as Plivo handles encoding), sanitize any user input that might be stored in a database or displayed elsewhere to prevent XSS if data is ever rendered in HTML. Libraries like
dompurify
(if rendering) or simple replacements can help. -
Authentication/Authorization: Our current API is open. In production, you'd need to protect it:
- API Keys: Issue unique keys to clients. Verify the key in middleware.
- JWT (JSON Web Tokens): For user-based authentication if a frontend is involved.
- Basic Auth: Simpler, but less secure (credentials sent with each request).
-
Rate Limiting: Prevent abuse and protect your Plivo account balance/API limits.
-
Implementation: Use middleware like
express-rate-limit
.npm install express-rate-limit
// src/app.js (add in middleware section, before API routes) const rateLimit = require('express-rate-limit'); const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // Limit each IP to 100 requests per windowMs message: 'Too many requests from this IP, please try again after 15 minutes', standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers }); // Apply to all API routes or specific ones app.use('/api/', apiLimiter);
-
-
Secure Credential Storage: We are using
.env
, which is good. Ensure the.env
file has strict file permissions on the server and is never checked into version control. Use environment variables provided by your hosting platform for production. -
Helmet: Use the
helmet
middleware for setting various security-related HTTP headers (likeContent-Security-Policy
,Strict-Transport-Security
).npm install helmet
// src/app.js (add early in middleware section) const helmet = require('helmet'); app.use(helmet());
8. Handling Special Cases
Real-world SMS involves nuances:
- E.164 Format: Plivo (and most providers) require phone numbers in E.164 format (e.g.,
+14155551234
). Your validation should enforce this (seeexpress-validator
example above). - Sender ID Regulations:
- US/Canada: Must use a Plivo-rented, SMS-enabled local or toll-free number. Alphanumeric Sender IDs are generally not supported for sending to these countries due to carrier restrictions and regulations like A2P 10DLC.