Skip to content

SaifuddinTipu/nestjs-webhook-sender

Repository files navigation

nestjs-webhook-sender

npm version npm downloads license CI tests

Production-ready NestJS module for outbound webhook delivery.
HMAC signing · BullMQ queue · Exponential backoff · Dead-letter queue · Delivery logs.
Stripe-grade reliability in 5 lines of setup.


Features

  • HMAC-SHA256 signing — Standard Webhooks, Stripe-style, or GitHub-style
  • BullMQ-backed queue — non-blocking, survives restarts
  • Svix-inspired retry schedule — immediate → 5s → 5min → 30min → 2hr → 5hr
  • Jitter — ±20% randomisation prevents thundering herd
  • Smart retry logic — 5xx/429/timeouts retry; 4xx go straight to DLQ
  • Dead-letter queue — failed webhooks stored for inspection and replay
  • replay() — re-queue any dead-lettered webhook without re-triggering business logic
  • Delivery logs — per-attempt log with status, HTTP code, duration, error (7-day TTL)
  • register + registerAsync — works with ConfigService / async factories
  • Full TypeScript — typed interfaces for every input and output

Installation

npm install nestjs-webhook-sender bullmq ioredis

Peer dependencies (install if not already present):

npm install @nestjs/common @nestjs/core

Quick Start

1. Register the module

// app.module.ts
import { WebhookSenderModule } from 'nestjs-webhook-sender';

@Module({
  imports: [
    WebhookSenderModule.register({
      redis: { host: 'localhost', port: 6379 },
      delivery: {
        maxAttempts: 6,      // Total attempts before dead-lettering
        timeoutMs: 10_000,   // HTTP timeout per attempt
      },
      signing: {
        style: 'standard',   // 'standard' | 'stripe' | 'github'
      },
      dlq: {
        enabled: true,       // Store permanently failed webhooks for replay
      },
    }),
  ],
})
export class AppModule {}

2. Send a webhook

import { WebhookSenderService } from 'nestjs-webhook-sender';

@Injectable()
export class OrderService {
  constructor(private readonly webhookSender: WebhookSenderService) {}

  async onOrderCreated(order: Order) {
    const webhookId = await this.webhookSender.send({
      url: 'https://customer.com/webhooks',
      event: 'order.created',
      payload: { orderId: order.id, total: order.total },
      secret: 'whsec_abc123',   // HMAC signing secret
    });

    // webhookId for tracking — e.g. wh_a1b2c3d4...
  }
}

That's it. The webhook is queued, signed, delivered, and retried automatically on failure.


Async Registration (with ConfigService)

WebhookSenderModule.registerAsync({
  imports: [ConfigModule],
  useFactory: (config: ConfigService) => ({
    redis: { url: config.get('REDIS_URL') },
    delivery: { maxAttempts: 6 },
    signing: { style: 'standard' },
    dlq: { enabled: true },
  }),
  inject: [ConfigService],
})

Signing Styles

Standard Webhooks (default) — style: 'standard'

Follows the Standard Webhooks specification.

webhook-id:        wh_a1b2c3...
webhook-timestamp: 1700000000
webhook-signature: v1,base64EncodedHmac...

Signed content: {webhookId}.{timestamp}.{body}

Stripe-style — style: 'stripe'

stripe-signature: t=1700000000,v1=hexEncodedHmac...

Signed content: {timestamp}.{body}

GitHub-style — style: 'github'

x-hub-signature-256: sha256=hexEncodedHmac...

Signed content: raw body


Retry Schedule

Default schedule (Svix-inspired, with ±20% jitter):

Attempt Delay
1 Immediate
2 ~5 seconds
3 ~5 minutes
4 ~30 minutes
5 ~2 hours
6 ~5 hours → DLQ

What gets retried

HTTP Status Action
2xx ✅ Success
5xx 🔁 Retry
429 🔁 Retry
Network timeout 🔁 Retry
400, 401, 403, 404 ❌ DLQ immediately
410 Gone ❌ DLQ immediately (endpoint disabled)

API Reference

send(dto)

Queue a webhook for delivery. Returns the webhookId.

