Build production-ready two-way SMS messaging in your RedwoodJS application using MessageBird's SMS API and webhook system. This tutorial shows you how to send outbound SMS messages via GraphQL and receive inbound SMS through secure webhooks—perfect for customer support chat, appointment confirmations, OTP verification, automated responses, and real-time notifications.
You'll implement complete bidirectional SMS communication with webhook signature verification, message logging, replay attack prevention, and production deployment strategies for RedwoodJS applications.
What You'll Build: Two-Way SMS Communication System
Core Features:
Outbound SMS Sending: Trigger SMS messages programmatically through GraphQL mutations from your RedwoodJS application
Inbound SMS Reception: Handle incoming SMS messages sent to your MessageBird virtual number using secure webhook endpoints
Message Persistence: Store complete conversation history of both inbound and outbound messages in your database
Webhook Security: Implement HMAC-SHA256 signature verification and timestamp validation to prevent unauthorized access
Real-World Use Cases:
This two-way SMS system enables interactive communication for appointment reminders with confirmations, customer support conversations, two-factor authentication codes, order status updates with customer replies, survey responses, and automated chatbot interactions.
Technology Stack:
RedwoodJS v8.0+: Full-stack JavaScript/TypeScript framework with GraphQL and serverless functions. Requires Node.js v20.x or later
Node.js v20.x+: Runtime environment for the RedwoodJS API side (official source)
Yarn v1.22.21+: Package manager required by RedwoodJS
MessageBird SMS API: Communication platform providing SMS API and virtual phone numbers for two-way messaging
MessageBird Node.js SDK v3.8.0+: Simplifies interaction with the MessageBird API. Requires Node.js >=0.10 (npm package)
Prisma ORM: Database toolkit used by RedwoodJS for type-safe database access
Database: PostgreSQL, MySQL, or SQLite (PostgreSQL recommended for production)
ngrok v3.0+: Development tool to expose your local server for testing inbound SMS webhooks
Virtual Phone Number: Purchase an SMS-enabled number from MessageBird Dashboard → Numbers → Buy a number for receiving inbound SMS
ngrok: Download from ngrok.com/download. Free account required for webhook testing
Database: PostgreSQL recommended. Install locally or use hosted service (e.g., Railway, Render, Supabase)
Expected Outcome:
A fully functional RedwoodJS application that sends SMS via GraphQL and receives/processes/logs incoming SMS messages via secure webhooks for true two-way communication.
1. Setting up the Project
Initialize a new RedwoodJS project, set up the database, and install dependencies.
Step 1.1: Install Prerequisites
Node.js and Yarn:
Bash
# Verify Node.js version (must be v20.x or later)
node --version
# If needed, download from https://nodejs.org/# Install Yarn globally
npm install -g yarn
# Verify Yarn installation
yarn --version
Copy your Live API Key (starts with live_) for production use
Copy your Test API Key (starts with test_) for development
Test keys simulate API requests without sending actual SMS or consuming credits. Live keys send real messages and deduct from your balance (official documentation).
Obtain Signing Key:
In Dashboard, go to Developer Settings → Webhook Signing
Click Generate signing key
Copy the signing key (used to verify webhook authenticity)
Purchase Virtual Number:
Go to Numbers → Buy a number
Select your country and ensure SMS capability is enabled
Choose and purchase a number
Note the number in E.164 format (e.g., +14155552671)
Step 1.6: Configure Environment Variables
Never commit API keys to version control. Use environment variables.
Create a .env file in the project root (redwood-messagebird-app/.env):
ini
# .env# MessageBird API KeysMESSAGEBIRD_ACCESS_KEY=YOUR_LIVE_OR_TEST_ACCESS_KEY
MESSAGEBIRD_SIGNING_KEY=YOUR_WEBHOOK_SIGNING_KEY
# Your purchased MessageBird number (E.164 format)MESSAGEBIRD_ORIGINATOR_NUMBER=+14155552671# (Optional) Your test mobile number (E.164 format)TEST_RECIPIENT_NUMBER=+12125551234# DatabaseDATABASE_URL="postgresql://user:password@localhost:5432/messagebird_dev"
E.164 Format: Phone numbers must be in international format without spaces or special characters:
US number: +12145551234 (country code +1, area code 214, number 5551234)
UK number: +442012345678 (country code +44, area code 20, number 12345678)
Ensure .gitignore contains .env to prevent committing keys. RedwoodJS includes this by default.
Step 1.7: Accessing Environment Variables
RedwoodJS automatically loads variables from .env into process.env. Access them in your API-side code (services, functions) like process.env.MESSAGEBIRD_ACCESS_KEY.
2. Implementing Core Functionality (Outbound SMS)
Create the service for sending SMS messages.
Step 2.1: Create the Outbound SMS Service
Generate a new service to encapsulate the outbound SMS logic:
Bash
yarn rw g service sms
This creates api/src/services/sms/sms.ts and api/src/services/sms/sms.test.ts.
RedwoodJS services contain business logic and data access. GraphQL resolvers call them, and you can reuse them throughout your application (RedwoodJS documentation).
Configure the sms.ts service:
TypeScript
// api/src/services/sms/sms.tsimport messagebird from'messagebird';
// Initialize the MessageBird client using API key from environment variableconst client = messagebird(process.env.MESSAGEBIRD_ACCESS_KEY);
/**
* Send an SMS message via MessageBird
* @param {string} to - Recipient phone number in E.164 format (e.g., +12345678901)
* @param {string} body - Message content (160 chars for GSM-7, 70 for Unicode)
* @param {string} originator - Sender ID (phone number or alphanumeric, max 11 chars)
* @returns {Promise<object>} - MessageBird API response with message ID and status
*/exportconstsendSMS = ({ to, body, originator }) => {
returnnewPromise((resolve, reject) => {
// Validate required parametersif (!to || !body || !originator) {
returnreject(newError('Missing required parameters: to, body, and originator are required'));
}
// Validate E.164 format (basic check)const e164Regex = /^\+[1-9]\d{1,14}$/;
if (!e164Regex.test(to)) {
returnreject(newError('Recipient number must be in E.164 format (e.g., +12125551234)'));
}
// Check message length and warn about concatenationconst isUnicode = /[^\x00-\x7F]/.test(body);
const maxLength = isUnicode ? 70 : 160;
const partCount = Math.ceil(body.length / maxLength);
if (partCount > 1) {
console.warn(`Message will be split into ${partCount} parts. Cost will be ${partCount}x per message.`);
}
const params = {
originator,
recipients: [to],
body
};
client.messages.create(params, (err, response) => {
if (err) {
console.error('MessageBird API Error:', err);
// Common error typesif (err.errors) {
const errorCodes = err.errors.map(e =>`${e.code}: ${e.description}`).join(', ');
returnreject(newError(`MessageBird API Error: ${errorCodes}`));
}
returnreject(err);
}
console.log('SMS sent successfully:', response.id);
resolve(response);
});
});
};
SMS Character Limits:
GSM-7 encoding: 160 characters per SMS, up to 1377 characters per concatenated message
Unicode encoding: 70 characters per SMS, up to 603 characters per concatenated message
MessageBird retries failed webhooks up to 10 times with exponential backoff. Return HTTP 200 within 30 seconds to acknowledge receipt (webhook documentation).
5. Securing Your Implementation
Protect your webhook endpoint from unauthorized access with signature verification.
Webhook Signature Verification
Verify every webhook request using MessageBird's signing key to prevent unauthorized requests and confirm message authenticity.
How signature verification works:
MessageBird signs each webhook request using your signing key with HMAC-SHA256
The signature arrives in the messagebird-signature header
The timestamp arrives in the messagebird-request-timestamp header (Unix timestamp)
Calculate the expected signature: HMAC-SHA256(signing_key, timestamp + "\n" + body)
Compare signatures using timing-safe comparison to prevent timing attacks
Timestamp Validation (Prevents Replay Attacks):
Validate the timestamp to prevent replay attacks. Accept requests within a 5-minute tolerance window (security best practices):
Use different keys for development, staging, and production
Rotate API keys quarterly or after security incidents
Monitor API key usage for unusual activity
Implement rate limiting on webhook endpoints
Use HTTPS for webhook URLs (required by MessageBird)
Validate and sanitize incoming data before database insertion
6. Database Schema and Message Logging
Store incoming and outgoing SMS for tracking, analytics, and conversation history.
Step 6.1: Create the Prisma Schema
Define a Message model in your Prisma schema to store SMS data:
Prisma
// api/db/schema.prisma
model Message {
id String @id @default(cuid())
messageBirdId String @unique // MessageBird's message ID (for idempotency)
sender String // Phone number in E.164 format
recipient String // Phone number in E.164 format
body String // Message content
direction String // "inbound" or "outbound"
status String? // Delivery status (for outbound messages)
receivedAt DateTime @default(now())
sentAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Indexes for common queries
@@index([sender])
@@index([recipient])
@@index([direction])
@@index([receivedAt])
}
Schema improvements:
@@unique constraint on messageBirdId prevents duplicate webhook processing
Indexes on sender, recipient, direction, and receivedAt optimize common queries
status field stores delivery status for outbound messages (sent, delivered, failed)
Step 6.2: Apply Database Migrations
Run Prisma migrations to create the database table:
Bash
yarn rw prisma migrate dev --name add_message_model
This:
Creates a migration file in api/db/migrations/
Applies the migration to your development database
Regenerates Prisma Client with the new model
Migration conflicts: Use yarn rw prisma migrate reset to reset the database. This deletes all data in development.
Step 6.3: Query Messages
Use Prisma Client to query message history:
TypeScript
// Get all messages for a specific phone numberconst messages = await db.message.findMany({
where: {
OR: [
{ sender: '+12345678901' },
{ recipient: '+12345678901' }
]
},
orderBy: {
receivedAt: 'desc'
}
});
// Get conversation between two numbersconst conversation = await db.message.findMany({
where: {
OR: [
{ sender: '+12345678901', recipient: '+10987654321' },
{ sender: '+10987654321', recipient: '+12345678901' }
]
},
orderBy: {
receivedAt: 'asc'
}
});
// Pagination for large message volumesconstPAGE_SIZE = 50;
const page = 1;
const paginatedMessages = await db.message.findMany({
take: PAGE_SIZE,
skip: (page - 1) * PAGE_SIZE,
orderBy: { receivedAt: 'desc' }
});
7. Testing Your Implementation
Test your SMS integration before deploying to production.
Verify the message appears in MessageBird dashboard (Messages section)
Confirm the message saves to your database: yarn rw prisma studio
Check API logs for any errors: yarn rw dev
Testing with Test Keys:
Use MessageBird test keys (test_xxxxxxx) for development. Test keys simulate API calls without sending SMS or consuming credits. The API returns success responses, but delivers no messages (official documentation).
Testing Inbound Webhooks Locally
Use ngrok to expose your local development server for webhook testing:
Step 1: Install and start ngrok:
Bash
# Install ngrok# macOS: brew install ngrok# Linux: snap install ngrok# Windows: Download from https://ngrok.com/download# Authenticate (required for ngrok v3+)
ngrok config add-authtoken YOUR_NGROK_AUTH_TOKEN
# Start ngrok tunnel on RedwoodJS API port (default 8911)
ngrok http 8911
Step 2: Configure MessageBird webhook:
Copy the HTTPS URL from ngrok (e.g., https://abc123.ngrok.io)
Go to MessageBird Dashboard → Flow Builder
Create a new flow or select existing flow
Select your virtual number as the trigger
Add "Call HTTP endpoint with SMS" step
Set webhook URL to: https://abc123.ngrok.io/.redwood/functions/webhook
Method: POST
Click Publish to activate
Step 3: Send test SMS:
Send an SMS from your phone to your MessageBird number. Monitor:
ngrok console for incoming requests (shows headers and payload)
RedwoodJS logs (yarn rw dev) for webhook processing
Database (Prisma Studio: yarn rw prisma studio) for stored message records
Simulating Webhook Requests
Test webhook handling without sending actual SMS using curl with a valid signature:
CRITICAL: SMS marketing is heavily regulated. Non-compliance can result in fines of $500+ per message.
TCPA Compliance (United States)
The Telephone Consumer Protection Act (TCPA) requires:
Obtain Express Written Consent: Get explicit written permission before sending marketing SMS. Customers must opt-in via web form, keyword, or checkbox (TCPA guide)
Provide Clear Disclosure: After opt-in, send a disclosure message:
Code
[Your Business]: You've subscribed to marketing messages. Msg & data rates may apply.
Msg frequency varies. Reply STOP to cancel, HELP for help. Terms: yoursite.com/terms
Honor Opt-Out Requests: Process STOP, UNSUBSCRIBE, CANCEL, END, and QUIT keywords within 10 business days (as of April 2025) (TCPA 2025 updates)
Respect Quiet Hours: Don't send messages before 8:00 AM or after 9:00 PM recipient's local time
Include Business Name: Every message must identify your business
Maintain Do-Not-Contact List: Keep records of opt-outs for at least 4 years
Example opt-out implementation:
TypeScript
// api/src/functions/webhook/webhook.tsconsthandleOptOut = async (sender: string, messageBody: string) => {
const optOutKeywords = ['STOP', 'STOPALL', 'UNSUBSCRIBE', 'CANCEL', 'END', 'QUIT'];
if (optOutKeywords.includes(messageBody.trim().toUpperCase())) {
// Add to do-not-contact listawait db.optOut.create({
data: { phoneNumber: sender, optedOutAt: newDate() }
});
// Send confirmationawaitsendSMS({
to: sender,
body: 'You have been unsubscribed. You will not receive further messages.',
originator: process.env.MESSAGEBIRD_ORIGINATOR_NUMBER
});
returntrue;
}
returnfalse;
};
International Regulations
GDPR (EU): Obtain consent, provide privacy policy, honor deletion requests
Canada (CASL): Requires express consent for commercial messages
Australia (Spam Act): Consent required, must include business identity and unsubscribe method
Cost Considerations
MessageBird uses pay-as-you-go pricing:
SMS pricing varies by country (typically $0.02-$0.10 per message)
You have successfully integrated two-way SMS messaging into your RedwoodJS application using MessageBird. You can send messages via GraphQL and receive incoming messages through a secure webhook function, with all communication logged to your database.
What you built:
Outbound SMS via GraphQL with validation and error handling
Inbound SMS webhook with signature verification and replay attack prevention
Message logging with database persistence and idempotency
Security best practices including environment variable management and rate limiting
Next steps:
Implement auto-responses based on message content keywords
Add message templates for common notifications
Build conversation threading and history UI
Integrate with user authentication system
Add delivery status tracking via MessageBird status reports
Prioritize security (especially webhook verification), handle errors gracefully, comply with TCPA and international regulations, and monitor SMS costs and delivery rates.
You can send SMS messages in a RedwoodJS application by creating a GraphQL mutation that interacts with the MessageBird API. This involves setting up a service to handle the API call logic and defining the mutation in your GraphQL schema. The provided code example uses the MessageBird Node.js SDK to simplify the integration.
MessageBird is a communication platform that provides the SMS API and virtual phone numbers for sending and receiving text messages within your RedwoodJS application. This allows you to add features like notifications, alerts, and customer support interactions via SMS.
ngrok creates a temporary public URL that tunnels HTTP traffic to your local RedwoodJS development server. This is necessary during development so MessageBird's webhook can reach your locally running messagebirdWebhook function to test incoming SMS messages before deploying to production.
Verifying the MessageBird webhook signature is crucial for security and should always be done, especially in production. This step confirms that incoming webhook requests genuinely originate from MessageBird and haven't been tampered with.
Yes, but be aware of character limits. Standard SMS (GSM-7) allows 160 characters. Using emojis or non-standard characters switches to UCS-2 encoding, reducing the limit to 70 characters. MessageBird handles concatenation for longer messages, but they're billed as multiple parts.
Incoming SMS messages are handled by creating a RedwoodJS function, which acts as a webhook endpoint. This function receives POST requests from MessageBird, verifies the signature, parses the message data, and then processes it (e.g., logging, triggering actions).
A RedwoodJS function serves as the webhook endpoint for receiving incoming SMS messages from MessageBird. It handles the incoming HTTP requests, signature verification, and processing of the received SMS data.
MessageBird requires a 200 OK response from your webhook to acknowledge successful receipt of the message. Failure to respond quickly or returning an error can cause MessageBird to retry sending the webhook, potentially leading to duplicate processing.
If processing inbound SMS messages involves time-consuming operations (database writes, external API calls), perform them asynchronously after returning a 200 OK to MessageBird. This keeps your webhook responsive and prevents MessageBird from retrying.
The MessageBird Node.js SDK provides a webhooks.verify function for signature verification. Your webhook function must check if the signature is missing from the request, compare it against the signing key (stored securely in environment variables) and return a 401 error if there is an issue with the signature.
The MessageBird originator number is your provisioned virtual phone number, used as the sender ID for outbound messages and the number users reply to for inbound messages. It must be in E.164 format (e.g., +14155552671).
You can store SMS message logs using Prisma, RedwoodJS's ORM. Define a model in your schema.prisma file to represent the message data (direction, originator, recipient, body, status, etc.) and then use db.messageLog.create in your service and function to save the message details.
E.164 (+14155552671) ensures consistent, internationally recognized phone number formatting, crucial for reliable SMS delivery and routing. MessageBird strongly recommends using E.164.
Carriers often mandate handling keywords like STOP, HELP, INFO. Check MessageBird's documentation, sometimes they handle opt-out logic with a STOP keyword. Implement logic in your webhook to process HELP messages (e.g., by sending an automated reply) and to update your database with user preferences if a user texts STOP, preventing future communication.
Build production-ready two-way SMS messaging in your RedwoodJS application using MessageBird's SMS API and webhook system. This tutorial shows you how to send outbound SMS messages via GraphQL and receive inbound SMS through secure webhooks—perfect for customer support chat, appointment confirmations, OTP verification, automated responses, and real-time notifications.
You'll implement complete bidirectional SMS communication with webhook signature verification, message logging, replay attack prevention, and production deployment strategies for RedwoodJS applications.
What You'll Build: Two-Way SMS Communication System
Core Features:
Real-World Use Cases:
This two-way SMS system enables interactive communication for appointment reminders with confirmations, customer support conversations, two-factor authentication codes, order status updates with customer replies, survey responses, and automated chatbot interactions.
Technology Stack:
System Architecture:
Prerequisites:
node --versionnpm install -g yarn. Verify:yarn --versionyarn global add @redwoodjs/cliExpected Outcome:
A fully functional RedwoodJS application that sends SMS via GraphQL and receives/processes/logs incoming SMS messages via secure webhooks for true two-way communication.
1. Setting up the Project
Initialize a new RedwoodJS project, set up the database, and install dependencies.
Step 1.1: Install Prerequisites
Node.js and Yarn:
# Verify Node.js version (must be v20.x or later) node --version # If needed, download from https://nodejs.org/ # Install Yarn globally npm install -g yarn # Verify Yarn installation yarn --versionDatabase Setup (PostgreSQL recommended):
For local development, install PostgreSQL:
brew install postgresql@14 && brew services start postgresql@14sudo apt-get install postgresql postgresql-contribOr use a hosted database service like Railway, Render, or Supabase.
Step 1.2: Create RedwoodJS Project
Open your terminal and run:
yarn create redwood-app ./redwood-messagebird-app --typescript cd redwood-messagebird-appUsing TypeScript provides better type safety and developer experience, especially for larger applications.
Step 1.3: Configure Database Connection
Update your
.envfile with your database connection string:# .env DATABASE_URL="postgresql://user:password@localhost:5432/messagebird_dev" # Or for SQLite (development only): # DATABASE_URL="file:./dev.db"Run initial database migration:
Step 1.4: Install MessageBird SDK
Navigate to the
apidirectory and add the MessageBird Node.js SDK:cd api yarn add messagebird cd ..SDK version: The messagebird package (v3.8.0+) is compatible with Node.js >=0.10, including Node.js v20.x.
Step 1.5: Set Up MessageBird Account
Obtain API Keys:
live_) for production usetest_) for developmentTest keys simulate API requests without sending actual SMS or consuming credits. Live keys send real messages and deduct from your balance (official documentation).
Obtain Signing Key:
Purchase Virtual Number:
+14155552671)Step 1.6: Configure Environment Variables
Never commit API keys to version control. Use environment variables.
Create a
.envfile in the project root (redwood-messagebird-app/.env):# .env # MessageBird API Keys MESSAGEBIRD_ACCESS_KEY=YOUR_LIVE_OR_TEST_ACCESS_KEY MESSAGEBIRD_SIGNING_KEY=YOUR_WEBHOOK_SIGNING_KEY # Your purchased MessageBird number (E.164 format) MESSAGEBIRD_ORIGINATOR_NUMBER=+14155552671 # (Optional) Your test mobile number (E.164 format) TEST_RECIPIENT_NUMBER=+12125551234 # Database DATABASE_URL="postgresql://user:password@localhost:5432/messagebird_dev"E.164 Format: Phone numbers must be in international format without spaces or special characters:
+12145551234(country code +1, area code 214, number 5551234)+442012345678(country code +44, area code 20, number 12345678)+[country code][area code][local number](max 15 digits) (official documentation)Add
.envto.gitignore:Ensure
.gitignorecontains.envto prevent committing keys. RedwoodJS includes this by default.Step 1.7: Accessing Environment Variables
RedwoodJS automatically loads variables from
.envintoprocess.env. Access them in your API-side code (services, functions) likeprocess.env.MESSAGEBIRD_ACCESS_KEY.2. Implementing Core Functionality (Outbound SMS)
Create the service for sending SMS messages.
Step 2.1: Create the Outbound SMS Service
Generate a new service to encapsulate the outbound SMS logic:
This creates
api/src/services/sms/sms.tsandapi/src/services/sms/sms.test.ts.RedwoodJS services contain business logic and data access. GraphQL resolvers call them, and you can reuse them throughout your application (RedwoodJS documentation).
Configure the
sms.tsservice:// api/src/services/sms/sms.ts import messagebird from 'messagebird'; // Initialize the MessageBird client using API key from environment variable const client = messagebird(process.env.MESSAGEBIRD_ACCESS_KEY); /** * Send an SMS message via MessageBird * @param {string} to - Recipient phone number in E.164 format (e.g., +12345678901) * @param {string} body - Message content (160 chars for GSM-7, 70 for Unicode) * @param {string} originator - Sender ID (phone number or alphanumeric, max 11 chars) * @returns {Promise<object>} - MessageBird API response with message ID and status */ export const sendSMS = ({ to, body, originator }) => { return new Promise((resolve, reject) => { // Validate required parameters if (!to || !body || !originator) { return reject(new Error('Missing required parameters: to, body, and originator are required')); } // Validate E.164 format (basic check) const e164Regex = /^\+[1-9]\d{1,14}$/; if (!e164Regex.test(to)) { return reject(new Error('Recipient number must be in E.164 format (e.g., +12125551234)')); } // Check message length and warn about concatenation const isUnicode = /[^\x00-\x7F]/.test(body); const maxLength = isUnicode ? 70 : 160; const partCount = Math.ceil(body.length / maxLength); if (partCount > 1) { console.warn(`Message will be split into ${partCount} parts. Cost will be ${partCount}x per message.`); } const params = { originator, recipients: [to], body }; client.messages.create(params, (err, response) => { if (err) { console.error('MessageBird API Error:', err); // Common error types if (err.errors) { const errorCodes = err.errors.map(e => `${e.code}: ${e.description}`).join(', '); return reject(new Error(`MessageBird API Error: ${errorCodes}`)); } return reject(err); } console.log('SMS sent successfully:', response.id); resolve(response); }); }); };SMS Character Limits:
Common API Errors:
3. Building the API Layer (GraphQL Mutation)
Create a GraphQL mutation to send SMS messages from your web application.
Step 3.1: Define GraphQL Schema
Create or update
api/src/graphql/sms.sdl.ts:// api/src/graphql/sms.sdl.ts export const schema = gql` type SMSResponse { success: Boolean! messageId: String error: String } type Mutation { sendSMS( to: String! body: String! originator: String! ): SMSResponse! @requireAuth } `;@requireAuthensures only authenticated users can send SMS. Remove it if you don't need authentication.Step 3.2: Implement GraphQL Resolver
Update
api/src/services/sms/sms.tsto add the resolver:// Add to api/src/services/sms/sms.ts interface SendSMSArgs { to: string; body: string; originator: string; } export const sendSMS = async ({ to, body, originator }: SendSMSArgs) => { try { const response = await new Promise((resolve, reject) => { // Validate parameters if (!to || !body || !originator) { return reject(new Error('Missing required parameters')); } const e164Regex = /^\+[1-9]\d{1,14}$/; if (!e164Regex.test(to)) { return reject(new Error('Recipient must be in E.164 format')); } const params = { originator, recipients: [to], body }; client.messages.create(params, (err, response) => { if (err) { console.error('MessageBird API Error:', err); return reject(err); } resolve(response); }); }); return { success: true, messageId: response.id, error: null }; } catch (error) { console.error('SMS sending failed:', error); return { success: false, messageId: null, error: error.message }; } };4. Building the Webhook Handler (Inbound SMS)
Receive and process incoming SMS messages through a webhook endpoint—the key component for two-way SMS functionality.
Step 4.1: Create the Webhook Handler Function
Generate a serverless function to handle incoming webhooks:
yarn rw g function webhookThis creates
api/src/functions/webhook/webhook.ts.RedwoodJS serverless functions are independent API endpoints ideal for webhooks, background jobs, and external integrations (RedwoodJS documentation).
Implement the webhook handler:
// api/src/functions/webhook/webhook.ts import crypto from 'crypto'; import { db } from 'src/lib/db'; import type { APIGatewayEvent, Context } from 'aws-lambda'; /** * Verify MessageBird webhook signature with timestamp validation * Prevents unauthorized requests and replay attacks */ const verifySignature = (event: APIGatewayEvent, body: string): boolean => { const signature = event.headers['messagebird-signature']; const timestamp = event.headers['messagebird-request-timestamp']; if (!signature || !timestamp) { console.error('Missing signature or timestamp headers'); return false; } // Validate timestamp to prevent replay attacks // Accept requests within 5 minutes (300 seconds) of creation const requestTime = parseInt(timestamp, 10) * 1000; // Convert to milliseconds const currentTime = Date.now(); const timeDiff = Math.abs(currentTime - requestTime); const TOLERANCE_MS = 5 * 60 * 1000; // 5 minutes if (timeDiff > TOLERANCE_MS) { console.error(`Timestamp outside tolerance: ${timeDiff}ms difference`); return false; } // Construct the signing payload const payload = `${timestamp}\n${body}`; // Calculate expected signature using your signing key const expectedSignature = crypto .createHmac('sha256', process.env.MESSAGEBIRD_SIGNING_KEY) .update(payload) .digest('hex'); // Compare signatures using timing-safe comparison try { return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expectedSignature) ); } catch (err) { console.error('Signature comparison failed:', err); return false; } }; /** * Handler for incoming webhook requests from MessageBird */ export const handler = async (event: APIGatewayEvent, context: Context) => { // Only accept POST requests if (event.httpMethod !== 'POST') { return { statusCode: 405, body: JSON.stringify({ error: 'Method not allowed' }) }; } try { const body = event.body; // Verify the webhook signature if (!verifySignature(event, body)) { console.error('Invalid webhook signature'); return { statusCode: 401, body: JSON.stringify({ error: 'Unauthorized' }) }; } const payload = JSON.parse(body); // Extract message data from webhook payload // Webhook payload structure: // { id, originator, body, recipient, createdDatetime, type, direction } const { id, originator, body: messageBody, recipient, createdDatetime } = payload; // Check for duplicate webhook delivery (idempotency) const existingMessage = await db.message.findUnique({ where: { messageBirdId: id } }); if (existingMessage) { console.log('Duplicate webhook ignored:', id); return { statusCode: 200, body: JSON.stringify({ success: true, duplicate: true }) }; } // Store the incoming message in database await db.message.create({ data: { messageBirdId: id, sender: originator, recipient: recipient, body: messageBody, direction: 'inbound', receivedAt: new Date(createdDatetime) } }); console.log('Inbound message processed:', id); // Return 200 within 30 seconds to acknowledge receipt // MessageBird will retry up to 10 times if not acknowledged return { statusCode: 200, body: JSON.stringify({ success: true }) }; } catch (error) { console.error('Webhook processing error:', error); return { statusCode: 500, body: JSON.stringify({ error: 'Internal server error' }) }; } };MessageBird Webhook Payload Structure:
Inbound SMS webhooks contain the following fields (official documentation):
id: Unique message identifieroriginator: Sender's phone number (E.164 format)recipient: Your MessageBird virtual numberbody: Message contentcreatedDatetime: Timestamp in RFC3339 formattype: Message type (usually "sms")direction: "mo" (mobile originated = inbound)Webhook Retry Behavior:
MessageBird retries failed webhooks up to 10 times with exponential backoff. Return HTTP 200 within 30 seconds to acknowledge receipt (webhook documentation).
5. Securing Your Implementation
Protect your webhook endpoint from unauthorized access with signature verification.
Webhook Signature Verification
Verify every webhook request using MessageBird's signing key to prevent unauthorized requests and confirm message authenticity.
How signature verification works:
messagebird-signatureheadermessagebird-request-timestampheader (Unix timestamp)HMAC-SHA256(signing_key, timestamp + "\n" + body)Timestamp Validation (Prevents Replay Attacks):
Validate the timestamp to prevent replay attacks. Accept requests within a 5-minute tolerance window (security best practices):
const requestTime = parseInt(timestamp, 10) * 1000; const currentTime = Date.now(); const timeDiff = Math.abs(currentTime - requestTime); const TOLERANCE_MS = 5 * 60 * 1000; // 5 minutes if (timeDiff > TOLERANCE_MS) { return false; // Reject expired requests }Best practices:
crypto.timingSafeEqual)Environment Variable Security
Protect sensitive credentials by storing them as environment variables:
# .env MESSAGEBIRD_ACCESS_KEY=your_api_key_here MESSAGEBIRD_SIGNING_KEY=your_signing_key_here MESSAGEBIRD_ORIGINATOR_NUMBER=+12345678901Security checklist:
.envto.gitignore6. Database Schema and Message Logging
Store incoming and outgoing SMS for tracking, analytics, and conversation history.
Step 6.1: Create the Prisma Schema
Define a
Messagemodel in your Prisma schema to store SMS data:Schema improvements:
@@uniqueconstraint onmessageBirdIdprevents duplicate webhook processingsender,recipient,direction, andreceivedAtoptimize common queriesstatusfield stores delivery status for outbound messages (sent, delivered, failed)Step 6.2: Apply Database Migrations
Run Prisma migrations to create the database table:
This:
api/db/migrations/Migration conflicts: Use
yarn rw prisma migrate resetto reset the database. This deletes all data in development.Step 6.3: Query Messages
Use Prisma Client to query message history:
// Get all messages for a specific phone number const messages = await db.message.findMany({ where: { OR: [ { sender: '+12345678901' }, { recipient: '+12345678901' } ] }, orderBy: { receivedAt: 'desc' } }); // Get conversation between two numbers const conversation = await db.message.findMany({ where: { OR: [ { sender: '+12345678901', recipient: '+10987654321' }, { sender: '+10987654321', recipient: '+12345678901' } ] }, orderBy: { receivedAt: 'asc' } }); // Pagination for large message volumes const PAGE_SIZE = 50; const page = 1; const paginatedMessages = await db.message.findMany({ take: PAGE_SIZE, skip: (page - 1) * PAGE_SIZE, orderBy: { receivedAt: 'desc' } });7. Testing Your Implementation
Test your SMS integration before deploying to production.
Testing Outbound SMS
Test SMS sending through your GraphQL API:
mutation SendTestSMS { sendSMS( to: "+12125551234" body: "Test message from RedwoodJS" originator: "+14155552671" ) { success messageId error } }Verification steps:
yarn rw prisma studioyarn rw devTesting with Test Keys:
Use MessageBird test keys (
test_xxxxxxx) for development. Test keys simulate API calls without sending SMS or consuming credits. The API returns success responses, but delivers no messages (official documentation).Testing Inbound Webhooks Locally
Use ngrok to expose your local development server for webhook testing:
Step 1: Install and start ngrok:
# Install ngrok # macOS: brew install ngrok # Linux: snap install ngrok # Windows: Download from https://ngrok.com/download # Authenticate (required for ngrok v3+) ngrok config add-authtoken YOUR_NGROK_AUTH_TOKEN # Start ngrok tunnel on RedwoodJS API port (default 8911) ngrok http 8911Step 2: Configure MessageBird webhook:
https://abc123.ngrok.io)https://abc123.ngrok.io/.redwood/functions/webhookStep 3: Send test SMS:
Send an SMS from your phone to your MessageBird number. Monitor:
yarn rw dev) for webhook processingyarn rw prisma studio) for stored message recordsSimulating Webhook Requests
Test webhook handling without sending actual SMS using curl with a valid signature:
Calculate valid test signature:
# 1. Get current Unix timestamp TIMESTAMP=$(date +%s) # 2. Create payload file cat > payload.json << EOF { "id": "test-msg-12345", "originator": "+12125551234", "body": "Test inbound message", "recipient": "+14155552671", "createdDatetime": "2024-01-15T10:30:00Z" } EOF # 3. Calculate signature PAYLOAD=$(cat payload.json) SIGNING_KEY="your_signing_key_here" SIGNATURE=$(echo -n "${TIMESTAMP}\n${PAYLOAD}" | openssl dgst -sha256 -hmac "$SIGNING_KEY" | awk '{print $2}') # 4. Send request with valid signature curl -X POST http://localhost:8911/.redwood/functions/webhook \ -H "Content-Type: application/json" \ -H "messagebird-signature: $SIGNATURE" \ -H "messagebird-request-timestamp: $TIMESTAMP" \ -d @payload.jsonCommon Testing Issues
Problem: Webhook receives requests but returns 401
.envProblem: Messages don't appear in database
.envyarn rw prisma migrate devmessageBirdIdconstraint violationsProblem: Outbound SMS fails with 401
Problem: ngrok tunnel disconnects
ngrok config add-authtoken YOUR_TOKENProblem: SMS character limit exceeded
8. Deployment Considerations
Deploy your RedwoodJS SMS application to production.
Environment Variables
Configure production environment variables in your hosting platform:
Platform-specific configuration:
Webhook URL Configuration
Update your MessageBird webhook URL to point to your production domain:
https://your-app.vercel.app)https://your-app.vercel.app/.redwood/functions/webhookZero-downtime updates: When deploying updates:
HTTPS Requirements
MessageBird requires HTTPS for all webhook endpoints.
Automatic HTTPS support:
Verify HTTPS is working:
curl -I https://your-domain.com/.redwood/functions/webhookDatabase Migrations
Apply database migrations before deploying new code:
# Production migration command (does not prompt) yarn rw prisma migrate deployCI/CD pipeline configuration (GitHub Actions example):
# .github/workflows/deploy.yml name: Deploy on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: '20' - name: Install dependencies run: yarn install - name: Run database migrations run: yarn rw prisma migrate deploy env: DATABASE_URL: ${{ secrets.DATABASE_URL }} - name: Build application run: yarn rw build - name: Deploy to Vercel run: vercel --prod --token=${{ secrets.VERCEL_TOKEN }}Monitoring and Logging
Implement monitoring to track SMS delivery and webhook processing.
Log critical events:
Key metrics to track:
Recommended monitoring tools:
RedwoodJS logging configuration:
// api/src/lib/logger.ts import { createLogger } from '@redwoodjs/api/logger' export const logger = createLogger({ options: { level: process.env.LOG_LEVEL || 'info', redact: ['MESSAGEBIRD_ACCESS_KEY', 'MESSAGEBIRD_SIGNING_KEY'], prettyPrint: process.env.NODE_ENV === 'development' } })Use in your code:
import { logger } from 'src/lib/logger' logger.info({ messageId: response.id }, 'SMS sent successfully') logger.error({ error: err }, 'Webhook signature verification failed')Rate Limiting
Implement rate limiting to prevent API abuse and protect your budget:
# Install rate limiting library yarn workspace api add limiter// api/src/functions/webhook/webhook.ts import { RateLimiterMemory } from 'rate-limiter-flexible'; // Allow 100 webhook requests per minute per IP const rateLimiter = new RateLimiterMemory({ points: 100, duration: 60, }); export const handler = async (event, context) => { // Extract IP address const ip = event.headers['x-forwarded-for']?.split(',')[0] || event.requestContext?.identity?.sourceIp || 'unknown'; try { // Consume 1 point per request await rateLimiter.consume(ip); } catch (rejRes) { console.warn(`Rate limit exceeded for IP: ${ip}`); return { statusCode: 429, body: JSON.stringify({ error: 'Too many requests' }) }; } // ... existing webhook code ... };Rate limit recommendations:
9. Compliance and Legal Considerations
CRITICAL: SMS marketing is heavily regulated. Non-compliance can result in fines of $500+ per message.
TCPA Compliance (United States)
The Telephone Consumer Protection Act (TCPA) requires:
Obtain Express Written Consent: Get explicit written permission before sending marketing SMS. Customers must opt-in via web form, keyword, or checkbox (TCPA guide)
Provide Clear Disclosure: After opt-in, send a disclosure message:
Honor Opt-Out Requests: Process STOP, UNSUBSCRIBE, CANCEL, END, and QUIT keywords within 10 business days (as of April 2025) (TCPA 2025 updates)
Respect Quiet Hours: Don't send messages before 8:00 AM or after 9:00 PM recipient's local time
Include Business Name: Every message must identify your business
Maintain Do-Not-Contact List: Keep records of opt-outs for at least 4 years
Example opt-out implementation:
// api/src/functions/webhook/webhook.ts const handleOptOut = async (sender: string, messageBody: string) => { const optOutKeywords = ['STOP', 'STOPALL', 'UNSUBSCRIBE', 'CANCEL', 'END', 'QUIT']; if (optOutKeywords.includes(messageBody.trim().toUpperCase())) { // Add to do-not-contact list await db.optOut.create({ data: { phoneNumber: sender, optedOutAt: new Date() } }); // Send confirmation await sendSMS({ to: sender, body: 'You have been unsubscribed. You will not receive further messages.', originator: process.env.MESSAGEBIRD_ORIGINATOR_NUMBER }); return true; } return false; };International Regulations
Cost Considerations
MessageBird uses pay-as-you-go pricing:
10. Conclusion
You have successfully integrated two-way SMS messaging into your RedwoodJS application using MessageBird. You can send messages via GraphQL and receive incoming messages through a secure webhook function, with all communication logged to your database.
What you built:
Next steps:
Related topics:
Prioritize security (especially webhook verification), handle errors gracefully, comply with TCPA and international regulations, and monitor SMS costs and delivery rates.
Resources: