Learn how to implement Vonage SMS delivery receipts (DLR) in your NestJS application and track message delivery status in real-time. This comprehensive guide walks you through building a production-ready webhook endpoint to receive delivery status updates from Vonage's SMS API.
Knowing whether your SMS messages are delivered is essential for customer communication, troubleshooting failed deliveries, and tracking campaign performance. This tutorial demonstrates how to receive and process Vonage delivery receipts using NestJS webhooks, giving you real-time visibility into message delivery status.
Technologies Used:
Node.js: The JavaScript runtime environment. Node.js v22 LTS is recommended for production use (active LTS until October 2025, maintained until April 2027).[1]
NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications. Current version: v11.1.6 (January 2025). Chosen for its modular architecture, dependency injection, and TypeScript support.[2]
Vonage SMS API: Used for sending SMS and providing DLR webhooks.
@vonage/server-sdk: The official Vonage Node.js SDK (current version: v3.24.1). While this guide focuses on receiving DLRs (which doesn't directly use the SDK), use this SDK within your NestJS application to send the initial SMS messages that generate these DLRs.[3]
@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:
Text
+-----------------+ +---------------------+ +------------------------+ +-----------------+
| 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 |
+--------------------------------+
Understanding Vonage Delivery Receipt Status Codes
Vonage sends different delivery receipt statuses depending on whether your SMS was delivered successfully or encountered an error. Understanding these status codes helps you handle delivery failures and track message performance:
Status
Meaning
Action Required
delivered
Message successfully delivered to recipient
Log success, update analytics
failed
Message delivery failed
Retry logic, notify support team
rejected
Message rejected by carrier
Check number validity, review content
expired
Message expired before delivery
Consider shorter validity period
accepted
Message accepted by Vonage (intermediate)
Wait for final status
buffered
Message queued by carrier (intermediate)
Wait for final status
unknown
Status cannot be determined
Log for investigation
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 (v22 LTS recommended, minimum v18+) and npm/yarn installed.[1]
Setting Up Your Vonage Account for Delivery Receipts
Before building your webhook handler, you need to configure your Vonage account to send delivery receipts to your application and set up a local development environment for testing.
API Credentials: Note your API Key and API Secret from 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.
Prepare for Delivery Receipts Webhook Configuration:
Navigate to Settings in the left-hand menu.
Scroll to the SMS settings section.
Locate the Delivery receipts (DLR) webhooks field.
Crucially: Vonage needs a publicly accessible URL to send POST requests for DLR delivery. For local development, use a tool like ngrok to create this public URL for your local machine. Set up ngrok in the next step, then return here to paste the generated URL.
Ensure the dropdown next to the URL field is set to POST and the type is JSON. Leave the URL field blank for now or enter a temporary placeholder.
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):
Bash
ngrok http 3000
Copy Forwarding URL:ngrok displays session information, including a Forwarding URL ending in .ngrok-free.app or similar (e.g., https://random-subdomain.ngrok-free.app). Copy the https 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 the Delivery receipts (DLR) webhooks field.
Append a specific path for your webhook endpoint that you will define in your NestJS application. For this guide, 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 and JSON are selected next to the URL field.
Click Save changes.
Creating Your NestJS Project for Vonage Webhooks
Create the NestJS application.
Create Project: Open your terminal, navigate to your desired parent directory, and run:
Bash
nest new vonage-dlr-handler
Choose your preferred package manager (npm or yarn) when prompted.
Architectural Decision: Use NestJS modules to organize features. Create a dedicated WebhookModule to handle incoming webhooks from Vonage.
Configuring Vonage Credentials in NestJS
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
# .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.
Text
# .gitignore
node_modules
dist
.env
Integrate ConfigModule: Modify your src/app.module.ts to load environment variables globally:
TypeScript
// src/app.module.tsimport { Module } from'@nestjs/common';
import { ConfigModule } from'@nestjs/config';
import { WebhookModule } from'./webhook/webhook.module'; // You'll create this next@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // Make ConfigModule available globallyenvFilePath: '.env', // Specify the env file path
}),
WebhookModule, // Import your feature module
],
controllers: [], // Remove default AppController if not neededproviders: [], // Remove default AppService if not needed
})
exportclassAppModule {}
Update main.ts to use the PORT and Global Validation: Modify src/main.ts to respect the PORT environment variable and enable global validation pipes.
TypeScript
// src/main.tsimport { NestFactory } from'@nestjs/core';
import { AppModule } from'./app.module';
import { ConfigService } from'@nestjs/config';
import { ValidationPipe } from'@nestjs/common'; // Import ValidationPipeasyncfunctionbootstrap() {
const app = awaitNestFactory.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(newValidationPipe({
whitelist: true, // Strip properties not defined in DTOtransform: true, // Automatically transform payloads to DTO instancesforbidNonWhitelisted: 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 URLawait app.listen(port);
console.log(`Application is running on: ${await app.getUrl()}`);
console.log('Webhook endpoint ready'); // Generic ready message
}
bootstrap();
The global ValidationPipe automatically validates incoming request bodies against your DTOs (Data Transfer Objects).
app.setGlobalPrefix('api') matches the /api/… path structure used in the webhook URL.
Building the Vonage DLR Webhook Endpoint
Create the module, controller, and service to receive and process the DLR webhooks.
Generate Webhook Module, Controller, and Service: Use the Nest CLI:
--flat prevents creating a dedicated directory for each. Adjust if you prefer nested structures.
This creates webhook.module.ts, webhook.controller.ts, and webhook.service.ts in the src directory (or src/webhook if not using --flat). Ensure WebhookModule is imported in app.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:
TypeScript
// src/webhook/dto/vonage-dlr.dto.tsimport { IsString, IsOptional, IsNotEmpty, IsDateString, IsEnum, Matches } from'class-validator';
import { ApiProperty } from'@nestjs/swagger'; // Optional: For Swagger documentation// Define possible statuses based on Vonage documentation[4]exportenumVonageSmsStatus {
DELIVERED = 'delivered',
EXPIRED = 'expired',
FAILED = 'failed',
REJECTED = 'rejected',
ACCEPTED = 'accepted', // Intermediate stateBUFFERED = 'buffered', // Intermediate stateUNKNOWN = 'unknown',
}
exportclassVonageDlrDto {
// 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; // The number the message was sent to (E.164 format)[4]@IsString()
@IsNotEmpty()
@ApiProperty({ description: 'Your Vonage virtual number (E.164 format)', example: '14155550100' })
to: string; // The SenderID you set in the from field[4]@IsString()
@IsOptional()
@ApiProperty({ required: false, description: 'Mobile Country Code + Mobile Network Code (MCCMNC) of the carrier', example: '310260' })
'network-code'?: string; // The MCCMNC of the carrier[4]@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;
@IsString() // Vonage uses 'YYMMDDHHMM' or 'YYYY-MM-DD HH:MM:SS' format[4]@IsNotEmpty()
@ApiProperty({ description: 'When the DLR was received from the carrier (YYMMDDHHMM or YYYY-MM-DD HH:MM:SS format)', example: '2023-10-26 10:00:00' })
scts: string; // Status change timestamp - when DLR was received from carrier[4]@IsString()
@IsNotEmpty()
@ApiProperty({ description: 'Error code (0 for success, non-zero for errors)', example: '0' })
'err-code': string; // The status of the request (0 for success, non-zero for errors)[4]@IsString()
@IsNotEmpty()
@ApiProperty({ description: 'Your Vonage API key (useful when multiple accounts send webhooks to same endpoint)', example: 'abcdef01' })
'api-key': string; // The API key that sent the SMS[4]@IsString() // Vonage timestamp format@IsNotEmpty()
@ApiProperty({ description: 'When Vonage started pushing this DLR to your webhook endpoint', example: '2023-10-26 09:59:00' })
'message-timestamp': string; // When Vonage started pushing this DLR[4]// Additional optional fields from Vonage DLR specification[4]@IsString()
@IsOptional()
@ApiProperty({ required: false, description: 'Client reference if provided during sending', example: 'my-internal-ref-123' })
'client-ref'?: string;
@IsString()
@IsOptional()
@ApiProperty({ required: false, description: 'Unix timestamp representation of message-timestamp', example: '1698314340' })
timestamp?: string; // Unix timestamp representation[4]@IsString()
@IsOptional()
@ApiProperty({ required: false, description: 'Random string for signature validation (if signatures enabled)', example: 'abc123xyz' })
nonce?: string; // Used for signature validation if enabled[4]// Concatenated message fields (if applicable)[4]@IsOptional()
@ApiProperty({ required: false, description: 'True if this is a concatenated message' })
concat?: string;
@IsString()
@IsOptional()
@ApiProperty({ required: false, description: 'Transaction reference shared by all parts of concatenated message' })
'concat-ref'?: string;
@IsString()
@IsOptional()
@ApiProperty({ required: false, description: 'Number of parts in concatenated message' })
'concat-total'?: string;
@IsString()
@IsOptional()
@ApiProperty({ required: false, description: 'Number of this part in message (counting starts at 1)' })
'concat-part'?: 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.
Enhanced field documentation with descriptions from Vonage DLR webhook specification.[4]
Implement the Webhook Controller: Define the endpoint that matches the URL configured in Vonage (/api/webhooks/vonage/delivery-receipts).
TypeScript
// src/webhook/webhook.controller.tsimport { 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)exportclassWebhookController {
privatereadonly logger = newLogger(WebhookController.name);
constructor(privatereadonlywebhookService: 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 overridingasynchandleDeliveryReceipt(@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 {
awaitthis.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 a 200 OK status is sent back to Vonage upon successful handling before the return 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.
TypeScript
// src/webhook/webhook.service.tsimport { Injectable, Logger, UnauthorizedException } from'@nestjs/common';
import { ConfigService } from'@nestjs/config';
import { VonageDlrDto, VonageSmsStatus } from'./dto/vonage-dlr.dto';
@Injectable()
exportclassWebhookService {
privatereadonly logger = newLogger(WebhookService.name);
privatereadonlyexpectedApiKey: string;
constructor(privatereadonlyconfigService: 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
}
}
asyncprocessDeliveryReceipt(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 processingawaitnewPromise(resolve =>setTimeout(resolve, 50)); // Simulate async workif (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 the VONAGE_API_KEY.
Includes placeholder comments for database integration.
Logs key information.
Updated the API key check to handle missing keys and provide better logging.
Handling Errors in Vonage Delivery Receipt Webhooks
NestJS provides built-in mechanisms, but let's refine them.
Validation Errors: The global ValidationPipe automatically throws a BadRequestException (400) if the incoming data doesn't match the VonageDlrDto. 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 returns 200 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 an HttpException (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.
Securing Your Vonage Webhook Endpoint
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 global ValidationPipe. 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.
Rate Limiting: Protect your endpoint from abuse or accidental loops by implementing rate limiting. The @nestjs/throttler module is excellent for this.
Bash
npm install @nestjs/throttler
# or
yarn add @nestjs/throttler
Configure it in app.module.ts:
TypeScript
// src/app.module.tsimport { ThrottlerModule, ThrottlerGuard } from'@nestjs/throttler';
import { APP_GUARD } from'@nestjs/core';
// ... other imports (ConfigModule, WebhookModule)@Module({
imports: [
// ... ConfigModule, WebhookModuleThrottlerModule.forRoot([{
ttl: 60000, // Time-to-live (milliseconds) - 1 minutelimit: 100, // Max requests per TTL per source IP
}]),
],
// ... controllers, providers from beforeproviders: [
// Apply Throttler globally to all endpoints
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
// ... other providers
],
})
exportclassAppModule {}
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.
Testing Your Vonage SMS Delivery Receipt Handler
Thorough testing ensures reliability.
A. Unit Testing: Test the service logic in isolation.
Modify Service Test: Update src/webhook/webhook.service.spec.ts:
TypeScript
// src/webhook/webhook.service.spec.tsimport { 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 ConfigServiceconst mockConfigService = {
get: jest.fn((key: string) => {
if (key === 'VONAGE_API_KEY') {
return'test-api-key';
}
returnnull;
}),
};
// Mock Logger methodsconst mockLogger = {
log: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
setContext: jest.fn(), // Add if Logger requires context setting
};
describe('WebhookService', () => {
letservice: 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);
constmodule: TestingModule = awaitTest.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();
});
constbaseDlr: 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 () => {
constvalidDlr: VonageDlrDto = {
...baseDlr,
status: VonageSmsStatus.DELIVERED,
'network-code': '310260',
price: '0.0075',
} asVonageDlrDto;
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 () => {
constinvalidApiKeyDlr: 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
} asVonageDlrDto;
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',
} asPartial<VonageDlrDto>;
delete missingApiKeyDlr['api-key']; // Remove the keyawait service.processDeliveryReceipt(missingApiKeyDlr asVonageDlrDto);
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 () => {
constfailedDlr: 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
} asVonageDlrDto;
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 and console.log.
Added tests for mismatched API key, missing API key, and failed status.
Included assertions against the mocked logger methods.
B. Integration Testing: Test the controller and service together.
Use NestJS's testing utilities (@nestjs/testing) to create a testing module that includes the WebhookModule.
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: Verify 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 or rejected DLR).
Check Vonage Dashboard: The Vonage dashboard often provides logs or status indicators for webhook attempts, which can help debug configuration issues.
Deploying Your NestJS Vonage Webhook to Production
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. 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.
Frequently Asked Questions (FAQ)
How do Vonage delivery receipts work?
Vonage delivery receipts (DLRs) are HTTP webhook callbacks sent from Vonage to your server when an SMS message's delivery status changes. After you send an SMS through Vonage, carriers report back the delivery status (delivered, failed, rejected, etc.), and Vonage forwards this information to your configured webhook endpoint as a POST request.
What is the difference between intermediate and final DLR statuses?
Intermediate statuses like accepted and buffered indicate the message is in transit. accepted means Vonage has accepted the message, while buffered means a carrier has queued it. Final statuses like delivered, failed, rejected, and expired represent the terminal state of the message and won't change further.
How do I handle failed SMS deliveries in NestJS?
When you receive a DLR with status failed or rejected, check the err-code field for the specific error reason. Common handling strategies include: logging the failure for analytics, implementing retry logic with exponential backoff, notifying your support team for persistent failures, and validating recipient numbers before sending future messages.
Can I test Vonage webhooks locally without deploying?
Yes, use ngrok to create a public tunnel to your local NestJS application. Run ngrok http 3000 (or your port), copy the HTTPS forwarding URL, and configure it in your Vonage dashboard settings. This allows Vonage to send DLR webhooks to your local development environment.
How do I secure my Vonage webhook endpoint?
Vonage SMS DLR webhooks don't support cryptographic signatures, so implement these security measures: verify the api-key field matches your Vonage API key, use HTTPS for all webhook URLs, implement rate limiting with @nestjs/throttler, consider IP whitelisting if Vonage publishes stable IP ranges, and validate all incoming data with DTOs and class-validator.
What happens if my webhook endpoint is temporarily unavailable?
Vonage will retry sending DLR webhooks if your endpoint returns a non-2xx status code or times out. However, retry behavior has limits. To handle temporary outages, ensure your endpoint responds quickly (under 10 seconds), return 200 OK even for internal processing errors (log them instead), and implement idempotency to handle duplicate DLRs safely.
How do I store delivery receipts in a database?
In the WebhookService.processDeliveryReceipt method, add database logic using TypeORM, Prisma, or Mongoose. Store the messageId, status, scts timestamp, err-code, and recipient msisdn. Create a Message model with fields for tracking status changes over time, and update the status when DLRs arrive.
Can I receive DLRs for messages sent through different Vonage accounts?
Yes. The DLR payload includes an api-key field identifying which Vonage account sent the message. Use this field to route DLRs to the appropriate handler if you manage multiple Vonage accounts through a single webhook endpoint.
What Node.js and NestJS versions should I use?
Use Node.js v22 LTS (active support until October 2025) with NestJS v11.1.6 for production deployments. Minimum requirement is Node.js v18+ for NestJS 11 compatibility. These versions provide long-term stability and security updates.[1][2]
How do I troubleshoot webhook delivery issues?
Check these common issues: verify the webhook URL in Vonage dashboard settings is correct and uses HTTPS, ensure your endpoint returns 200 OK status codes, check ngrok is running for local development, review NestJS application logs for validation errors, verify the global validation pipe is configured correctly, and check Vonage dashboard logs for webhook delivery attempts and errors.
Set up a webhook endpoint in your NestJS application to receive DLRs. Configure your Vonage account to send POST requests to this endpoint. Use ngrok for local development to create a publicly accessible URL for your webhook.
DLRs are real-time status updates from Vonage about the delivery status of your SMS messages. They provide information about whether a message was delivered, failed, or expired, enabling better communication and troubleshooting.
NestJS provides a structured, scalable, and maintainable environment for building server-side applications. Its modular architecture, dependency injection, and TypeScript support make it ideal for handling webhook integrations.
Use ngrok during local development to expose your local server to the internet, as Vonage requires a publicly accessible URL for webhooks. For production, deploy to a server and use a permanent URL.
Create a DTO (Data Transfer Object) representing the DLR payload structure. Use class-validator and class-transformer to validate and transform incoming webhook data. Enable a global ValidationPipe in your NestJS application.
The Vonage SMS API is used for sending SMS messages and providing delivery receipt webhooks. You'll typically use the @vonage/server-sdk within your NestJS application to send the initial messages.
Create a .env file in your project's root directory. Store your VONAGE_API_KEY, VONAGE_API_SECRET, VONAGE_NUMBER, and other configurations in this file. Use @nestjs/config module to load these variables into your application.
These libraries help validate incoming webhook data against a defined DTO (Data Transfer Object) and transform the data into the DTO's format, improving type safety and preventing errors.
Logging helps track incoming requests, monitor the status of message deliveries, identify potential issues, and debug errors. Use different log levels for varying degrees of detail.
While standard DLRs lack cryptographic signature verification, you can use techniques like IP whitelisting (if available from Vonage) and secrets in the URL path to improve security, but be aware of their limitations. Use HTTPS in production and consider rate limiting.
Use unit tests to test your service logic, integration tests for combined controller-service behavior, and end-to-end tests to verify the full flow using ngrok, a real phone number, and actual SMS messages.
Use HTTPS, manage environment variables securely, update the webhook URL in Vonage settings to the permanent production URL, and implement monitoring, alerting, and scaling strategies.
Use a try-catch block to handle errors. Log all errors and return a 200 OK to Vonage to acknowledge receipt even on processing failure, unless you specifically want Vonage to retry (in which case, throw an HttpException). Consider using background job queues for heavy processing tasks.
Log the failure details, including the error code. Implement specific error handling based on your application needs, such as notifying support, attempting retries, or marking the message as permanently failed.
Use HTTPS, validate incoming DLRs with DTOs, implement IP whitelisting and rate limiting. For stronger security, consider using Vonage Messages API which supports JWT verification for webhooks, if your use-case allows it.
Learn how to implement Vonage SMS delivery receipts (DLR) in your NestJS application and track message delivery status in real-time. This comprehensive guide walks you through building a production-ready webhook endpoint to receive delivery status updates from Vonage's SMS API.
Knowing whether your SMS messages are delivered is essential for customer communication, troubleshooting failed deliveries, and tracking campaign performance. This tutorial demonstrates how to receive and process Vonage delivery receipts using NestJS webhooks, giving you real-time visibility into message delivery status.
Technologies Used:
@vonage/server-sdk: The official Vonage Node.js SDK (current version: v3.24.1). While this guide focuses on receiving DLRs (which doesn't directly use the SDK), use this SDK within your NestJS application to send the initial SMS messages that generate these DLRs.[3]@nestjs/config: For managing environment variables.class-validator&class-transformer: For validating incoming webhook data.System Architecture:
Understanding Vonage Delivery Receipt Status Codes
Vonage sends different delivery receipt statuses depending on whether your SMS was delivered successfully or encountered an error. Understanding these status codes helps you handle delivery failures and track message performance:
Final Outcome & Prerequisites:
By the end of this guide, you will have a NestJS application capable of:
Prerequisites:
npm install -g @nestjs/cli).gitinstalled.Setting Up Your Vonage Account for Delivery Receipts
Before building your webhook handler, you need to configure your Vonage account to send delivery receipts to your application and set up a local development environment for testing.
A. Vonage Account Configuration:
API KeyandAPI Secretfrom the main dashboard page. You'll need these later for environment variables (primarily for sending SMS and for basic DLR verification).Numbers→Buy Numbers. Search for and purchase a number with SMS capabilities in your desired country. Note this number.Settingsin the left-hand menu.SMS settingssection.Delivery receipts (DLR) webhooksfield.ngrokto create this public URL for your local machine. Set upngrokin the next step, then return here to paste the generated URL.POSTand the type isJSON. Leave the URL field blank for now or enter a temporary placeholder.B. Local Development Environment Setup (ngrok):
ngrokcreates a secure tunnel from the public internet to your local development machine.Start ngrok: Open your terminal and run the following command, replacing
3000with the port your NestJS app will run on (NestJS default is 3000):Copy Forwarding URL:
ngrokdisplays session information, including aForwardingURL ending in.ngrok-free.appor similar (e.g.,https://random-subdomain.ngrok-free.app). Copy thehttpsversion of this URL. This is your temporary public address.Update Vonage DLR Webhook URL:
Settings→SMS settings.ngrokForwarding URL into theDelivery receipts (DLR) webhooksfield./api/webhooks/vonage/delivery-receipts.https://random-subdomain.ngrok-free.app/api/webhooks/vonage/delivery-receipts.POSTandJSONare selected next to the URL field.Save changes.Creating Your NestJS Project for Vonage Webhooks
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.
Navigate to Project:
cd vonage-dlr-handlerInstall Dependencies:
npm install @nestjs/config class-validator class-transformer # or yarn add @nestjs/config class-validator class-transformerProject 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 (you'll remove/replace this).app.service.ts: Default example service (you'll remove/replace this)..env: (You will create this) Stores environment variables.nest-cli.json,package.json,tsconfig.json, etc.: Configuration files.Architectural Decision: Use NestJS modules to organize features. Create a dedicated
WebhookModuleto handle incoming webhooks from Vonage.Configuring Vonage Credentials in NestJS
Securely manage your Vonage credentials and other configurations.
Create
.envfile: In the root directory of your project, create a file named.env.Add Variables: Add the following, replacing placeholders with your actual Vonage details:
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.envto.env.exampleand remove the sensitive values. Commit.env.exampleto git, but add.envto your.gitignorefile to avoid committing secrets.Integrate
ConfigModule: Modify yoursrc/app.module.tsto load environment variables globally:// src/app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { WebhookModule } from './webhook/webhook.module'; // You'll create this next @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, // Make ConfigModule available globally envFilePath: '.env', // Specify the env file path }), WebhookModule, // Import your feature module ], controllers: [], // Remove default AppController if not needed providers: [], // Remove default AppService if not needed }) export class AppModule {}Update
main.tsto use the PORT and Global Validation: Modifysrc/main.tsto respect thePORTenvironment 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();ValidationPipeautomatically validates incoming request bodies against your DTOs (Data Transfer Objects).app.setGlobalPrefix('api')matches the/api/…path structure used in the webhook URL.Building the Vonage DLR Webhook Endpoint
Create the module, controller, and service to receive and process the DLR webhooks.
Generate Webhook Module, Controller, and Service: Use the Nest CLI:
--flatprevents creating a dedicated directory for each. Adjust if you prefer nested structures.webhook.module.ts,webhook.controller.ts, andwebhook.service.tsin thesrcdirectory (orsrc/webhookif not using--flat). EnsureWebhookModuleis imported inapp.module.tsas 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 based on Vonage documentation[4] 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; // The number the message was sent to (E.164 format)[4] @IsString() @IsNotEmpty() @ApiProperty({ description: 'Your Vonage virtual number (E.164 format)', example: '14155550100' }) to: string; // The SenderID you set in the from field[4] @IsString() @IsOptional() @ApiProperty({ required: false, description: 'Mobile Country Code + Mobile Network Code (MCCMNC) of the carrier', example: '310260' }) 'network-code'?: string; // The MCCMNC of the carrier[4] @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; @IsString() // Vonage uses 'YYMMDDHHMM' or 'YYYY-MM-DD HH:MM:SS' format[4] @IsNotEmpty() @ApiProperty({ description: 'When the DLR was received from the carrier (YYMMDDHHMM or YYYY-MM-DD HH:MM:SS format)', example: '2023-10-26 10:00:00' }) scts: string; // Status change timestamp - when DLR was received from carrier[4] @IsString() @IsNotEmpty() @ApiProperty({ description: 'Error code (0 for success, non-zero for errors)', example: '0' }) 'err-code': string; // The status of the request (0 for success, non-zero for errors)[4] @IsString() @IsNotEmpty() @ApiProperty({ description: 'Your Vonage API key (useful when multiple accounts send webhooks to same endpoint)', example: 'abcdef01' }) 'api-key': string; // The API key that sent the SMS[4] @IsString() // Vonage timestamp format @IsNotEmpty() @ApiProperty({ description: 'When Vonage started pushing this DLR to your webhook endpoint', example: '2023-10-26 09:59:00' }) 'message-timestamp': string; // When Vonage started pushing this DLR[4] // Additional optional fields from Vonage DLR specification[4] @IsString() @IsOptional() @ApiProperty({ required: false, description: 'Client reference if provided during sending', example: 'my-internal-ref-123' }) 'client-ref'?: string; @IsString() @IsOptional() @ApiProperty({ required: false, description: 'Unix timestamp representation of message-timestamp', example: '1698314340' }) timestamp?: string; // Unix timestamp representation[4] @IsString() @IsOptional() @ApiProperty({ required: false, description: 'Random string for signature validation (if signatures enabled)', example: 'abc123xyz' }) nonce?: string; // Used for signature validation if enabled[4] // Concatenated message fields (if applicable)[4] @IsOptional() @ApiProperty({ required: false, description: 'True if this is a concatenated message' }) concat?: string; @IsString() @IsOptional() @ApiProperty({ required: false, description: 'Transaction reference shared by all parts of concatenated message' }) 'concat-ref'?: string; @IsString() @IsOptional() @ApiProperty({ required: false, description: 'Number of parts in concatenated message' }) 'concat-total'?: string; @IsString() @IsOptional() @ApiProperty({ required: false, description: 'Number of this part in message (counting starts at 1)' }) 'concat-part'?: string; }class-validator, and improve type safety.@ApiPropertyfor potential Swagger integration.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 OKstatus is sent back to Vonage upon successful handling before thereturnstatement is evaluated, unless an exception is thrown.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 } }ConfigServiceto retrieve theVONAGE_API_KEY.Handling Errors in Vonage Delivery Receipt Webhooks
NestJS provides built-in mechanisms, but let's refine them.
ValidationPipeautomatically 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.try...catchblock in the controller logs internal processing errors but still returns200 OKto 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.Logger. For production, configure a more robust logging strategy:log,debug,warn,error).Securing Your Vonage Webhook Endpoint
Webhook endpoints are publicly accessible, so security is paramount.
HTTPS: Always use HTTPS for your webhook endpoint in production.
ngrokprovides HTTPS locally. Ensure your production deployment environment (e.g., via load balancer, reverse proxy) enforces HTTPS.Input Validation: Handled effectively by the
VonageDlrDtoand the globalValidationPipe. This is crucial to prevent processing malformed data.Source Verification (Basic & Limitations):
WebhookServiceprovides minimal verification. As noted, the key is part of the payload itself./api/webhooks/vonage/dlr/SOME_RANDOM_SECRET_TOKEN). Check this token in your controller. This is security through obscurity and less robust than signatures.Rate Limiting: Protect your endpoint from abuse or accidental loops by implementing rate limiting. The
@nestjs/throttlermodule is excellent for this.npm install @nestjs/throttler # or yarn add @nestjs/throttlerConfigure it in
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
processDeliveryReceiptlogic 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.Testing Your Vonage SMS Delivery Receipt Handler
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 });beforeEach,afterEach) to correctly mock the logger andconsole.log.B. Integration Testing: Test the controller and service together.
@nestjs/testing) to create a testing module that includes theWebhookModule.supertestto send mock HTTP POST requests to the/api/webhooks/vonage/delivery-receiptsendpoint with various valid and invalid DLR payloads.WebhookService.processDeliveryReceiptmethod to ensure it's called with the correct data.C. End-to-End (E2E) Testing:
npm run start:dev).ngrok http 3000(or your port) is running.ngrokURL +/api/webhooks/vonage/delivery-receipts.@vonage/server-sdk) to send an SMS message from your configured Vonage number to a real test phone number you have access to.ngrokconsole for incoming POST requests to your webhook path.delivered).failedorrejectedDLR).Deploying Your NestJS Vonage Webhook to Production
.envfiles with production secrets.ngrokone.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. 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.
Frequently Asked Questions (FAQ)
How do Vonage delivery receipts work?
Vonage delivery receipts (DLRs) are HTTP webhook callbacks sent from Vonage to your server when an SMS message's delivery status changes. After you send an SMS through Vonage, carriers report back the delivery status (delivered, failed, rejected, etc.), and Vonage forwards this information to your configured webhook endpoint as a POST request.
What is the difference between intermediate and final DLR statuses?
Intermediate statuses like
acceptedandbufferedindicate the message is in transit.acceptedmeans Vonage has accepted the message, whilebufferedmeans a carrier has queued it. Final statuses likedelivered,failed,rejected, andexpiredrepresent the terminal state of the message and won't change further.How do I handle failed SMS deliveries in NestJS?
When you receive a DLR with status
failedorrejected, check theerr-codefield for the specific error reason. Common handling strategies include: logging the failure for analytics, implementing retry logic with exponential backoff, notifying your support team for persistent failures, and validating recipient numbers before sending future messages.Can I test Vonage webhooks locally without deploying?
Yes, use ngrok to create a public tunnel to your local NestJS application. Run
ngrok http 3000(or your port), copy the HTTPS forwarding URL, and configure it in your Vonage dashboard settings. This allows Vonage to send DLR webhooks to your local development environment.How do I secure my Vonage webhook endpoint?
Vonage SMS DLR webhooks don't support cryptographic signatures, so implement these security measures: verify the
api-keyfield matches your Vonage API key, use HTTPS for all webhook URLs, implement rate limiting with@nestjs/throttler, consider IP whitelisting if Vonage publishes stable IP ranges, and validate all incoming data with DTOs and class-validator.What happens if my webhook endpoint is temporarily unavailable?
Vonage will retry sending DLR webhooks if your endpoint returns a non-2xx status code or times out. However, retry behavior has limits. To handle temporary outages, ensure your endpoint responds quickly (under 10 seconds), return 200 OK even for internal processing errors (log them instead), and implement idempotency to handle duplicate DLRs safely.
How do I store delivery receipts in a database?
In the
WebhookService.processDeliveryReceiptmethod, add database logic using TypeORM, Prisma, or Mongoose. Store themessageId,status,sctstimestamp,err-code, and recipientmsisdn. Create a Message model with fields for tracking status changes over time, and update the status when DLRs arrive.Can I receive DLRs for messages sent through different Vonage accounts?
Yes. The DLR payload includes an
api-keyfield identifying which Vonage account sent the message. Use this field to route DLRs to the appropriate handler if you manage multiple Vonage accounts through a single webhook endpoint.What Node.js and NestJS versions should I use?
Use Node.js v22 LTS (active support until October 2025) with NestJS v11.1.6 for production deployments. Minimum requirement is Node.js v18+ for NestJS 11 compatibility. These versions provide long-term stability and security updates.[1][2]
How do I troubleshoot webhook delivery issues?
Check these common issues: verify the webhook URL in Vonage dashboard settings is correct and uses HTTPS, ensure your endpoint returns 200 OK status codes, check ngrok is running for local development, review NestJS application logs for validation errors, verify the global validation pipe is configured correctly, and check Vonage dashboard logs for webhook delivery attempts and errors.
References
[1] Node.js Release Working Group. "Node.js Releases." Retrieved from https://nodejs.org/en/about/previous-releases. Node.js v22 LTS information: https://nodesource.com/blog/Node.js-v22-Long-Term-Support-LTS
[2] NestJS. "Releases." GitHub. Retrieved from https://github.com/nestjs/nest/releases. NestJS v11 announcement: https://trilon.io/blog/announcing-nestjs-11-whats-new
[3] Vonage. "@vonage/server-sdk v3.24.1." npm. Retrieved from https://www.npmjs.com/package/@vonage/server-sdk
[4] Vonage API Developer. "Webhooks Guide – SMS Delivery Receipts." Retrieved from https://developer.vonage.com/en/getting-started/concepts/webhooks