Webhooks are a powerful way to enable real-time communication between services. But what happens if a webhook fails? Or if a malicious user floods your endpoint with spam? In this guide, we’ll build a resilient webhook system in NestJS that includes:
- Webhook sender: Securely signing and sending webhooks.
- Webhook receiver: Verifying, processing, and retrying failed webhooks.
- Dead-letter queues (DLQ): Handling persistent failures gracefully.
- Rate limiting: Preventing spam and abuse.
1. Setting Up the Webhook Sender 🔄
The sender application is responsible for dispatching events to the webhook receiver. To ensure security, we sign each payload before sending it.
Signing and Sending Webhooks
import * as crypto from 'crypto';
import axios from 'axios';
// Replace with your secret key, use environment variables in production
const SECRET = 'your-secret-key';
function signPayload(payload: any): string {
return crypto.createHmac('sha256', SECRET)
.update(JSON.stringify(payload))
.digest('hex');
}
async function sendWebhook(url: string, event: any) {
const signature = signPayload(event);
await axios.post(url, event, {
headers: {
'X-Signature': signature,
},
});
}
// Example usage:
sendWebhook('http://webhook-receiver.com/events', { event: 'USER_CREATED', data: { id: 123 } });
2. Implementing the Webhook Receiver 🏠
The receiver application verifies the signature and processes incoming webhooks.
Verifying Webhook Signatures
import { Controller, Post, Headers, Body, HttpException, HttpStatus } from '@nestjs/common';
import * as crypto from 'crypto';
const SECRET = 'your-secret-key';
function verifySignature(payload: any, signature: string): boolean {
const expectedSignature = crypto.createHmac('sha256', SECRET)
.update(JSON.stringify(payload))
.digest('hex');
return expectedSignature === signature;
}
@Controller('webhooks')
export class WebhookController {
@Post()
async handleWebhook(@Headers('X-Signature') signature: string, @Body() payload: any) {
if (!verifySignature(payload, signature)) {
throw new HttpException('Invalid signature', HttpStatus.UNAUTHORIZED);
}
console.log('Received webhook:', payload);
// Process webhook...
}
}
3. Implementing Retry Logic with Bull 📥
If webhook processing fails, we should retry it using BullMQ, a Redis-based queue system.
Installing Dependencies:
npm install @nestjs/bull bull redis
Configuring Bull in app.module.ts
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
@Module({
imports: [
BullModule.forRoot({
redis: {
host: 'localhost',
port: 6379,
},
}),
BullModule.registerQueue({ name: 'webhook-queue' }),
],
})
export class AppModule {}
Webhook Retry Queue Processor
import { Process, Processor } from '@nestjs/bull';
import { Job } from 'bull';
import axios from 'axios';
@Processor('webhook-queue')
export class WebhookProcessor {
@Process('retry')
async handleFailedWebhook(job: Job) {
const { payload, url } = job.data;
try {
await axios.post(url, payload);
} catch (error) {
throw new Error('Webhook retry failed');
}
}
}
Adding Failed Webhooks to the Queue
import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull';
import { Controller, Post, Body } from '@nestjs/common';
@Controller('webhooks')
export class WebhookController {
constructor(@InjectQueue('webhook-queue') private webhookQueue: Queue) {}
@Post()
async handleWebhook(@Body() payload: any) {
try {
console.log('Processing webhook:', payload);
// Process webhook normally...
} catch (error) {
console.error('Failed webhook, adding to queue');
await this.webhookQueue.add('retry', { payload, url: 'http://webhook-receiver.com/events' }, { attempts: 3 });
}
}
}
4. Implementing Dead-Letter Queues (DLQ) ☠️
If a webhook fails after multiple retries, it should be moved to a Dead-letter queue for manual intervention.
This is useful for logging and debugging, and allows you to handle persistent failures gracefully.
@Processor('webhook-queue')
export class WebhookProcessor {
@Process('retry')
async retryWebhook(job: Job) {
try {
await axios.post(job.data.url, job.data.payload);
} catch (error) {
if (job.attemptsMade >= job.opts.attempts) {
console.error('Moving webhook to DLQ:', job.data);
await this.deadLetterQueue.add(job.data);
}
throw error;
}
}
}
5. Implementing Rate Limiting ⏳
To prevent spam attacks, we’ll use NestJS Throttler in our webhook receiver.
This will limit the number of requests a user can make in a given time frame.
Installing Dependencies:
npm install @nestjs/throttler
Configuring Rate Limiting
import { Module } from '@nestjs/common';
import { ThrottlerModule } from '@nestjs/throttler';
@Module({
imports: [
ThrottlerModule.forRoot({
ttl: 60, // 1 minute
limit: 5, // Max 5 requests per minute per user
}),
],
})
export class AppModule {}
Applying Rate Limiting to the Webhook Controller
import { Throttle } from '@nestjs/throttler';
import { Controller, Post, Body } from '@nestjs/common';
@Controller('webhooks')
export class WebhookController {
@Throttle(5, 60)
@Post()
async handleWebhook(@Body() payload: any) {
console.log('Processing webhook:', payload);
}
}
Conclusion 🎯
By implementing secure webhooks with retries, dead-letter queues, and rate limiting, we ensure our system is reliable, fault-tolerant, and secure. 🚀
- Security: Signed payloads prevent unauthorized requests.
- Retries: Failed webhooks are reprocessed automatically.
- Dead-letter queues: Webhooks that repeatedly fail are logged for manual intervention.
- Rate limiting: Protects against spam and DoS attacks.
Got questions or improvements? Drop them in the comments! 💬 Happy coding! 🔥