code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

NestJS Two-Way SMS with MessageBird: Complete Webhook Integration Guide

Learn how to build production-ready two-way SMS messaging with NestJS and MessageBird API. Step-by-step tutorial covering webhook setup, inbound SMS handling, JWT signature verification, and deployment best practices.

Building Production-Ready Two-Way SMS with NestJS and MessageBird

This comprehensive tutorial shows you how to implement two-way SMS messaging using NestJS and the MessageBird API. You'll learn how to handle inbound SMS webhooks, send automated replies, secure your webhook endpoints with JWT verification, and deploy a production-ready messaging system.

Two-way SMS messaging solves critical business needs: customer support chat, notifications requiring user responses, and interactive SMS campaigns. By the end of this guide, you'll have a production-ready NestJS application with proper error handling, webhook security, and configuration management.

Key Technologies:

  • NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications. Its modular architecture and built-in features (like validation pipes and configuration management) accelerate development.
  • MessageBird: A communication platform providing APIs for SMS, voice, and chat. You'll use its SMS API and virtual numbers.
  • MessageBird Node.js SDK: Simplifies interaction with the MessageBird API. The SDK uses a callback-based API, which you'll wrap with Promise utilities for modern async/await patterns.
  • Tunneling Service (ngrok/localtunnel): Required during development to expose your local NestJS application to the public internet so MessageBird webhooks can reach it.
  • (Optional) Database: For persisting message history (e.g., PostgreSQL with TypeORM).

System Architecture:

mermaid
graph LR
    User -- SMS --> MessageBird_VN[MessageBird Virtual Number]
    MessageBird_VN -- Configured Flow --> MB_Flow[MessageBird Flow Builder]
    MB_Flow -- POST Webhook --> Tunnel[Tunnel (ngrok/localtunnel)]
    Tunnel -- Forwards Request --> NestJS_App[NestJS Application (Webhook Endpoint)]
    NestJS_App -- Processes & Stores (Optional) --> DB[(Database)]
    NestJS_App -- Uses SDK --> MB_API[MessageBird API]
    MB_API -- Sends SMS --> User

Prerequisites:

  • Node.js 18+ (LTS version recommended) and npm or yarn
  • A MessageBird account
  • A purchased MessageBird virtual mobile number (VMN) with SMS capabilities
  • A tunneling tool like ngrok or localtunnel installed globally
  • Basic understanding of TypeScript and NestJS concepts
  • Access to a terminal or command prompt

By the end of this guide, you will have a functional NestJS application that:

  1. Listens for incoming SMS messages on a specific endpoint.
  2. Validates the incoming webhook payload.
  3. Logs received messages.
  4. Sends an automated reply back to the sender using the MessageBird API.
  5. Includes basic error handling, configuration management, and security considerations.

1. Setting up Your NestJS Project

Initialize your NestJS project and install the necessary dependencies for MessageBird integration.

  1. Install NestJS CLI: If you haven't already, install the NestJS CLI globally.

    bash
    npm install -g @nestjs/cli
    # or
    yarn global add @nestjs/cli
  2. Create NestJS Project: Generate a new NestJS project.

    bash
    nest new messagebird-nestjs-webhook
    cd messagebird-nestjs-webhook
  3. Install Dependencies: Install the MessageBird SDK, NestJS config module, and dotenv for environment variable management.

    bash
    npm install messagebird @nestjs/config dotenv class-validator class-transformer
    # or
    yarn add messagebird @nestjs/config dotenv class-validator class-transformer
    • messagebird: The official Node.js SDK (v10+).
    • @nestjs/config: Manages environment variables.
    • dotenv: Loads environment variables from a .env file during development.
    • class-validator & class-transformer: Validates incoming webhook data using DTOs.
  4. Environment Configuration (.env): Create a .env file in the project root. This file stores sensitive credentials and configuration. Do not commit this file to version control. Ensure .env is listed in your .gitignore file.

    • Obtain MessageBird API Key:
      • Log in to your MessageBird Dashboard.
      • Navigate to Developers > API access (REST).
      • If you don't have one, click Add access key. Ensure it's a Live key (not Test).
      • Copy the generated access key.
    • Obtain MessageBird Originator (Virtual Number):
      • Navigate to Numbers in your Dashboard.
      • Copy the virtual mobile number you purchased (including the + and country code, e.g., +12015550123).

    Add these values to your .env file:

    ini
    # .env
    MESSAGEBIRD_API_KEY=YOUR_LIVE_API_KEY_HERE
    MESSAGEBIRD_ORIGINATOR=YOUR_VIRTUAL_NUMBER_HERE
  5. Configure NestJS ConfigModule: Import and configure the ConfigModule in your main application module (src/app.module.ts) to load environment variables from the .env file.

    typescript
    // src/app.module.ts
    import { Module } from '@nestjs/common';
    import { ConfigModule } from '@nestjs/config';
    import { AppController } from './app.controller';
    import { AppService } from './app.service';
    import { WebhooksModule } from './webhooks/webhooks.module';
    
    @Module({
      imports: [
        ConfigModule.forRoot({
          isGlobal: true, // Make config available globally
          envFilePath: '.env',
        }),
        WebhooksModule,
      ],
      controllers: [AppController],
      providers: [AppService],
    })
    export class AppModule {}
  6. Enable Validation Pipe Globally: To automatically validate incoming request bodies against DTOs, enable the ValidationPipe globally in src/main.ts.

    typescript
    // src/main.ts
    import { NestFactory } from '@nestjs/core';
    import { AppModule } from './app.module';
    import { ValidationPipe } from '@nestjs/common';
    import { ConfigService } from '@nestjs/config';
    
    async function bootstrap() {
      const app = await NestFactory.create(AppModule);
    
      // Enable global validation pipe with strict security options
      app.useGlobalPipes(new ValidationPipe({
        whitelist: true, // Strip properties not defined in DTO
        forbidNonWhitelisted: false, // Allow webhook providers to send extra fields
        transform: true, // Automatically transform payloads to DTO instances
        // Note: Setting forbidNonWhitelisted to false allows MessageBird to include
        // extra fields in webhooks. The whitelist option strips these fields before
        // reaching your handler. Set forbidNonWhitelisted to true if you want to
        // reject requests with unexpected fields rather than silently ignoring them.
      }));
    
      // Get port from environment variables or default to 3000
      const configService = app.get(ConfigService);
      const port = configService.get<number>('PORT', 3000);
    
      await app.listen(port);
      console.log(`Application is running on: ${await app.getUrl()}`);
    }
    bootstrap();

