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.
- ✅ 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
npm install nestjs-webhook-sender bullmq ioredisPeer dependencies (install if not already present):
npm install @nestjs/common @nestjs/core// 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 {}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.
WebhookSenderModule.registerAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
redis: { url: config.get('REDIS_URL') },
delivery: { maxAttempts: 6 },
signing: { style: 'standard' },
dlq: { enabled: true },
}),
inject: [ConfigService],
})Follows the Standard Webhooks specification.
webhook-id: wh_a1b2c3...
webhook-timestamp: 1700000000
webhook-signature: v1,base64EncodedHmac...
Signed content: {webhookId}.{timestamp}.{body}
stripe-signature: t=1700000000,v1=hexEncodedHmac...
Signed content: {timestamp}.{body}
x-hub-signature-256: sha256=hexEncodedHmac...
Signed content: raw body
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 |
| HTTP Status | Action |
|---|---|
2xx |
✅ Success |
5xx |
🔁 Retry |
429 |
🔁 Retry |
| Network timeout | 🔁 Retry |
400, 401, 403, 404 |
❌ DLQ immediately |
410 Gone |
❌ DLQ immediately (endpoint disabled) |
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
});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 },
// ],
// }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');Inspect a dead-lettered webhook without replaying it.
const entry = await this.webhookSender.getDlqEntry('wh_abc123');
// {
// webhookId, url, event, payload, failureReason,
// attempts, deadLetteredAt, metadata
// }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 };
}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
},
})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)
);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),
);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
MIT © Saifuddin Tipu