Track the delivery status of your SMS messages in real-time by integrating Vonage Delivery Receipts (DLRs) into your NestJS application. This guide provides a step-by-step walkthrough for setting up a robust webhook endpoint to receive and process these status updates.
Knowing whether your messages reach their destination is crucial for many applications, enabling better customer communication, troubleshooting, and analytics. This guide solves the problem of reliably capturing and handling these delivery statuses within a modern Node.js backend framework.
Technologies Used:
- Node.js: The JavaScript runtime environment.
- NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications. Chosen for its modular architecture, dependency injection, and TypeScript support.
- Vonage SMS API: Used for sending SMS and providing DLR webhooks.
@vonage/server-sdk
: The official Vonage Node.js SDK. While this guide focuses on receiving DLRs (which doesn't directly use the SDK), you would typically use this SDK within your NestJS application to send the initial SMS messages that generate these DLRs.@nestjs/config
: For managing environment variables.class-validator
&class-transformer
: For validating incoming webhook data.- ngrok (for local development): To expose your local development server to the internet for Vonage webhooks.
System Architecture:
+-----------------+ +---------------------+ +------------------------+ +-----------------+
| Your Application| ---> | Vonage SMS API | ===> | Mobile Carrier Network | ===> | End User Device |
| (Sends SMS) | | (Initiates Send) | | (Delivers SMS) | | (Receives SMS) |
+-----------------+ +---------------------+ +------------------------+ +-----------------+
| ^
| (Sends DLR Status Update) |
V |
+---------------------+ +------------------------+ +---------------------+ |
| Vonage Platform | <--- | Mobile Carrier Network | <--- | End User Device | | (Processes DLR)
| (Receives DLR) | | (Reports Status) | | (Implicit Ack) | |
+---------------------+ +------------------------+ +---------------------+ |
| |
| (Forwards DLR via Webhook POST) |
V |
+------------------------+ +--------------------------------+ |
| Your Publicly | ---> | NestJS Application | --------------------+
| Accessible Endpoint | | - Webhook Controller |
| (e.g., via ngrok/Prod)| | - DLR Processing Service |
+------------------------+ | - Validation (DTO) |
| - Logging/Error Handling |
+--------------------------------+
Final Outcome & Prerequisites:
By the end of this guide, you will have a NestJS application capable of:
- Receiving POST requests from Vonage containing SMS delivery status updates.
- Validating the structure of the incoming DLR data.
- Logging the received delivery information.
- Responding correctly to Vonage to acknowledge receipt.
Prerequisites:
- Node.js (LTS version recommended) and npm/yarn installed.
- A Vonage API account (Sign up for free credit)
- Vonage API Key and API Secret (found in Section 1.A).
- A Vonage virtual phone number capable of sending SMS.
- NestJS CLI installed (
npm install -g @nestjs/cli
). - Basic understanding of TypeScript, REST APIs, and webhooks.
git
installed.- ngrok installed and authenticated (Sign up for free account).
1. Setting Up the Vonage Account and Local Environment
Before writing code, configure your Vonage account and prepare your local development environment.
A. Vonage Account Configuration:
- Log in: Access your Vonage API Dashboard.
- API Credentials: Note down your
API Key
andAPI Secret
found on the main dashboard page. You'll need these later for environment variables (primarily for sending SMS, and for basic DLR verification). - Buy a Number: If you don't have one, navigate to
Numbers
->Buy Numbers
. Search for and purchase a number with SMS capabilities in your desired country. Note this number down. - Prepare for Delivery Receipts Webhook Configuration:
- Navigate to
Settings
in the left-hand menu. - Scroll down to the
SMS settings
section. - Locate the
Delivery receipts (DLR) webhooks
field. - Crucially: Vonage needs to send POST requests to a publicly accessible URL to deliver DLRs. For local development, we need a tool like
ngrok
to create this public URL for your local machine. We will set upngrok
in the next step and then come back here to paste the generated URL. - Ensure the dropdown next to the URL field is set to
POST
and the type isJSON
. You can leave the URL field blank for now or enter a temporary placeholder.
- Navigate to
B. Local Development Environment Setup (ngrok):
ngrok
creates a secure tunnel from the public internet to your local development machine.
- Start ngrok: Open your terminal and run the following command, replacing
3000
with the port your NestJS app will run on (NestJS default is 3000):ngrok http 3000
- Copy Forwarding URL:
ngrok
will display session information, including aForwarding
URL ending in.ngrok-free.app
or similar (e.g.,https://random-subdomain.ngrok-free.app
). Copy thehttps
version of this URL. This is your temporary public address. - Update Vonage DLR Webhook URL:
- Go back to your Vonage Dashboard ->
Settings
->SMS settings
. - Paste the copied
ngrok
Forwarding URL into theDelivery receipts (DLR) webhooks
field. - Append a specific path for your webhook endpoint that you will define in your NestJS application. For this guide, we'll use:
/api/webhooks/vonage/delivery-receipts
. - Your full URL in the Vonage settings should look like:
https://random-subdomain.ngrok-free.app/api/webhooks/vonage/delivery-receipts
. - Ensure
POST
andJSON
are selected next to the URL field. - Click
Save changes
.
- Go back to your Vonage Dashboard ->
2. Initializing the NestJS Project
Now, let's create the NestJS application.
- Create Project: Open your terminal, navigate to your desired parent directory, and run:
Choose your preferred package manager (npm or yarn) when prompted.
nest new vonage-dlr-handler
- Navigate to Project:
cd vonage-dlr-handler
- Install Dependencies:
- Config Module: For environment variables.
- Validation Pipes: For validating incoming data.
npm install @nestjs/config class-validator class-transformer # or yarn add @nestjs/config class-validator class-transformer
- Project Structure Overview:
src/
: Contains your application code.main.ts
: Entry point, bootstraps the application.app.module.ts
: Root module of the application.app.controller.ts
: Default example controller (we'll remove/replace this).app.service.ts
: Default example service (we'll remove/replace this).
.env
: (We will create this) Stores environment variables.nest-cli.json
,package.json
,tsconfig.json
, etc.: Configuration files.
Architectural Decision: We will use NestJS modules to organize features. We'll create a dedicated WebhookModule
to handle incoming webhooks from Vonage.
3. Configuring Environment Variables
Securely manage your Vonage credentials and other configurations.
-
Create
.env
file: In the root directory of your project, create a file named.env
. -
Add Variables: Add the following, replacing placeholders with your actual Vonage details:
# .env # Vonage API Credentials (Used for sending SMS and basic DLR verification) VONAGE_API_KEY=YOUR_VONAGE_API_KEY VONAGE_API_SECRET=YOUR_VONAGE_API_SECRET VONAGE_NUMBER=YOUR_VONAGE_VIRTUAL_NUMBER # Application Port PORT=3000 # Add any other config variables as needed
VONAGE_API_KEY
: Your key from the Vonage dashboard.VONAGE_API_SECRET
: Your secret from the Vonage dashboard.VONAGE_NUMBER
: The Vonage virtual number you purchased.PORT
: The port the application will listen on (should match the ngrok port).
-
Create
.env.example
: Copy.env
to.env.example
and remove the sensitive values. Commit.env.example
to git, but add.env
to your.gitignore
file to avoid committing secrets.# .gitignore node_modules dist .env
-
Integrate
ConfigModule
: Modify yoursrc/app.module.ts
to load environment variables globally:// src/app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { WebhookModule } from './webhook/webhook.module'; // We'll create this next @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, // Make ConfigModule available globally envFilePath: '.env', // Specify the env file path }), WebhookModule, // Import our feature module ], controllers: [], // Remove default AppController if not needed providers: [], // Remove default AppService if not needed }) export class AppModule {}
-
Update
main.ts
to use the PORT and Global Validation: Modifysrc/main.ts
to respect thePORT
environment variable and enable global validation pipes.// src/main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { ConfigService } from '@nestjs/config'; import { ValidationPipe } from '@nestjs/common'; // Import ValidationPipe async function bootstrap() { const app = await NestFactory.create(AppModule); const configService = app.get(ConfigService); const port = configService.get<number>('PORT') || 3000; // Enable global validation pipe to ensure all incoming request bodies // matching a DTO are automatically validated. app.useGlobalPipes(new ValidationPipe({ whitelist: true, // Strip properties not defined in DTO transform: true, // Automatically transform payloads to DTO instances forbidNonWhitelisted: true, // Throw error if extra properties are present })); // Optional: Add a global prefix for all routes if desired app.setGlobalPrefix('api'); // Matches the path in the webhook URL await app.listen(port); console.log(`Application is running on: ${await app.getUrl()}`); console.log('Webhook endpoint ready'); // Generic ready message } bootstrap();
- We added
ValidationPipe
globally to automatically validate incoming request bodies against our DTOs (Data Transfer Objects). - We added
app.setGlobalPrefix('api')
to match the/api/...
path structure used in the webhook URL.
- We added
4. Implementing the Webhook Handler
Create the module, controller, and service to receive and process the DLR webhooks.
-
Generate Webhook Module, Controller, and Service: Use the Nest CLI:
nest generate module webhook --flat nest generate controller webhook --flat nest generate service webhook --flat
--flat
prevents creating a dedicated directory for each. Adjust if you prefer nested structures.- This creates
webhook.module.ts
,webhook.controller.ts
, andwebhook.service.ts
in thesrc
directory (orsrc/webhook
if not using--flat
). EnsureWebhookModule
is imported inapp.module.ts
as shown previously.
-
Define the DLR Data Structure (DTO): Create a Data Transfer Object (DTO) to represent the expected payload from Vonage and apply validation rules. Create a file
src/webhook/dto/vonage-dlr.dto.ts
:// src/webhook/dto/vonage-dlr.dto.ts import { IsString, IsOptional, IsNotEmpty, IsDateString, IsEnum, Matches } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; // Optional: For Swagger documentation // Define possible statuses (adjust based on Vonage documentation if needed) export enum VonageSmsStatus { DELIVERED = 'delivered', EXPIRED = 'expired', FAILED = 'failed', REJECTED = 'rejected', ACCEPTED = 'accepted', // Intermediate state BUFFERED = 'buffered', // Intermediate state UNKNOWN = 'unknown', } export class VonageDlrDto { // Note on Naming: Vonage DLR payloads use kebab-case keys (e.g., 'network-code'). // While standard TypeScript/NestJS convention is camelCase (networkCode), // using kebab-case directly in the DTO simplifies mapping with class-transformer // (which handles it automatically). Using camelCase would require explicit // mapping decorators like @Type or manual mapping. This approach prioritizes // ease of initial setup. @IsString() @IsNotEmpty() @Matches(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/, { message: 'messageId must be a valid UUID' }) @ApiProperty({ description: 'The UUID of the message', example: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' }) messageId: string; @IsString() @IsNotEmpty() @ApiProperty({ description: 'Recipient phone number (E.164 format)', example: '14155550101' }) msisdn: string; @IsString() @IsNotEmpty() @ApiProperty({ description: 'Your Vonage virtual number (E.164 format)', example: '14155550100' }) to: string; // Vonage virtual number @IsString() @IsOptional() @ApiProperty({ required: false, description: 'Mobile network code', example: '310260' }) 'network-code'?: string; @IsString() @IsOptional() @ApiProperty({ required: false, description: 'Price charged per message segment', example: '0.00750000' }) price?: string; @IsEnum(VonageSmsStatus) @IsNotEmpty() @ApiProperty({ enum: VonageSmsStatus, description: 'Delivery status of the message', example: VonageSmsStatus.DELIVERED }) status: VonageSmsStatus; @IsDateString() // Vonage uses 'YYYY-MM-DD HH:MM:SS' @IsNotEmpty() @ApiProperty({ description: 'Timestamp when the status changed (UTC)', example: '2023-10-26 10:00:00' }) scts: string; // Status change timestamp @IsString() @IsNotEmpty() @ApiProperty({ description: 'Error code (0 for success)', example: '0' }) 'err-code': string; @IsString() @IsNotEmpty() @ApiProperty({ description: 'Your Vonage API key', example: 'abcdef01' }) 'api-key': string; // Your Vonage API key (can be used for basic verification) @IsDateString() // Vonage uses 'YYYY-MM-DD HH:MM:SS' @IsNotEmpty() @ApiProperty({ description: 'Timestamp when the message was submitted (UTC)', example: '2023-10-26 09:59:00' }) 'message-timestamp': string; // Original message submission timestamp // Add other potential fields if needed, marking them @IsOptional() // e.g., client-ref, latency, etc. @IsString() @IsOptional() @ApiProperty({ required: false, description: 'Client reference if provided during sending', example: 'my-internal-ref-123' }) 'client-ref'?: string; }
- Why DTOs? They provide clear structure, enable automatic validation via
class-validator
, and improve type safety. - Added
@ApiProperty
for potential Swagger integration. - Added a comment explaining the kebab-case vs camelCase choice.
- Why DTOs? They provide clear structure, enable automatic validation via
-
Implement the Webhook Controller: Define the endpoint that matches the URL configured in Vonage (
/api/webhooks/vonage/delivery-receipts
).// src/webhook/webhook.controller.ts import { Controller, Post, Body, HttpCode, HttpStatus, Logger, ValidationPipe, UsePipes } from '@nestjs/common'; import { WebhookService } from './webhook.service'; import { VonageDlrDto } from './dto/vonage-dlr.dto'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; // Optional: For Swagger @ApiTags('Webhooks') // Optional: Group in Swagger @Controller('webhooks/vonage') // Base path (prefixed with 'api' globally) export class WebhookController { private readonly logger = new Logger(WebhookController.name); constructor(private readonly webhookService: WebhookService) {} @Post('delivery-receipts') // Full path: /api/webhooks/vonage/delivery-receipts @HttpCode(HttpStatus.OK) // Respond with 200 OK on success @ApiOperation({ summary: 'Handle Vonage SMS Delivery Receipts (DLR)' }) // Optional: Swagger @ApiResponse({ status: 200, description: 'DLR received successfully or processed with internal error (logged).' }) @ApiResponse({ status: 400, description: 'Bad Request - Invalid DLR payload structure.' }) // Global validation pipe is active, no need for @UsePipes here unless overriding async handleDeliveryReceipt(@Body() dlrData: VonageDlrDto): Promise<string> { this.logger.log(`Received Vonage DLR for message ${dlrData.messageId}`); // Avoid logging the full DLR data in production if it contains sensitive info or is too verbose // this.logger.debug(`Full DLR Payload: ${JSON.stringify(dlrData)}`); try { await this.webhookService.processDeliveryReceipt(dlrData); // Vonage expects a 2xx response to acknowledge receipt. // No specific response body is required by Vonage. return 'DLR received successfully.'; } catch (error) { this.logger.error(`Error processing DLR for message ${dlrData?.messageId}: ${error.message}`, error.stack); // Even if processing fails internally, acknowledge receipt to Vonage (200 OK) // to prevent unnecessary retries unless specific retry logic is desired. // If you want Vonage to retry (e.g., temporary DB issue), throw an HttpException // (like `throw new ServiceUnavailableException('Internal processing failed, please retry');`) // For now, we respond OK but log the error. return 'DLR received, processing error occurred.'; } } }
@Controller('webhooks/vonage')
: Sets the base path relative to the global prefix/api
.@Post('delivery-receipts')
: Defines the specific route.@Body() dlrData: VonageDlrDto
: Injects the parsed and validated request body.@HttpCode(HttpStatus.OK)
: Ensures a200 OK
status is sent back to Vonage upon successful handling before thereturn
statement is evaluated, unless an exception is thrown.- Logging: Essential for observing incoming requests. Adjusted log to be less verbose by default.
-
Implement the Webhook Service: Contains the business logic for handling the DLR data.
// src/webhook/webhook.service.ts import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { VonageDlrDto, VonageSmsStatus } from './dto/vonage-dlr.dto'; @Injectable() export class WebhookService { private readonly logger = new Logger(WebhookService.name); private readonly expectedApiKey: string; constructor(private readonly configService: ConfigService) { this.expectedApiKey = this.configService.get<string>('VONAGE_API_KEY'); if (!this.expectedApiKey) { this.logger.error('VONAGE_API_KEY is not configured in environment variables!'); // Consider throwing an error during startup in a real app } } async processDeliveryReceipt(dlrData: VonageDlrDto): Promise<void> { this.logger.log(`Processing DLR for message: ${dlrData.messageId}, Status: ${dlrData.status}`); // **Basic Security Check:** Verify the API key matches yours. // Note: This is a weak check as the API key is sent in the payload itself. // Anyone intercepting the payload can see the key. Robust Vonage webhooks // (like Messages API) use JWT signatures. Standard DLRs lack this. // Use the safer check: handle missing api-key property. if (!dlrData['api-key'] || dlrData['api-key'] !== this.expectedApiKey) { const receivedKeySnippet = dlrData['api-key'] ? `${dlrData['api-key'].substring(0, 4)}...` : 'MISSING'; const expectedKeySnippet = this.expectedApiKey ? `${this.expectedApiKey.substring(0, 4)}...` : 'MISSING'; this.logger.warn(`Received DLR with invalid or mismatched API key. Expected: ${expectedKeySnippet}, Received: ${receivedKeySnippet} for message ${dlrData.messageId}`); // Decide how to handle - log and ignore, or throw an error? // Throwing an error (e.g., 401/403) might be appropriate if you consider this invalid. // However, Vonage might retry on non-2xx. For now, log and continue. // Consider: throw new UnauthorizedException('Invalid API key provided in DLR payload'); // If you throw, catch it appropriately in the controller or use an Exception Filter. } // **Core Logic:** // Here, you would typically: // 1. Find the original message record in your database using `dlrData.messageId`. // 2. Update the message status based on `dlrData.status` and `dlrData.scts`. // 3. Log the status change details. // 4. Trigger any necessary downstream actions (e.g., notify users, update analytics). console.log(`---> DLR Processed: Message ${dlrData.messageId} status updated to ${dlrData.status} at ${dlrData.scts}`); // Example: Simulate DB update or further processing await new Promise(resolve => setTimeout(resolve, 50)); // Simulate async work if (dlrData.status === VonageSmsStatus.FAILED || dlrData.status === VonageSmsStatus.REJECTED) { this.logger.warn(`Message ${dlrData.messageId} failed/rejected. Error code: ${dlrData['err-code']}. Status: ${dlrData.status}`); // Implement specific handling for failures (e.g., notify support, attempt retry via different route) } // No return value needed if successful } }
- Injects
ConfigService
to retrieve theVONAGE_API_KEY
. - Includes placeholder comments for database integration.
- Logs key information.
- Updated the API key check to handle missing keys and provide better logging.
- Injects
5. Error Handling and Logging
NestJS provides built-in mechanisms, but let's refine them.
- Validation Errors: The global
ValidationPipe
automatically throws aBadRequestException
(400) if the incoming data doesn't match theVonageDlrDto
. This response is sent back to Vonage. Since validation failures usually mean the request structure is wrong, a 400 is appropriate, and Vonage typically won't retry these. - Processing Errors: The
try...catch
block in the controller logs internal processing errors but still returns200 OK
to Vonage. This acknowledges receipt and prevents Vonage from retrying indefinitely due to bugs in your processing logic. If you want Vonage to retry (e.g., temporary database outage), you should throw anHttpException
(e.g.,ServiceUnavailableException
- 503) from the controller or service. - Logging: We use NestJS's built-in
Logger
. For production, configure a more robust logging strategy:- Use different log levels (
log
,debug
,warn
,error
). - Consider structured logging (JSON format).
- Send logs to a centralized logging service (e.g., Datadog, Sentry, ELK stack, AWS CloudWatch Logs). NestJS logging can be customized or replaced.
- Use different log levels (
6. Security Considerations
Webhook endpoints are publicly accessible, so security is paramount.
- HTTPS: Always use HTTPS for your webhook endpoint in production.
ngrok
provides HTTPS locally. Ensure your production deployment environment (e.g., via load balancer, reverse proxy) enforces HTTPS. - Input Validation: Handled effectively by the
VonageDlrDto
and the globalValidationPipe
. This is crucial to prevent processing malformed data. - Source Verification (Basic & Limitations):
- The API key check implemented in
WebhookService
provides minimal verification. As noted, the key is part of the payload itself. - Crucial Limitation: Standard Vonage SMS DLR webhooks do not support cryptographic signature verification (like JWT or HMAC signatures found in other Vonage APIs like the Messages API or Verify API). This means you cannot cryptographically prove the request genuinely originated from Vonage.
- Mitigation Strategies:
- IP Whitelisting: If Vonage publishes a stable list of IP addresses for webhook origins (check their documentation), configure your firewall or load balancer to only allow requests from those IPs. This adds a layer of protection but can be brittle if IPs change without notice.
- Secret in URL (Less Secure): Include a hard-to-guess secret token in the webhook URL path configured in Vonage (e.g.,
/api/webhooks/vonage/dlr/SOME_RANDOM_SECRET_TOKEN
). Check this token in your controller. This is security through obscurity and less robust than signatures. - Use Vonage Messages API: If feasible for your use case, consider sending SMS via the Vonage Messages API. Its status webhooks do support JWT signature verification, offering much stronger security.
- The API key check implemented in
- Rate Limiting: Protect your endpoint from abuse or accidental loops by implementing rate limiting. The
@nestjs/throttler
module is excellent for this.Configure it innpm install @nestjs/throttler # or yarn add @nestjs/throttler
app.module.ts
:// src/app.module.ts import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; import { APP_GUARD } from '@nestjs/core'; // ... other imports (ConfigModule, WebhookModule) @Module({ imports: [ // ... ConfigModule, WebhookModule ThrottlerModule.forRoot([{ ttl: 60000, // Time-to-live (milliseconds) - 1 minute limit: 100, // Max requests per TTL per source IP }]), ], // ... controllers, providers from before providers: [ // Apply Throttler globally to all endpoints { provide: APP_GUARD, useClass: ThrottlerGuard, }, // ... other providers ], }) export class AppModule {}
- Denial of Service (DoS) / Resource Exhaustion: Ensure your
processDeliveryReceipt
logic is efficient. Avoid long-running synchronous operations. Offload heavy or potentially slow tasks (e.g., complex database updates, third-party API calls) to background job queues (like BullMQ, RabbitMQ) managed by separate workers.
7. Testing the Implementation
Thorough testing ensures reliability.
A. Unit Testing: Test the service logic in isolation.
-
Modify Service Test: Update
src/webhook/webhook.service.spec.ts
:// src/webhook/webhook.service.spec.ts import { Test, TestingModule } from '@nestjs/testing'; import { WebhookService } from './webhook.service'; import { ConfigService } from '@nestjs/config'; import { VonageDlrDto, VonageSmsStatus } from './dto/vonage-dlr.dto'; import { Logger, UnauthorizedException } from '@nestjs/common'; // Mock ConfigService const mockConfigService = { get: jest.fn((key: string) => { if (key === 'VONAGE_API_KEY') { return 'test-api-key'; } return null; }), }; // Mock Logger methods const mockLogger = { log: jest.fn(), warn: jest.fn(), error: jest.fn(), setContext: jest.fn(), // Add if Logger requires context setting }; describe('WebhookService', () => { let service: WebhookService; beforeEach(async () => { // Provide the mock logger implementation jest.spyOn(Logger.prototype, 'log').mockImplementation(mockLogger.log); jest.spyOn(Logger.prototype, 'warn').mockImplementation(mockLogger.warn); jest.spyOn(Logger.prototype, 'error').mockImplementation(mockLogger.error); const module: TestingModule = await Test.createTestingModule({ providers: [ WebhookService, { provide: ConfigService, useValue: mockConfigService }, // No need to provide Logger if using prototype spy ], }).compile(); service = module.get<WebhookService>(WebhookService); // Reset mocks before each test jest.clearAllMocks(); // Mock console.log if needed for assertions jest.spyOn(console, 'log').mockImplementation(() => {}); }); afterEach(() => { // Restore console.log mock if used jest.restoreAllMocks(); }); it('should be defined', () => { expect(service).toBeDefined(); }); const baseDlr: Partial<VonageDlrDto> = { messageId: 'valid-uuid-1111-4444-8888-1234567890ab', msisdn: '14155550101', to: '14155550100', scts: '2023-10-26 10:00:00', 'err-code': '0', 'message-timestamp': '2023-10-26 09:59:00', 'api-key': 'test-api-key', // Correct key }; it('should process a valid delivered DLR', async () => { const validDlr: VonageDlrDto = { ...baseDlr, status: VonageSmsStatus.DELIVERED, 'network-code': '310260', price: '0.0075', } as VonageDlrDto; await service.processDeliveryReceipt(validDlr); expect(mockLogger.log).toHaveBeenCalledWith( expect.stringContaining(`Processing DLR for message: ${validDlr.messageId}, Status: ${validDlr.status}`) ); expect(mockLogger.warn).not.toHaveBeenCalled(); // No warnings for valid key/status // Add more assertions based on your actual processing logic (e.g., mock db calls) expect(console.log).toHaveBeenCalledWith(expect.stringContaining('DLR Processed')); // Check console log if needed }); it('should log a warning for mismatched API key', async () => { const invalidApiKeyDlr: VonageDlrDto = { ...baseDlr, messageId: 'valid-uuid-2222-4444-8888-1234567890ac', status: VonageSmsStatus.DELIVERED, scts: '2023-10-26 10:01:00', 'api-key': 'wrong-api-key', // Mismatched key } as VonageDlrDto; await service.processDeliveryReceipt(invalidApiKeyDlr); expect(mockLogger.warn).toHaveBeenCalledWith( expect.stringContaining('Received DLR with invalid or mismatched API key') ); expect(mockLogger.warn).toHaveBeenCalledWith( expect.stringContaining('Expected: test..., Received: wron...') ); }); it('should log a warning if API key is missing', async () => { const missingApiKeyDlr = { ...baseDlr, messageId: 'valid-uuid-mkey-4444-8888-1234567890af', status: VonageSmsStatus.DELIVERED, scts: '2023-10-26 10:03:00', } as Partial<VonageDlrDto>; delete missingApiKeyDlr['api-key']; // Remove the key await service.processDeliveryReceipt(missingApiKeyDlr as VonageDlrDto); expect(mockLogger.warn).toHaveBeenCalledWith( expect.stringContaining('Received DLR with invalid or mismatched API key') ); expect(mockLogger.warn).toHaveBeenCalledWith( expect.stringContaining('Expected: test..., Received: MISSING') ); }); it('should log a warning for failed status', async () => { const failedDlr: VonageDlrDto = { ...baseDlr, messageId: 'valid-uuid-fail-4444-8888-1234567890ae', status: VonageSmsStatus.FAILED, scts: '2023-10-26 10:02:00', 'err-code': '6', // Example error code for failure } as VonageDlrDto; await service.processDeliveryReceipt(failedDlr); expect(mockLogger.warn).toHaveBeenCalledWith( expect.stringContaining(`Message ${failedDlr.messageId} failed/rejected. Error code: ${failedDlr['err-code']}. Status: ${failedDlr.status}`) ); }); // Add more tests for other statuses, edge cases, error handling within the service logic });
- Updated the test setup (
beforeEach
,afterEach
) to correctly mock the logger andconsole.log
. - Added tests for mismatched API key, missing API key, and failed status.
- Included assertions against the mocked logger methods.
- Updated the test setup (
B. Integration Testing: Test the controller and service together.
- Use NestJS's testing utilities (
@nestjs/testing
) to create a testing module that includes theWebhookModule
. - Use a tool like
supertest
to send mock HTTP POST requests to the/api/webhooks/vonage/delivery-receipts
endpoint with various valid and invalid DLR payloads. - Assert the HTTP status code (should be 200 for valid/processed, 400 for invalid DTO).
- Spy on the
WebhookService.processDeliveryReceipt
method to ensure it's called with the correct data. - Check logs or mock database interactions if applicable.
C. End-to-End (E2E) Testing:
- Start Application: Run your NestJS application locally (
npm run start:dev
). - Start ngrok: Ensure
ngrok http 3000
(or your port) is running. - Configure Vonage: Double-check the DLR webhook URL in your Vonage settings points to your
ngrok
URL +/api/webhooks/vonage/delivery-receipts
. - Send Test SMS: Use the Vonage API (via curl, Postman, another script, or a simple sending function in your app using
@vonage/server-sdk
) to send an SMS message from your configured Vonage number to a real test phone number you have access to. - Observe:
- Watch the
ngrok
console for incoming POST requests to your webhook path. - Check your NestJS application console logs for the "Received Vonage DLR..." and "Processing DLR..." messages.
- Verify the status logged matches the actual delivery status on your test phone (e.g.,
delivered
). - Test failure scenarios if possible (e.g., sending to an invalid number might generate a
failed
orrejected
DLR).
- Watch the
- Check Vonage Dashboard: The Vonage dashboard often provides logs or status indicators for webhook attempts, which can help debug configuration issues.
8. Deployment Considerations
- Environment: Deploy to a suitable environment (e.g., AWS EC2/ECS/Lambda, Google Cloud Run/Functions, Heroku, DigitalOcean).
- HTTPS: Ensure your production endpoint is served over HTTPS. Use a reverse proxy (like Nginx or Caddy) or a load balancer to handle SSL termination.
- Environment Variables: Use your hosting provider's mechanism for securely managing environment variables (e.g., AWS Secrets Manager, Parameter Store, environment variable configuration in platform settings). Do not commit
.env
files with production secrets. - Permanent Webhook URL: Update the Vonage DLR webhook URL in the Vonage dashboard to point to your permanent production URL, not the temporary
ngrok
one. - Monitoring & Alerting: Set up monitoring for application health, performance (CPU, memory), and error rates. Configure alerts for critical errors or downtime (e.g., using CloudWatch Alarms, Datadog Monitors, Sentry).
- Scalability: Consider your expected webhook volume. Ensure your deployment strategy can handle the load (e.g., auto-scaling groups, serverless scaling). If processing is heavy, use background job queues.
- Logging: Configure production-grade logging aggregation and analysis (as mentioned in Section 5).
Conclusion
You have successfully implemented a NestJS webhook endpoint to receive and process Vonage SMS Delivery Receipts. This allows your application to track message statuses effectively. Remember to implement robust error handling, security measures (especially source verification limitations), and thorough testing before deploying to production. Further enhancements could include storing DLR data in a database, triggering notifications based on status changes, and integrating with analytics platforms.