The basic project structure and configuration are now in place.


2. Implementing Webhook Handler for Inbound SMS

Create a dedicated module, controller, service, and DTO to handle incoming MessageBird SMS webhooks.

  1. Generate Module, Controller, and Service: Use the NestJS CLI to scaffold these components within a webhooks feature directory.

    bash
    nest g module webhooks
    nest g controller webhooks/messagebird --flat --no-spec
    nest g service webhooks/messagebird --flat --no-spec
    • --flat: Prevents creating an extra subdirectory for the controller/service files.
    • --no-spec: Skips generating test files for now (add them later for production code).

    The WebhooksModule should already be imported into AppModule (as shown in the previous step).

  2. Create Incoming Message DTO: Define a Data Transfer Object (DTO) to represent the expected payload from the MessageBird SMS webhook. Add validation rules using class-validator.

    typescript
    // src/webhooks/dto/incoming-message.dto.ts
    import { IsNotEmpty, IsString, IsPhoneNumber } from 'class-validator';
    
    export class IncomingMessageDto {
      @IsNotEmpty()
      @IsString()
      @IsPhoneNumber(null) // Use null for international format validation
      originator: string; // Sender's phone number (e.g., +14155552671)
    
      @IsNotEmpty()
      @IsString()
      payload: string; // The content of the SMS message
    
      // Note: MessageBird sends additional fields in the webhook payload
      // (e.g., 'recipient', 'id', 'receivedDatetime'). We only define
      // the fields essential for our core logic here. The whitelist: true
      // option in the ValidationPipe strips any extra fields automatically,
      // enhancing security and simplifying the handler.
      // If you need other fields, add them to this DTO with appropriate validation.
    }
  3. Implement the MessageBird Service: This service contains the logic to initialize the MessageBird SDK and send reply messages. The MessageBird Node.js SDK uses callbacks by default, so use Node.js's util.promisify to convert callback-based methods to Promise-based ones for modern async/await patterns.

    typescript
    // src/webhooks/messagebird.service.ts
    import { Injectable, Logger, InternalServerErrorException } from '@nestjs/common';
    import { ConfigService } from '@nestjs/config';
    import { initClient } from 'messagebird';
    import { IncomingMessageDto } from './dto/incoming-message.dto';
    import { promisify } from 'util';
    
    @Injectable()
    export class MessagebirdService {
      private readonly logger = new Logger(MessagebirdService.name);
      private messagebird: any; // SDK instance (uses callback-based API)
      private originator: string; // Our sending number
      private sendMessageAsync: any; // Promisified send method
    
      constructor(private configService: ConfigService) {
        const apiKey = this.configService.get<string>('MESSAGEBIRD_API_KEY');
        this.originator = this.configService.get<string>('MESSAGEBIRD_ORIGINATOR');
    
        if (!apiKey || !this.originator) {
          throw new Error('MessageBird API Key or Originator not configured in environment variables.');
        }
    
        // Initialize the SDK using initClient() as per official documentation
        this.messagebird = initClient(apiKey);
    
        // Convert callback-based messages.create to Promise-based for async/await
        this.sendMessageAsync = promisify(this.messagebird.messages.create.bind(this.messagebird.messages));
      }
    
      /**
       * Handles the validated incoming message payload.
       * Logs the message and sends a reply.
       * @param incomingMessage - Validated incoming message DTO.
       */
      async handleIncomingMessage(incomingMessage: IncomingMessageDto): Promise<void> {
        this.logger.log(`Received message from ${incomingMessage.originator}: "${incomingMessage.payload}"`);
    
        // Simple reply logic
        const replyText = `Thanks for your message: "${incomingMessage.payload}". We'll be in touch!`;
    
        try {
          await this.sendMessage(incomingMessage.originator, replyText);
        } catch (error) {
          // Error handling is done within sendMessage, log additional context here
          this.logger.error(`Failed to send reply to ${incomingMessage.originator}`, error instanceof Error ? error.stack : String(error));
          // Depending on requirements, you can re-throw or handle differently
          // The controller will catch this and return 500 if not handled here
        }
      }
    
      /**
       * Sends an SMS message using the MessageBird SDK (promisified for async/await).
       * @param recipient - The destination phone number (international format).
       * @param body - The text content of the message.
       */
      async sendMessage(recipient: string, body: string): Promise<void> {
        const params = {
          originator: this.originator,
          recipients: [recipient],
          body: body,
        };
    
        this.logger.log(`Attempting to send SMS to ${recipient} from ${this.originator}`);
    
        try {
          const response = await this.sendMessageAsync(params);
          this.logger.log(`MessageBird SMS sent successfully to ${recipient}. ID: ${response?.id}`);
        } catch (error: any) {
          this.logger.error(`Error sending MessageBird SMS to ${recipient}:`, error);
          // MessageBird SDK error structure: error.errors array with code, description, parameter
          const errorMessage = error?.errors?.[0]?.description || error?.message || 'Unknown MessageBird API error';
          throw new InternalServerErrorException(`Failed to send SMS via MessageBird: ${errorMessage}`);
        }
      }
    }
  4. Implement the MessageBird Controller: This controller defines the webhook endpoint. It uses the DTO for validation and delegates processing to the service.

    typescript
    // src/webhooks/messagebird.controller.ts
    import { Controller, Post, Body, HttpCode, HttpStatus, Logger } from '@nestjs/common';
    import { MessagebirdService } from './messagebird.service';
    import { IncomingMessageDto } from './dto/incoming-message.dto';
    
    @Controller('webhooks/messagebird')
    export class MessagebirdController {
      private readonly logger = new Logger(MessagebirdController.name);
    
      constructor(private readonly messagebirdService: MessagebirdService) {}
    
      @Post('sms') // Endpoint: POST /webhooks/messagebird/sms
      @HttpCode(HttpStatus.OK) // Respond with 200 OK on success
      async handleIncomingSms(@Body() incomingMessageDto: IncomingMessageDto): Promise<string> {
        // DTO validation is handled automatically by ValidationPipe (configured in main.ts)
        this.logger.log(`Webhook received: ${JSON.stringify(incomingMessageDto)}`);
    
        // Delegate processing to the service.
        // For production robustness, offload long-running operations (like external API calls)
        // to a background job queue. This allows the handler to return 'OK' faster and
        // reduces the risk of timeouts and retries from MessageBird.
        try {
             await this.messagebirdService.handleIncomingMessage(incomingMessageDto);
        } catch (error: any) {
             // Log the error but still return OK to MessageBird
             // unless it's critical that MessageBird knows about the failure (rare for SMS replies)
             this.logger.error(`Error handling incoming SMS: ${error.message}`, error.stack);
             // Optionally rethrow if MessageBird should retry (return 5xx status)
             // throw error;
        }
    
        // MessageBird expects a 200 OK status to acknowledge receipt.
        // The response body is usually ignored for SMS webhooks.
        return 'OK';
      }
    }

With these components, the core logic for receiving and replying to SMS messages is implemented using the MessageBird SDK.


3. Building a Complete Webhook API Endpoint

In this scenario, the primary "API" is the webhook endpoint (POST /webhooks/messagebird/sms) that receives data from MessageBird. We aren't building a traditional REST API for external clients to call to initiate SMS actions (though you can add endpoints like POST /messages for that purpose if needed).

  • Authentication/Authorization: The webhook endpoint doesn't require user authentication, as MessageBird's servers call it. Security relies on:
    • JWT signature verification (see Section 7) – this is the primary security mechanism.
    • Keeping the webhook URL non-obvious (avoiding generic paths like /webhook).
    • Rate limiting and firewall rules.
  • Request Validation: The IncomingMessageDto and the global ValidationPipe with whitelist: true handle this effectively. Invalid requests (missing required fields, incorrect types) result in a 400 Bad Request response from NestJS before your controller code runs. Fields not defined in the DTO are stripped automatically based on your ValidationPipe configuration.
  • API Endpoint Documentation:
    • Method: POST

    • Path: /webhooks/messagebird/sms

    • Request Content-Type: MessageBird sends webhooks as application/x-www-form-urlencoded (form-encoded) by default, though it can also send application/json depending on Flow Builder configuration. NestJS handles both formats automatically with its body parser middleware.

    • Request Body Structure:

      Form-encoded format (default from MessageBird):

      originator=+14155552671&payload=Hello+from+user!&recipient=+12015550123&id=message_id_string&receivedDatetime=2025-04-20T10:00:00Z

      JSON format (if configured):

      json
      {
        "originator": "+14155552671",
        "payload": "Hello from user!",
        "recipient": "+12015550123",
        "id": "message_id_string",
        "receivedDatetime": "2025-04-20T10:00:00Z"
      }

      Note: MessageBird sends additional fields beyond originator and payload. Our IncomingMessageDto combined with whitelist: true ensures only the fields we define are accepted and passed to our service logic. With forbidNonWhitelisted: false (recommended for webhooks), extra fields are silently stripped. With forbidNonWhitelisted: true, requests with extra fields are rejected with a 400 error.

    • Successful Response:

      • Status Code: 200 OK
      • Body: "OK" (The body content isn't critical for MessageBird).
    • Error Response (e.g., Validation Failed):

      • Status Code: 400 Bad Request
      • Body (Example):
        json
        {
          "statusCode": 400,
          "message": [
            "originator must be a phone number",
            "payload should not be empty"
          ],
          "error": "Bad Request"
        }
  • Testing with cURL: Assuming your app runs locally on port 3000:
    bash
    # Valid request (only fields in DTO matter)
    curl -X POST http://localhost:3000/webhooks/messagebird/sms \
    -H "Content-Type: application/json" \
    -d '{
      "originator": "+14155552671",
      "payload": "Test message from curl",
      "extra_field": "this will be ignored"
    }'
    
    # Invalid request (missing payload)
    curl -X POST http://localhost:3000/webhooks/messagebird/sms \
    -H "Content-Type: application/json" \
    -d '{
      "originator": "+14155552671"
    }'

4. Configuring MessageBird Flow Builder for Webhooks

This is a critical step. Tell MessageBird where to send incoming SMS messages directed to your virtual number.

  1. Start Tunneling: Before configuring MessageBird, you need a public URL that points to your local NestJS application.

    • Start your NestJS app: npm run start:dev (or yarn start:dev). It should be running (likely on port 3000).
    • Open a new terminal window.
    • Start your tunneling tool (replace 3000 if your app uses a different port):
      • Using ngrok: ngrok http 3000
      • Using localtunnel: lt --port 3000
    • The tool outputs a public URL (e.g., https://random-subdomain.ngrok.io or https://your-subdomain.localtunnel.me). Copy this HTTPS URL. You'll need it in the next step. Keep this tunnel running while testing.
  2. Configure MessageBird Flow Builder:

    • Log in to the MessageBird Dashboard.
    • Navigate to Flow Builder.
    • Click Create new flow > Create Custom Flow.
    • Give your flow a descriptive name (e.g., "NestJS SMS Webhook Handler").
    • Choose SMS as the trigger. Click Next.
    • In the flow editor, click the SMS trigger step. In the right-hand panel, select the virtual number(s) you want to associate with this flow. Click Save.
    • Click the + icon below the trigger step to add an action.
    • Select Call HTTP endpoint with SMS action from the available options.
    • Configure the HTTP endpoint:
      • Method: Select POST (strongly recommended by MessageBird).
      • URL: Paste your tunnel URL from step 1 and append the controller path: [Your Tunnel HTTPS URL]/webhooks/messagebird/sms.
        • Example: https://random-subdomain.ngrok.io/webhooks/messagebird/sms
      • Content-Type: You can optionally select application/json for the "Set Content-Type header" field, though the default form-encoded format works fine with NestJS. Our implementation handles both.
      • Leave other options as default unless you have specific needs (e.g., custom headers).
    • Click Save.
    • Click Publish (or Publish changes) in the top-right corner to activate the flow. Make sure it shows "Published".

    Important Notes:

    • MessageBird does not provide a programmatic API to configure webhook URLs for inbound SMS. You must use Flow Builder to set this up through the dashboard interface.
    • When you publish the flow, MessageBird may provide a webhook URL example in a pop-up. This is for reference on how to structure your endpoint.
    • The webhook URL you configure here is where MessageBird will POST incoming SMS data whenever someone sends a message to your virtual number.
  3. Environment Variables Recap:

    • MESSAGEBIRD_API_KEY: Your Live API key from the MessageBird Dashboard (Developers > API Access). Used by the SDK to authenticate requests to MessageBird (like sending replies).
    • MESSAGEBIRD_ORIGINATOR: Your purchased virtual mobile number (e.g., +12015550123) from the MessageBird Dashboard (Numbers). Used as the From number when sending replies via the SDK.
    • MESSAGEBIRD_SIGNING_KEY: Your signing key from the MessageBird Dashboard (Developers > API Access). Used to verify webhook request signatures for security.

    Ensure these are correctly set in your .env file for local development and configured securely in your deployment environment.


5. Implementing Error Handling and Retry Logic

  • Error Handling Strategy:
    • Validation Errors: Handled automatically by ValidationPipe. NestJS returns a 400 response.
    • SDK Errors (Sending): The messagebird.service.ts uses a try...catch block around the messagebird.messages.create call within the async sendMessage method. It logs the specific error from the SDK and throws a NestJS InternalServerErrorException (500). This prevents leaking detailed SDK errors to the caller (the controller) while signaling a failure. The controller can decide how to handle this (log and return 200, or let it bubble up to return 500).
    • Unexpected Errors: NestJS has built-in exception filters that catch unhandled errors and typically return a 500 response.
  • Logging:
    • We use NestJS's built-in Logger (@nestjs/common).
    • Logs are generated for:
      • Receiving a webhook request (messagebird.controller.ts).
      • Handling the message details (messagebird.service.ts).
      • Attempting to send a reply (messagebird.service.ts).
      • Successful reply transmission (messagebird.service.ts).
      • Errors during reply transmission (messagebird.service.ts).
    • Production Logging: Consider using a more robust logging library (like pino with nestjs-pino) to output structured JSON logs, which are easier to parse and analyze with log aggregation tools (e.g., Datadog, ELK stack). Configure log levels appropriately (e.g., INFO for standard operations, WARN for recoverable issues, ERROR for failures).
  • Retry Mechanisms:
    • Incoming Webhook: MessageBird retries sending the webhook if your endpoint returns an error (5xx) or times out. Your endpoint should be idempotent if possible (see Section 8) to handle duplicate deliveries gracefully. The primary goal is to return 200 OK quickly to acknowledge receipt. Offloading work to queues helps significantly here.
    • Outgoing Reply (SDK Call): The current implementation does not automatically retry sending the reply if the messagebird.messages.create call fails. For production robustness:
      • Simple Retry: Implement a basic retry loop within the sendMessage method (e.g., using async-retry npm package) with exponential backoff for transient network issues or temporary MessageBird API problems. Be cautious not to block the webhook response for too long.
      • Queue-Based Approach (Recommended): For high reliability, decouple sending from the webhook handler. When a message needs to be sent:
        1. Add a job to a message queue (e.g., Redis with BullMQ, RabbitMQ).
        2. A separate worker process picks up jobs from the queue.
        3. The worker attempts to send the SMS via the MessageBird SDK.
        4. Configure the queue system for automatic retries with backoff on failure. This prevents webhook timeouts and handles temporary downstream issues robustly.

6. Creating a Database Schema for Message Persistence (Optional)

While not strictly required for a simple echo bot, persisting message history is crucial for real applications (support chats, order updates). Here's a conceptual outline using TypeORM and PostgreSQL (adapt for your chosen database).

  1. Install Dependencies:

    bash
    npm install @nestjs/typeorm typeorm pg
    # or
    yarn add @nestjs/typeorm typeorm pg
  2. Configure TypeOrmModule: Set up the database connection in app.module.ts (or a dedicated database module), loading credentials from ConfigService.

    typescript
    // src/app.module.ts (Example addition)
    import { TypeOrmModule } from '@nestjs/typeorm';
    import { ConfigModule, ConfigService } from '@nestjs/config';
    // ... other imports
    
    @Module({
      imports: [
        ConfigModule.forRoot({ /* ... */ }),
        TypeOrmModule.forRootAsync({
          imports: [ConfigModule],
          inject: [ConfigService],
          useFactory: (configService: ConfigService) => ({
            type: 'postgres',
            host: configService.get<string>('DB_HOST', 'localhost'),
            port: configService.get<number>('DB_PORT', 5432),
            username: configService.get<string>('DB_USERNAME'),
            password: configService.get<string>('DB_PASSWORD'),
            database: configService.get<string>('DB_NAME'),
            entities: [__dirname + '/../**/*.entity{.ts,.js}'],
            synchronize: configService.get<string>('NODE_ENV') !== 'production', // ONLY true for dev
          }),
        }),
        WebhooksModule,
      ],
    })
    export class AppModule {}

    Add DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_NAME to your .env.

  3. Define Message Entity: Create an entity representing a message record.

    typescript
    // src/messages/message.entity.ts
    import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm';
    
    export enum MessageDirection {
      INBOUND = 'in',
      OUTBOUND = 'out',
    }
    
    @Entity('messages')
    export class Message {
      @PrimaryGeneratedColumn('uuid')
      id: string;
    
      @Column({
        type: 'enum',
        enum: MessageDirection,
      })
      direction: MessageDirection;
    
      @Index() // Index for faster lookups
      @Column()
      contactNumber: string; // The external party's number (+1 format)
    
      @Column()
      virtualNumber: string; // Your MessageBird number involved (+1 format)
    
      @Column('text')
      body: string;
    
      @Column({ type: 'varchar', length: 64, nullable: true })
      messagebirdId?: string; // Store the ID from MessageBird API if available
    
      @CreateDateColumn()
      timestamp: Date;
    
      // Add other fields as needed: status, conversationId, etc.
    }
  4. Integrate with Service:

    • Inject the Repository<Message> into MessagebirdService.
    • In handleIncomingMessage, create and save an INBOUND message record before sending the reply.
    • In sendMessage, create and save an OUTBOUND message record after successfully sending via the SDK (include the messagebirdId from the response).
  5. Migrations: For production, set synchronize: false and use TypeORM migrations to manage schema changes safely.

    • Add migration scripts to your package.json.
    • Generate migrations: npm run typeorm -- migration:generate -n InitialSchema
    • Run migrations: npm run typeorm -- migration:run

This provides a basic structure for message persistence, enabling conversation history and state management.


7. Securing Your Webhook with JWT Signature Verification

Securing your webhook endpoint and application is vital.

  1. Webhook Signature Verification (Highly Recommended): MessageBird signs all webhook requests with a JWT signature in the MessageBird-Signature-JWT header. Verifying this signature ensures the request genuinely comes from MessageBird and hasn't been tampered with, protecting against replay attacks and unauthorized webhook calls.

    • Setup:

      • Obtain your signing key from the MessageBird Dashboard (Developers > API access). This is different from your API key.
      • Add it to your .env file:
        ini
        MESSAGEBIRD_SIGNING_KEY=YOUR_SIGNING_KEY_HERE
    • Implementation: Create a NestJS guard to verify the signature. Install the required package if not already present:

      bash
      npm install jsonwebtoken
      # or
      yarn add jsonwebtoken

      Create the verification guard:

      typescript
      // src/webhooks/guards/messagebird-signature.guard.ts
      import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, Logger } from '@nestjs/common';
      import { ConfigService } from '@nestjs/config';
      import { Request } from 'express';
      import * as jwt from 'jsonwebtoken';
      
      @Injectable()
      export class MessagebirdSignatureGuard implements CanActivate {
        private readonly logger = new Logger(MessagebirdSignatureGuard.name);
        private readonly signingKey: string;
      
        constructor(private configService: ConfigService) {
          const key = this.configService.get<string>('MESSAGEBIRD_SIGNING_KEY');
          if (!key) {
            throw new Error('MESSAGEBIRD_SIGNING_KEY not configured');
          }
          this.signingKey = key;
        }
      
        canActivate(context: ExecutionContext): boolean {
          const request = context.switchToHttp().getRequest<Request>();
          const signature = request.headers['messagebird-signature-jwt'] as string;
      
          if (!signature) {
            this.logger.warn('Missing MessageBird-Signature-JWT header');
            throw new UnauthorizedException('Missing signature');
          }
      
          try {
            // Verify the JWT signature with the signing key
            const decoded = jwt.verify(signature, this.signingKey, {
              algorithms: ['HS256'],
            });
      
            // Validate timestamp claims to prevent replay attacks
            const payload = decoded as any;
            const now = Math.floor(Date.now() / 1000);
      
            // Check if token is not expired (nbf = not before, exp = expiration)
            if (payload.nbf && payload.nbf > now) {
              throw new UnauthorizedException('Token not yet valid');
            }
            if (payload.exp && payload.exp < now) {
              throw new UnauthorizedException('Token expired');
            }
      
            this.logger.log('MessageBird signature verified successfully');
            return true;
          } catch (error) {
            this.logger.error('Signature verification failed', error);
            throw new UnauthorizedException('Invalid signature');
          }
        }
      }

      Apply the guard to your webhook controller:

      typescript
      // src/webhooks/messagebird.controller.ts
      import { Controller, Post, Body, HttpCode, HttpStatus, Logger, UseGuards } from '@nestjs/common';
      import { MessagebirdService } from './messagebird.service';
      import { IncomingMessageDto } from './dto/incoming-message.dto';
      import { MessagebirdSignatureGuard } from './guards/messagebird-signature.guard';
      
      @Controller('webhooks/messagebird')
      @UseGuards(MessagebirdSignatureGuard) // Apply signature verification to all routes
      export class MessagebirdController {
        // ... rest of controller implementation
      }

      Important Note: If your Node.js server is behind a proxy, configure Express to trust the proxy to correctly infer the protocol and hostname:

      typescript
      // In main.ts, after creating the app
      app.set('trust proxy', true);
  2. Input Validation and Sanitization:

    • Handled: Our use of class-validator within the IncomingMessageDto and the global ValidationPipe with whitelist: true provides strong input validation. It ensures only expected fields with correct types are processed, mitigating risks like prototype pollution.
    • Sanitization: While validation checks format, explicit sanitization (e.g., preventing potential XSS if message content is ever displayed in HTML without proper encoding) should be done at the point of use (e.g., in a front-end), not typically within this backend service unless it directly renders HTML. Trust the raw SMS content initially but handle it safely downstream.
  3. Common Vulnerabilities:

    • Webhook Abuse: Without signature verification, an attacker can repeatedly call your webhook URL, causing unnecessary processing and cost (if replies are sent). JWT signature verification (implemented above) is the primary defense.
    • Information Leakage: Ensure error messages (especially in production) don't leak sensitive information (stack traces, internal configurations). NestJS's default production error handling is generally safe, but be mindful of custom error logging.
  4. Rate Limiting: Protect the webhook endpoint from brute-force/DoS attacks using @nestjs/throttler.

    • Install: npm install @nestjs/throttler or yarn add @nestjs/throttler
    • Configure: Import and configure in app.module.ts:
      typescript
      // src/app.module.ts
      import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
      import { APP_GUARD } from '@nestjs/core';
      // ... other imports
      
      @Module({
        imports: [
          // ... ConfigModule, TypeOrmModule etc.
          ThrottlerModule.forRoot([{
            ttl: 60000, // Time-to-live (milliseconds) – 60 seconds
            limit: 10, // Max requests per TTL from one IP
          }]),
          WebhooksModule,
        ],
        controllers: [AppController],
        providers: [
          AppService,
          {
            provide: APP_GUARD,
            useClass: ThrottlerGuard,
          },
        ],
      })
      export class AppModule {}
    • This applies rate limiting based on IP address to all endpoints. Customize limits per-route using the @Throttle() decorator if needed.
  5. Secure API Key/Secrets Management:

    • NEVER hardcode API keys or sensitive data in source code.
    • Use .env for local development (and ensure it's in .gitignore).
    • In production, use your hosting provider's secrets management service (e.g., AWS Secrets Manager, Google Secret Manager, Docker secrets, environment variables injected securely by the platform).
  6. Keep Dependencies Updated: Regularly update Node.js, NestJS, MessageBird SDK, and other dependencies to patch known security vulnerabilities (npm outdated, npm update or yarn outdated, yarn upgrade). Use tools like npm audit or yarn audit.


8. Handling Edge Cases in SMS Integration

Real-world SMS interaction involves nuances:

  1. Duplicate Messages (Idempotency): MessageBird occasionally sends the same webhook twice (e.g., if it didn't receive a timely 200 OK initially).

    • Challenge: The standard MessageBird SMS webhook payload doesn't include a reliable, unique ID for the incoming message event itself that's guaranteed across retries. Fields like id usually refer to the message stored on MessageBird's side.
    • Mitigation (if using Database): Before processing (e.g., saving to DB and sending reply), check if a message with a very similar timestamp (e.g., within the last few seconds) from the same originator and with the identical payload already exists in your database. If so, log the duplicate and return 200 OK without reprocessing. This isn't foolproof but helps prevent obvious double replies.
    • Focus on Quick Response: The best defense is to ensure your webhook endpoint responds quickly with 200 OK. Offloading processing to a background queue (as mentioned in Section 5) is the most robust way to achieve this, minimizing the chance MessageBird needs to retry.
  2. Long Messages and Message Concatenation: SMS messages are limited to 160 characters for standard GSM encoding or 70 characters for Unicode (including emojis). Longer messages are split into segments.

    • Receiving: MessageBird automatically concatenates multipart incoming messages before sending the webhook, so you receive the full payload in one piece.
    • Sending: When sending via the SDK, MessageBird automatically splits messages longer than the character limit into segments. You'll be charged per segment. The recipient typically receives the segments as a single, reassembled message on their device (if their carrier supports it).
  3. Handling Opt-Outs and Stop Keywords: For marketing messages, you're typically required to honor opt-out requests (e.g., when a user replies "STOP").

    • Implementation: In your handleIncomingMessage method, check if the payload (converted to lowercase) matches keywords like "stop", "unsubscribe", or "cancel". If matched, add the originator to a suppression list (database table) and send a confirmation message. Before sending any outgoing message (especially marketing), check the suppression list to ensure the recipient hasn't opted out.
  4. Time Zone Handling: Store all timestamps in UTC in your database. When displaying messages to users, convert to their local time zone.

  5. Rate Limits and Throughput: MessageBird has rate limits on API requests. Check the MessageBird documentation for your account tier. For high-volume applications, implement throttling on the sending side or use a queue to control the rate of outgoing messages.

  6. Message Status Tracking: The MessageBird SDK returns a message ID when you send an SMS. Store this ID in your database. Set up a separate webhook endpoint to receive delivery status updates from MessageBird (delivered, failed, etc.). Update your database records based on these status callbacks to track message delivery.


9. Testing Your NestJS SMS Webhook Integration

  1. Unit Tests: Write unit tests for your service logic using Jest (included with NestJS).

    • Test MessagebirdService.handleIncomingMessage with various input payloads.
    • Mock the MessageBird SDK to test sending logic without making real API calls.
    • Test edge cases: empty payloads, invalid phone numbers, SDK errors.
  2. Integration Tests: Test the full webhook endpoint flow:

    • Use @nestjs/testing to create a testing module.
    • Send HTTP POST requests to /webhooks/messagebird/sms with various payloads.
    • Verify that validation errors return 400, valid requests return 200.
    • Test signature verification by including valid/invalid JWT headers.
  3. Manual Testing with cURL: Use the cURL examples from Section 3 to test your local endpoint.

  4. End-to-End Testing: Send a real SMS to your MessageBird virtual number and verify that:

    • Your webhook receives the message.
    • Your application sends the reply.
    • The reply arrives on your phone.

10. Production Deployment Best Practices

  1. Environment Variables: Ensure all required environment variables are set in your production environment:

    • MESSAGEBIRD_API_KEY
    • MESSAGEBIRD_ORIGINATOR
    • MESSAGEBIRD_SIGNING_KEY
    • DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_NAME (if using database)
    • PORT (optional, defaults to 3000)
    • NODE_ENV=production
  2. Update Webhook URL: Once deployed, update the webhook URL in MessageBird Flow Builder to point to your production domain (e.g., https://yourdomain.com/webhooks/messagebird/sms).

  3. Scaling:

    • Deploy multiple instances of your NestJS application behind a load balancer for high availability and throughput.
    • Use a queue system (Redis + BullMQ, RabbitMQ) to decouple webhook handling from message sending.
    • Use a managed database service (AWS RDS, Google Cloud SQL) for reliability and automatic backups.
  4. Monitoring:

    • Set up application monitoring (e.g., New Relic, Datadog, Application Insights).
    • Monitor webhook endpoint response times, error rates, and throughput.
    • Set up alerts for high error rates or slow response times.
    • Use structured logging (JSON format) and send logs to a centralized log aggregation service (ELK stack, Datadog).
  5. Health Checks: Implement a health check endpoint using @nestjs/terminus:

    bash
    npm install @nestjs/terminus

    Create a health module and endpoint that checks database connectivity, memory usage, and disk space. Configure your load balancer or orchestration system (Kubernetes) to use this endpoint for health checks.

  6. Security Checklist:

    • Enable HTTPS (use Let's Encrypt or your cloud provider's certificate service).
    • Implement JWT signature verification (covered in Section 7).
    • Enable rate limiting (covered in Section 7).
    • Set NODE_ENV=production to enable production optimizations and error handling.
    • Keep dependencies updated.
    • Use a firewall to restrict access to your application.
    • Implement proper secrets management (use your cloud provider's secrets service).
  7. Database Migrations: Run TypeORM migrations on deployment to apply schema changes:

    bash
    npm run typeorm -- migration:run

    Ensure your CI/CD pipeline includes this step before starting the application.


11. Troubleshooting Common Issues

IssuePossible CauseSolution
Webhook not receiving messagesTunnel not running or expiredRestart tunnel and update webhook URL in Flow Builder
Webhook returns 400 Bad RequestValidation error in payloadCheck logs for validation errors, verify DTO matches MessageBird's payload
Webhook returns 401 UnauthorizedSignature verification failedVerify MESSAGEBIRD_SIGNING_KEY is correct and matches dashboard
Reply message not sentInvalid API key or originatorVerify MESSAGEBIRD_API_KEY and MESSAGEBIRD_ORIGINATOR in .env
Reply message not sentInsufficient balanceCheck MessageBird account balance
Database connection failedIncorrect credentials or unreachable hostVerify database credentials and network access
Rate limit exceededToo many requestsImplement queue-based sending or increase rate limit
JWT signature verification errorMismatched signing keyRegenerate signing key in MessageBird dashboard and update .env

Debug Steps:

  1. Check application logs for error messages.
  2. Verify all environment variables are set correctly.
  3. Test the webhook endpoint with cURL to isolate the issue.
  4. Check MessageBird Flow Builder configuration and flow status.
  5. Verify tunnel is running and accessible from the internet.

Conclusion

You've built a production-ready two-way SMS messaging system using NestJS and MessageBird. This application:

  • Receives incoming SMS messages via MessageBird webhooks
  • Validates webhook payloads with DTO validation
  • Sends automated replies using the MessageBird SDK
  • Implements JWT signature verification for security
  • Includes error handling, logging, and rate limiting
  • Optionally persists message history to a database

Next Steps:

  • Implement database persistence for message history (Section 6)
  • Add background job queue for reliable message sending (Section 5)
  • Deploy to production and update webhook URL (Section 10)
  • Implement message status tracking with delivery callbacks
  • Add support for opt-outs and suppression lists (Section 8)
  • Build a front-end dashboard to view and manage conversations
  • Implement conversation threading and state management
  • Add support for MMS, WhatsApp, or other MessageBird channels

For more information, refer to:

Frequently Asked Questions

How to set up two-way SMS with NestJS?

Start by installing the NestJS CLI, creating a new project, and adding the MessageBird SDK, NestJS ConfigModule, and dotenv. Configure the ConfigModule to load environment variables, enable the ValidationPipe globally, and create a ".env" file to store your MessageBird API key and originator (virtual number).

What is MessageBird used for in NestJS SMS?

MessageBird is a communication platform that provides APIs for various communication channels, including SMS. In this NestJS setup, MessageBird's SMS API and virtual numbers are used to receive incoming SMS messages and send replies, enabling two-way SMS communication.

Why does my NestJS app need a tunneling service for MessageBird?

A tunneling service like ngrok or localtunnel is necessary during development to expose your locally running NestJS application to the public internet. This allows MessageBird webhooks, which are triggered by incoming SMS messages, to reach your local server.

When should I use a database with MessageBird and NestJS?

While not essential for simple SMS applications, a database becomes crucial when you need to persist message history, especially for features like customer support chats, interactive SMS campaigns, or scenarios requiring tracking message status and user responses over time.

How to create a MessageBird webhook endpoint in NestJS?

Use the NestJS CLI to generate a module, controller, and service specifically for webhooks. Define an IncomingMessageDto with validation rules, implement the MessageBird service to initialize the SDK and send replies, and configure the controller to handle incoming SMS webhooks at the desired endpoint.

What is the MessageBird IncomingMessageDto used for?

The IncomingMessageDto is a Data Transfer Object in NestJS that represents the structure of the incoming webhook payload from MessageBird. It is used with the ValidationPipe to automatically validate and sanitize incoming data, ensuring only expected fields with correct types are processed.

How to handle errors when sending SMS with MessageBird in NestJS?

Implement try...catch blocks around MessageBird API calls within your service to handle potential errors during sending. Log the errors using NestJS's Logger and throw appropriate exceptions. Consider retry mechanisms for better resilience.

How to configure MessageBird Flow Builder for NestJS webhooks?

After starting your local server and a tunneling service, go to the MessageBird Flow Builder. Create a new flow triggered by SMS, select your virtual number, and add a "Call HTTP endpoint with SMS" action. Set the method to POST and the URL to your tunnel URL + webhook endpoint path.

Why use class-validator and class-transformer with NestJS and MessageBird?

class-validator and class-transformer enhance security and streamline data handling. class-validator ensures that only expected fields with correct types are processed, preventing vulnerabilities. class-transformer simplifies data transformations between different formats.

How to handle duplicate messages in MessageBird webhooks with NestJS?

To handle duplicate messages, use a database to store message history. Upon receiving a webhook, query the database for a message with a similar timestamp, originator, and payload. If found, log it as a duplicate and avoid reprocessing.

What are recommended security measures for NestJS and MessageBird integration?

Implement input validation, rate limiting using @nestjs/throttler, secure API key management, and regular dependency updates to enhance your application's security.

How to log MessageBird SMS activity in a NestJS application?

Use NestJS's built-in Logger for development. For production, integrate a more robust logging library like Pino or Winston to output structured logs, facilitating analysis and debugging.

How to send SMS messages using the MessageBird API from a NestJS app?

Initialize the MessageBird Node.js SDK (v10+) using your API key. Use the messages.create method with appropriate MessageParameters, including originator, recipients, and body, to send SMS messages. Handle responses and errors appropriately.

What are the key dependencies for two-way SMS with NestJS and MessageBird?

Key dependencies include messagebird (Node.js SDK), @nestjs/config, dotenv, class-validator, class-transformer, and optionally @nestjs/typeorm, typeorm, and a database driver like pg for PostgreSQL.