const webhookId = await this.webhookSender.send({
  url: 'https://example.com/hooks',          // required
  event: 'order.created',                    // required
  payload: { orderId: '123' },               // required
  secret: 'whsec_abc123',                    // required
  webhookId: 'wh_custom_id',                 // optional — auto-generated if omitted
  metadata: { customerId: 'c_456' },         // optional — stored in delivery log
});

listDeliveries(webhookId)

Get the full delivery log for a webhook.

const log = await this.webhookSender.listDeliveries('wh_abc123');
// {
//   webhookId: 'wh_abc123',
//   event: 'order.created',
//   finalStatus: 'success' | 'retrying' | 'dead_lettered',
//   attempts: [
//     { attempt: 1, status: 'retrying', statusCode: 500, durationMs: 210, timestamp: 1700000000 },
//     { attempt: 2, status: 'success', statusCode: 200, durationMs: 95, timestamp: 1700000305 },
//   ],
// }

replay(webhookId)

Re-queue a dead-lettered webhook. The original payload and secret are preserved. Returns a new webhookId.

const newId = await this.webhookSender.replay('wh_abc123');

getDlqEntry(webhookId)

Inspect a dead-lettered webhook without replaying it.

const entry = await this.webhookSender.getDlqEntry('wh_abc123');
// {
//   webhookId, url, event, payload, failureReason,
//   attempts, deadLetteredAt, metadata
// }

Building a Webhook Dashboard

listDeliveries() gives you everything you need to build a customer-facing webhook log:

@Get('webhooks/:id/deliveries')
async getDeliveries(@Param('id') webhookId: string) {
  return this.webhookSender.listDeliveries(webhookId);
}

@Post('webhooks/:id/replay')
async replay(@Param('id') webhookId: string) {
  const newId = await this.webhookSender.replay(webhookId);
  return { newWebhookId: newId };
}

Configuration Reference

WebhookSenderModule.register({
  redis: {
    host: 'localhost',   // default
    port: 6379,          // default
    password: undefined,
    db: 0,               // default
    url: undefined,      // takes precedence over host/port if set
  },
  delivery: {
    timeoutMs: 10_000,        // HTTP timeout per attempt. Default: 10s
    maxAttempts: 6,           // Total attempts. Default: 6
    backoff: 'svix',          // 'svix' | 'exponential' | 'fixed'. Default: 'svix'
    initialDelayMs: 5_000,    // Base delay for exponential/fixed. Default: 5s
    jitter: true,             // ±20% delay randomisation. Default: true
  },
  signing: {
    style: 'standard',        // 'standard' | 'stripe' | 'github'. Default: 'standard'
  },
  dlq: {
    enabled: true,            // Enable dead-letter queue. Default: true
  },
})

Verifying Signatures (Receiver Side)

Standard Webhooks

import { WebhookSenderSigner } from 'nestjs-webhook-sender';

const signer = new WebhookSenderSigner();

const isValid = signer.verifyStandard(
  req.headers['webhook-id'],
  rawBody,                          // unparsed request body string
  'whsec_your_secret',
  req.headers['webhook-signature'],
  parseInt(req.headers['webhook-timestamp'], 10),
  300,                              // tolerance in seconds (default: 5 min)
);

GitHub-style (manual)

import * as crypto from 'crypto';

const expected = `sha256=${crypto
  .createHmac('sha256', secret)
  .update(rawBody)
  .digest('hex')}`;

const isValid = crypto.timingSafeEqual(
  Buffer.from(req.headers['x-hub-signature-256']),
  Buffer.from(expected),
);

Redis Key Structure

webhook:delivery:{webhookId}    LIST   → delivery attempt log (7-day TTL)
webhook:dlq:{webhookId}         STRING → DLQ entry JSON (7-day TTL)
webhook-sender:queue            BullMQ queue
webhook-sender:dlq              BullMQ DLQ queue

License

MIT © Saifuddin Tipu

About

Production-ready NestJS module for outbound webhook delivery. HMAC signing · BullMQ queue · Exponential backoff · Dead-letter queue · Delivery logs. Stripe-grade reliability in 5 lines of setup.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors