This guide provides a step-by-step walkthrough for building a production-ready Node.js application using the Fastify framework to handle two-way SMS communication via the MessageBird API. We will cover project setup, webhook implementation, sending replies, security considerations, error handling, deployment, and verification.
By the end of this tutorial, you will have a functional Fastify application capable of receiving incoming SMS messages sent to a MessageBird number and automatically replying to them. This foundational setup enables use cases like customer support bots, automated notifications with response capabilities, and interactive SMS services.
Technologies Used:
- Node.js: The JavaScript runtime environment.
- Fastify: A high-performance, low-overhead web framework for Node.js. Chosen for its speed, extensibility, and developer experience.
- MessageBird: A communication platform providing APIs for SMS, Voice, and more. Used here for sending and receiving SMS messages.
@messagebird/api
: The official MessageBird Node.js SDK.dotenv
: For managing environment variables.qs
: For parsing URL-encoded form data reliably.async-retry
: For implementing retry logic on outgoing API calls.@fastify/rate-limit
: For protecting endpoints against abuse.- Sentry (
@sentry/node
, etc.): For error tracking and monitoring (Optional). - Prisma: For database interactions (Optional).
ngrok
/ Tunneling Service: To expose the local development server to the internet for testing webhooks.
System Architecture:
+------+ SMS +--------------+ Webhook +----------------+ API Call +--------------+ SMS +------+
| User | <---------> | MessageBird | -------------> | Fastify App | -------------> | MessageBird | <---------> | User |
| Phone| | Platform | (POST Request) | (Node.js) | (Send Reply) | Platform | | Phone|
+------+ +--------------+ +----------------+ +--------------+ +------+
^ | | | ^
|---------------------| |--------------------------------|---------------------|
(Receives Inbound SMS) (Processes & Sends Outbound SMS Reply)
Prerequisites:
- Node.js and npm (or yarn) installed.
- A MessageBird account with an active phone number capable of sending/receiving SMS.
- Basic familiarity with Node.js, REST APIs, and asynchronous programming.
ngrok
or a similar tunneling service installed for local development testing.
1. Setting up the Project
Let's initialize our Node.js project using Fastify and install the necessary dependencies.
1.1 Environment Setup:
Ensure you have Node.js (v16 or later recommended) and npm installed. You can manage Node.js versions using nvm
(Node Version Manager) if needed.
- macOS/Linux: Instructions at nvm-sh/nvm
- Windows: Instructions at coreybutler/nvm-windows
1.2 Project Initialization:
Open your terminal and create a new project directory:
mkdir fastify-messagebird-sms
cd fastify-messagebird-sms
npm init -y
1.3 Install Dependencies:
Install Fastify, the MessageBird SDK, dotenv
, pino-pretty
for development logs, and other runtime dependencies we'll use later (qs
, async-retry
, @fastify/rate-limit
, Sentry packages). We'll also add nodemon
as a development dependency.
# Runtime Dependencies
npm install fastify @messagebird/api dotenv pino-pretty qs async-retry @fastify/rate-limit @sentry/node @sentry/integrations @sentry/profiling-node
# Development Dependencies
npm install --save-dev nodemon
(Note: Prisma dependencies will be installed later in Section 6 if you choose to implement the database layer).
1.4 Project Structure:
Create a basic project structure for better organization:
fastify-messagebird-sms/
├── node_modules/
├── src/
│ ├── app.js # Fastify application setup
│ └── routes/
│ ├── webhook.js # Webhook handling logic
│ └── api.js # Optional API logic
├── .env # Environment variables (DO NOT COMMIT)
├── .env.example # Example environment variables (safe to commit)
├── .gitignore # Git ignore file
└── package.json
Create the src
and src/routes
directories.
1.5 Configure Environment Variables:
Create a .env.example
file with the necessary variable names:
# .env.example
# MessageBird Credentials
MESSAGEBIRD_API_KEY=YOUR_MESSAGEBIRD_LIVE_API_KEY
MESSAGEBIRD_WEBHOOK_SIGNING_KEY=YOUR_MESSAGEBIRD_WEBHOOK_SIGNING_KEY
MESSAGEBIRD_ORIGINATOR=YOUR_MESSAGEBIRD_NUMBER_OR_ALPHANUMERIC_SENDER_ID
# Application Settings
PORT=3000
HOST=0.0.0.0
LOG_LEVEL=info
NODE_ENV=development
# Webhook Security
# Use a long, random, hard-to-guess string for the path
WEBHOOK_SECRET_PATH=your-very-secret-random-webhook-path
# Database (Optional - if using Prisma)
# DATABASE_URL="postgresql://user:password@host:port/database?schema=public"
# Sentry (Optional)
# SENTRY_DSN=YOUR_SENTRY_DSN
Now, create a .env
file by copying .env.example
. Never commit your .env
file to version control.
cp .env.example .env
How to obtain MessageBird credentials:
MESSAGEBIRD_API_KEY
:- Log in to your MessageBird Dashboard.
- Navigate to Developers > API access (or API Keys).
- Use your live API key. Copy and paste it into your
.env
file.
MESSAGEBIRD_WEBHOOK_SIGNING_KEY
:- In the MessageBird Dashboard, go to Developers > API settings.
- Find the Webhook Signing section.
- If no key exists, click Generate new key.
- Copy the generated Signing Key and paste it into your
.env
file.
MESSAGEBIRD_ORIGINATOR
:- This is the phone number (in E.164 format, e.g.,
+12025550181
) or approved Alphanumeric Sender ID you purchased/configured in MessageBird that will send the replies. Find this under Numbers in your dashboard. Note: Alphanumeric Sender IDs have country restrictions and cannot receive replies. For two-way messaging, use a virtual number.
- This is the phone number (in E.164 format, e.g.,
Fill in the values in your .env
file. Choose a secure, random string for WEBHOOK_SECRET_PATH
. Add your DATABASE_URL
and SENTRY_DSN
if you plan to use those features.
1.6 Create .gitignore
:
Create a .gitignore
file to prevent sensitive files and unnecessary directories from being committed:
# .gitignore
node_modules
.env
npm-debug.log*
yarn-debug.log*
yarn-error.log*
*.env
*.log
# Optional directory for build output
dist/
# Prisma specific
prisma/migrations/*
!prisma/migrations/migration_lock.toml
1.7 Basic Fastify Server Setup:
Create the main application file src/app.js
. This includes the critical custom content type parser needed for webhook signature validation.
// src/app.js
'use strict'
const path = require('path')
const Fastify = require('fastify')
const dotenv = require('dotenv')
const qs = require('qs') // For parsing x-www-form-urlencoded
// Load environment variables from .env file
dotenv.config()
// --- Fastify Logger Configuration ---
const pinoOptions = {
level: process.env.LOG_LEVEL || 'info',
}
if (process.env.NODE_ENV !== 'production') {
pinoOptions.transport = {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname',
},
}
}
// --- Fastify Instance ---
const app = Fastify({
logger: pinoOptions,
// Note: We disable the default body parser for the webhook route via the custom parser below.
})
// --- Add Raw Body Content Parser for MessageBird Webhook ---
// This parser handles 'application/x-www-form-urlencoded' specifically for the webhook.
// It stores the raw body string (needed for signature verification) and then parses the body.
// It must be registered *before* the routes that need it.
app.addContentTypeParser('application/x-www-form-urlencoded', { parseAs: 'string' }, function (req, body, done) {
try {
// Store the raw body string on the request object
req.rawBodyString = body;
// Parse the body using 'qs' for reliable form data handling
const parsed = qs.parse(body);
done(null, parsed); // Pass the parsed body to the route handler
} catch (err) {
req.log.error({ err, body }, 'Failed to parse form-urlencoded body');
err.statusCode = 400;
done(err, undefined);
}
});
// --- Register Plugins ---
// Plugin for health check endpoint
app.register(require('fastify-healthcheck'))
// Plugin for rate limiting (See Section 7.3)
app.register(require('@fastify/rate-limit'), {
max: 100, // Example: Max 100 requests per minute per IP
timeWindow: '1 minute'
});
// --- Register Routes ---
// Ensure routes are registered *after* the content type parser
app.register(require('./routes/webhook'), { prefix: '/' }) // Register webhook routes at root
app.register(require('./routes/api'), { prefix: '/api' }) // Register API routes under /api
// --- Graceful Shutdown ---
const signals = ['SIGINT', 'SIGTERM']
signals.forEach((signal) => {
process.on(signal, async () => {
app.log.info(`Received ${signal}, closing server...`)
await app.close()
app.log.info('Server closed.')
process.exit(0)
})
})
// --- Global Error Handler ---
app.setErrorHandler(function (error, request, reply) {
// Log the error with context
request.log.error({ err: error, req: request.raw }, 'Unhandled error caught by global error handler');
// Send error response based on the error type
if (error.validation) {
// Fastify's built-in validation errors
reply.status(400).send({
statusCode: 400,
error: 'Bad Request',
message: error.message,
details: error.validation // Include validation details for client debugging
});
} else if (error.statusCode && error.statusCode < 500) {
// Handle errors thrown with a specific 4xx status code
reply.status(error.statusCode).send({
statusCode: error.statusCode_
error: error.name || 'Error'_
message: error.message
});
} else {
// Generic server error for 5xx or unknown errors
// Avoid sending detailed internal error messages to the client in production
const message = process.env.NODE_ENV !== 'production' ? error.message : 'Internal Server Error';
reply.status(error.statusCode || 500).send({
statusCode: error.statusCode || 500_
error: error.name || 'Internal Server Error'_
message: message
});
// Consider integrating with Sentry or similar here for unhandled errors (See Section 10)
}
});
// --- Start Server ---
const start = async () => {
try {
const port = parseInt(process.env.PORT, 10) || 3000
const host = process.env.HOST || '0.0.0.0'
await app.listen({ port, host })
// Log available routes (optional, useful for debugging)
// app.log.info(`Routes:\n${app.printRoutes()}`)
} catch (err) {
app.log.error(err)
process.exit(1)
}
}
// --- Initialize Sentry (Optional - See Section 10) ---
// Sentry initialization code would go here, checking NODE_ENV and SENTRY_DSN
start()
module.exports = app // Export for testing purposes if needed
1.8 Add package.json
Scripts:
Update the scripts
section in your package.json
. The default test
script is a placeholder; implementing actual tests is recommended but outside the scope of this initial setup guide.
// package.json (scripts section)
{
"scripts": {
"start": "node src/app.js",
"dev": "nodemon src/app.js",
"test": "echo \"Error: no test specified\" && exit 1"
}
}
Now you can run npm run dev
to start the server in development mode with automatic restarts, or npm start
for production.
2. Implementing Core Functionality: Handling Incoming SMS
The core of our two-way messaging system is the webhook handler. MessageBird will send an HTTP POST request to this endpoint whenever an SMS is sent to your configured MessageBird number.
2.1 Create the Webhook Route:
Create the file src/routes/webhook.js
. This includes the crucial signature verification logic using the raw body provided by the custom parser in app.js
.
// src/routes/webhook.js
'use strict'
const crypto = require('crypto')
const { initClient } = require('@messagebird/api')
const retry = require('async-retry');
// Initialize MessageBird client globally (ensure required env vars are set)
// Ensure MESSAGEBIRD_API_KEY is available in your environment
const messagebird = initClient(process.env.MESSAGEBIRD_API_KEY)
// --- Webhook Signature Verification Hook (Fastify preHandler) ---
// This is crucial for security to ensure requests genuinely come from MessageBird
async function verifyMessageBirdSignature (request, reply) {
const signature = request.headers['messagebird-signature']
const timestamp = request.headers['messagebird-request-timestamp']
const webhookSigningKey = process.env.MESSAGEBIRD_WEBHOOK_SIGNING_KEY
// Access the raw body string added by the custom parser in app.js
const rawBody = request.rawBodyString
if (!signature || !timestamp || !webhookSigningKey || typeof rawBody !== 'string') {
request.log.warn({
signature: !!signature,
timestamp: !!timestamp,
key: !!webhookSigningKey,
rawBodyType: typeof rawBody
}, 'Missing signature headers, signing key, or raw body. Rejecting webhook.');
// Send 400 Bad Request if essential components for verification are missing
reply.code(400).send({ error: 'Missing MessageBird signature headers, key, or raw body' })
return; // Stop processing further handlers for this request
}
try {
// Construct the signed payload according to MessageBird documentation: timestamp + '.' + rawBody
const signedPayload = timestamp + '.' + rawBody;
const expectedSignature = crypto
.createHmac('sha256', webhookSigningKey)
.update(signedPayload)
.digest('hex');
// Use crypto.timingSafeEqual to prevent timing attacks
const sigBuffer = Buffer.from(signature, 'hex'); // Assume hex encoding for signature
const expectedSigBuffer = Buffer.from(expectedSignature, 'hex');
if (sigBuffer.length !== expectedSigBuffer.length || !crypto.timingSafeEqual(sigBuffer, expectedSigBuffer)) {
request.log.error('Invalid webhook signature received.');
// Send 401 Unauthorized if the signature is invalid
reply.code(401).send({ error: 'Invalid MessageBird signature' });
return; // Stop processing further handlers
}
request.log.info('Webhook signature verified successfully.');
// If signature is valid, the request proceeds to the main handler (handleIncomingSms)
} catch (error) {
request.log.error({ err: error }, 'Error during signature verification process.');
// Send 500 Internal Server Error if verification logic itself fails
reply.code(500).send({ error: 'Webhook signature verification failed internally' });
// Note: We are calling reply.send() here, so the main handler won't execute.
}
}
// --- Webhook Handler Logic ---
async function handleIncomingSms (request, reply) {
// This handler only runs if verifyMessageBirdSignature hook passes without sending a reply
request.log.info({ body: request.body }, 'Received incoming SMS webhook (Signature Verified)')
// Extract necessary data from the webhook payload (already parsed by custom parser)
// Verify these field names against actual MessageBird webhook payloads
const sender = request.body.originator // The end-user's phone number
const messageContent = request.body.payload // The text message content
const messagebirdNumber = request.body.recipient // Your MessageBird number that received the SMS
if (!sender || !messageContent) {
request.log.error('Webhook payload missing required fields: originator or payload.')
// Even if data is bad, acknowledge receipt to MessageBird to prevent retries
return reply.code(204).send()
}
// --- Business Logic: Process the incoming message ---
// Example: Simple echo reply
const replyText = `You sent: ""${messageContent}"". Thanks for messaging!`
// --- Send Reply using MessageBird SDK ---
const params = {
originator: process.env.MESSAGEBIRD_ORIGINATOR, // Your MessageBird number or Sender ID
recipients: [sender], // Send back to the original sender
body: replyText,
}
try {
await retry(
async (bail, attempt) => {
// bail(error) should be called if the error is not retryable (e.g., 4xx client errors)
request.log.info({ params: { ...params, recipients: '[REDACTED]' }, attempt }, `Attempting to send reply SMS via MessageBird API (Attempt ${attempt})`);
try {
const result = await messagebird.messages.create(params);
request.log.info({ resultId: result.id, status: result.recipients?.items[0]?.status }, 'Successfully sent reply SMS.');
// If successful, the retry loop stops here.
return result; // Return the result if needed downstream
} catch (error) {
// Check if the error is a MessageBird API client error (e.g., 4xx)
if (error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
request.log.error({ err: error_ attempt }_ `Non-retryable error sending SMS (Attempt ${attempt}). Bailing.`);
bail(error); // Stop retrying for client errors
} else {
request.log.warn({ err: error_ attempt }_ `Retryable error sending SMS (Attempt ${attempt}). Retrying...`);
throw error; // Throw error to trigger retry
}
}
}_
{
retries: 3_ // Number of retries (total 4 attempts)
factor: 2_ // Exponential backoff factor (delay = minTimeout * factor^(attempt-1))
minTimeout: 1000_ // Initial delay 1 second
maxTimeout: 10000_ // Max delay 10 seconds
onRetry: (error_ attempt) => {
request.log.warn(`Retrying SMS send (attempt ${attempt}) due to error: ${error.message || 'Unknown error'}`);
}
}
);
// If retry succeeds, execution continues here.
} catch (error) {
// This block executes only if all retries fail or if bail() was called.
request.log.error({ err: error }, 'Failed to send reply SMS via MessageBird API after all retries.');
// Still return 204 for the incoming webhook, but consider alerting/monitoring this failure.
}
// Acknowledge receipt of the webhook to MessageBird *after* attempting the reply
// Sending 204 No Content is appropriate as no response body is needed by MessageBird
reply.code(204).send()
}
// --- Define the Route ---
module.exports = async function (fastify, opts) {
const webhookPath = `/${process.env.WEBHOOK_SECRET_PATH}`
if (!process.env.WEBHOOK_SECRET_PATH || process.env.WEBHOOK_SECRET_PATH.length < 10) {
// Add a basic check for a non-trivial secret path
fastify.log.error('WEBHOOK_SECRET_PATH environment variable is missing or too short!');
throw new Error('WEBHOOK_SECRET_PATH environment variable must be set to a secure_ random string.');
}
fastify.log.info(`Registering webhook endpoint at POST ${webhookPath}`)
fastify.post(
webhookPath_
{
// The preHandler hook runs *before* the main handler
// If verifyMessageBirdSignature sends a reply (e.g._ on error)_ handleIncomingSms will not run.
preHandler: [verifyMessageBirdSignature]
}_
handleIncomingSms // The main handler for processing the SMS
)
}
Explanation:
- SDK Initialization: The MessageBird SDK is initialized globally using the API key from environment variables.
verifyMessageBirdSignature
(Hook):- This
async
function is registered as apreHandler
hook for the webhook route. - It retrieves the signature_ timestamp_ signing key_ and crucially_ the
request.rawBodyString
(made available by our custom parser inapp.js
). - It performs checks to ensure all necessary components are present.
- It constructs the
signedPayload
as specified by MessageBird (timestamp + '.' + rawBody
). - It calculates the expected HMAC SHA256 signature using the
webhookSigningKey
. - It uses
crypto.timingSafeEqual
to securely compare the received signature with the expected one_ preventing timing attacks. - Important: If verification fails (missing headers_ invalid signature_ internal error)_ it sends an appropriate error response (
400
_401
_ or500
) and returns_ preventing the mainhandleIncomingSms
function from executing. - If verification succeeds_ it logs success and allows the request to proceed to the main handler.
- This
handleIncomingSms
:- This function now only executes if the signature verification hook passes successfully.
- It logs the (already parsed)
request.body
. - Extracts
originator
_payload
_ andrecipient
. Note: Always verify these field names by logging a real webhook payload from MessageBird. - Includes basic validation for required fields.
- Defines simple reply logic.
- Uses the globally initialized
messagebird.messages.create
to send the reply_ wrapped inasync-retry
. - Sends a
204 No Content
response back to MessageBird to acknowledge successful receipt and processing of the webhook. This should be sent even if the outgoing reply fails_ to prevent MessageBird from retrying the incoming webhook.
- Route Definition:
- Reads the secret path from
WEBHOOK_SECRET_PATH
. Includes a basic check to ensure it's set. - Registers a
POST
handler for that path. - The
preHandler
array includes ourverifyMessageBirdSignature
hook.
- Reads the secret path from
3. Building an API Layer (Optional)
While the core functionality relies on the webhook_ you might want an API endpoint to manually trigger an outbound SMS for testing or other application purposes.
3.1 Create API Route:
Create src/routes/api.js
:
// src/routes/api.js
'use strict'
const { initClient } = require('@messagebird/api')
// Initialize MessageBird client (ensure required env vars are set)
const messagebird = initClient(process.env.MESSAGEBIRD_API_KEY)
// Define schema for request validation using Fastify's built-in validation
const sendMessageSchema = {
body: {
type: 'object'_
required: ['recipient'_ 'message']_
properties: {
recipient: {
type: 'string'_
description: 'E.164 formatted phone number (e.g._ +12025550181)'_
// Example pattern for basic E.164 format check
pattern: '^\\+[1-9]\\d{1_14}$' // Corrected pattern string
}_
message: {
type: 'string'_
minLength: 1_
maxLength: 1600_ // Standard max length for concatenated SMS
description: 'The text message content'
}_
}_
additionalProperties: false // Disallow extra fields in the request body
}_
response: {
200: {
description: 'SMS sent successfully'_
type: 'object'_
properties: {
messageId: { type: 'string'_ description: 'The ID of the sent message from MessageBird' }_
status: { type: 'string'_ description: 'Initial status (usually "sent")' }_
}_
}_
400: {
description: 'Invalid request body'_
type: 'object'_
properties: {
statusCode: { type: 'integer' }_
error: { type: 'string' }_
message: { type: 'string' }
}
}_
500: {
description: 'Internal server error or failed to send SMS'_
type: 'object'_
properties: {
error: { type: 'string' }
}
}
// Add other response schemas (e.g._ 401_ 403) if implementing authentication/authorization
}_
}
async function sendManualSms (request_ reply) {
// Request body is automatically validated against the schema by Fastify
const { recipient_ message } = request.body
const sender = process.env.MESSAGEBIRD_ORIGINATOR
if (!sender) {
request.log.error('MessageBird Originator (MESSAGEBIRD_ORIGINATOR) is not configured on the server.');
reply.code(500).send({ error: 'SMS configuration error on server.' })
return
}
const params = {
originator: sender_
recipients: [recipient]_
body: message_
// You could add optional parameters like 'reference'_ 'reportUrl' here
}
request.log.info({ params: { ...params_ recipients: '[REDACTED]' } }_ 'Sending manual SMS via /api/send-message endpoint') // Avoid logging recipient numbers if possible
try {
const result = await messagebird.messages.create(params)
request.log.info({ resultId: result.id_ status: result.recipients?.items[0]?.status }_ 'Successfully sent manual SMS via API.')
// Respond with relevant info_ like the message ID and initial status
reply.send({
messageId: result.id_
status: result.recipients?.items[0]?.status || 'sent' // Extract status if available
})
} catch (error) {
request.log.error({ err: error }_ 'Failed to send manual SMS via MessageBird API.')
// Provide a generic error response_ masking internal details
reply.code(500).send({ error: 'Failed to send SMS via MessageBird API' })
}
}
module.exports = async function (fastify_ opts) {
fastify.post(
'/send-message'_
{
schema: sendMessageSchema_
// Add authentication/authorization hooks here if this API needs protection
// preHandler: [fastify.authenticate] // Example if using fastify-auth
}_
sendManualSms
)
}
3.2 Register API Route in app.js
:
This was already done in Section 1.7 when setting up src/app.js
:
// src/app.js (inside the routes registration section)
// ... other registrations
app.register(require('./routes/api')_ { prefix: '/api' }) // Register API routes under /api
3.3 Testing the API Endpoint:
Once the server is running (npm run dev
)_ you can test this endpoint using curl
or Postman:
curl -X POST http://localhost:3000/api/send-message \
-H "Content-Type: application/json" \
-d '{
"recipient": "+15551234567"_
"message": "Hello from the Fastify API!"
}'
Replace +15551234567
with a valid test phone number in E.164 format.
Response Example (Success):
{
"messageId": "mb-message-id-string-from-api"_
"status": "sent"
}
Response Example (Validation Error):
{
"statusCode": 400_
"error": "Bad Request"_
"message": "body/recipient must match pattern \"^\\\\+[1-9]\\\\d{1_14}$\""
}
4. Integrating with MessageBird (Webhook Configuration)
Now_ we need to tell MessageBird where to send incoming SMS messages by configuring the webhook URL in Flow Builder.
4.1 Expose Local Server:
While developing_ your server running on localhost:3000
isn't accessible from the public internet. Use ngrok
to create a secure tunnel.
Open a new terminal window (keep the Fastify server running in the first one) and run:
ngrok http 3000
ngrok
will display output similar to this:
Session Status online
Account Your Name (Plan: Free)
Version x.x.x
Region United States (us)
Web Interface http://127.0.0.1:4040
Forwarding http://randomstring.ngrok.io -> http://localhost:3000
Forwarding https://randomstring.ngrok.io -> http://localhost:3000
Connections ttl opn rt1 rt5 p50 p90
0 0 0.00 0.00 0.00 0.00
Copy the https://
forwarding URL (e.g., https://randomstring.ngrok.io
). This is your temporary public URL. Use the HTTPS version.
4.2 Configure Flow Builder:
- Go to your MessageBird Dashboard.
- Navigate to Flow Builder.
- Click Create new flow > Create Custom Flow.
- Give your flow a name (e.g., ""Fastify SMS Handler"").
- For the Trigger, select SMS. Click Next.
- Click the SMS trigger step in the flow canvas.
- In the configuration panel on the right, select the MessageBird Number(s) you want to associate with this flow. This is the number users will text.
- Click the + button below the SMS trigger to add a step.
- Choose the Forward to URL step (under ""HTTP Request"").
- In the configuration panel for this step:
- URL: Paste your
ngrok
HTTPS URL and append your secret webhook path (from.env
). Example:https://randomstring.ngrok.io/your-very-secret-random-webhook-path
- Method: Select POST.
- Authentication: Leave as ""None"" (we are using signature validation in our app).
- (Optional) Configure retries if needed, but our app aims to always return
2xx
quickly. MessageBird's default retries are usually sufficient if your app has temporary downtime.
- URL: Paste your
- Click Save.
- Click the Publish button in the top-right corner of the Flow Builder interface to activate the flow.
Now, when an SMS is sent to your selected MessageBird number, MessageBird will execute this flow, sending a POST request (content type application/x-www-form-urlencoded
) to your ngrok
URL, which forwards it to your local Fastify application's /your-very-secret-random-webhook-path
endpoint. The signature verification hook will run first.
5. Implementing Proper Error Handling and Logging
Robust error handling and clear logging are essential for production applications.
5.1 Fastify Error Handling:
Fastify has built-in error handling. Our try...catch
blocks in the route handlers manage specific API call errors. The signature verification hook handles its own errors. For broader, uncaught errors, Fastify's default handler will catch them. We added a global setErrorHandler
in app.js
(Section 1.7) for consistent error responses.
5.2 Logging:
We configured pino-pretty
for readable development logs. In production (NODE_ENV=production
), Fastify defaults to efficient JSON logging, suitable for log aggregation tools (Datadog, Splunk, ELK stack, CloudWatch Logs).
- Key Log Points:
- Webhook received (after signature verification)
- Signature verification success/failure
- Outgoing API call attempt (with parameters, potentially redacted)
- Outgoing API call success (with result ID/status)
- Outgoing API call failure (with error details)
- Database operations (if implemented)
- Unhandled errors (via
setErrorHandler
)
- Log Level: Use
LOG_LEVEL
environment variable (info
for production,debug
for troubleshooting).
5.3 Retry Mechanisms:
- Incoming Webhook: MessageBird retries webhooks if it doesn't receive a
2xx
response within its timeout period. Our goal is to always send204 No Content
quickly from the webhook handler, even if the subsequent reply fails. Handle reply failures asynchronously if necessary (e.g., push to a background queue). - Outgoing API Calls: Network issues or temporary MessageBird API problems can cause
messagebird.messages.create
to fail. We usedasync-retry
(installed in Section 1.3 and implemented insrc/routes/webhook.js
in Section 2.1) to handle this gracefully.
5.4 Testing Error Scenarios:
- Invalid Signature: Temporarily change
MESSAGEBIRD_WEBHOOK_SIGNING_KEY
in.env
and send an SMS. Observe the401 Unauthorized
response from theverifyMessageBirdSignature
hook in your Fastify logs and thengrok
web interface (http://127.0.0.1:4040
). - API Failure: Temporarily use an invalid
MESSAGEBIRD_API_KEY
or simulate network issues. Observe the retry attempts and final failure logs from thehandleIncomingSms
function. Check that the webhook still returns204
to MessageBird. - Invalid Payload: Send a request to the API endpoint (
/api/send-message
) with missing or malformed JSON data. Observe the400 Bad Request
response generated by Fastify's schema validation or the global error handler.