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! 🔥