Every SaaS app eventually needs to send webhooks. And every team eventually learns the hard way that "just HTTP POST it" isn't enough.
I've seen it go wrong the same way every time:
- A customer's endpoint is down at 2am. The webhook fires once, gets a 503, and silently disappears.
- A developer hardcodes the same signing secret for every customer. One leak exposes all of them.
- Support opens a ticket: "We never received the payment webhook." Nobody can prove whether it was sent, what the response was, or whether it should be retried.
So I built nestjs-webhook-sender — a production-ready NestJS module that handles all of this properly.
What it does
Your Service
│
│ webhookSenderService.send({ url, payload, secret })
▼
┌──────────────────────────┐
│ BullMQ Queue │ ← persisted in Redis, survives restarts
└──────────────────────────┘
│
│ Worker picks up job
▼
┌──────────────────────────┐
│ Signs payload (HMAC) │
│ POSTs via axios │
│ Checks response status │
└──────────────────────────┘
│
├── 2xx → ✅ Logged as success
├── 5xx/429 → 🔁 Retried with backoff (up to 6 attempts)
└── 4xx/410 → ☠️ Dead-letter queue immediately
The retry schedule is inspired by Svix (the webhook infrastructure company):
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | ~5 seconds |
| 3 | ~5 minutes |
| 4 | ~30 minutes |
| 5 | ~2 hours |
| 6 | ~5 hours → DLQ |
Each delay has ±20% jitter to prevent thundering herd when multiple webhooks fail at the same time.
Installation
npm install nestjs-webhook-sender bullmq ioredis
Setup (5 lines)
// app.module.ts
import { WebhookSenderModule } from 'nestjs-webhook-sender';
@Module({
imports: [
WebhookSenderModule.register({
redis: { host: 'localhost', port: 6379 },
}),
],
})
export class AppModule {}
Sending a webhook
@Injectable()
export class OrderService {
constructor(private readonly webhooks: WebhookSenderService) {}
async onOrderCompleted(order: Order) {
const webhookId = await this.webhooks.send({
url: customer.webhookUrl,
event: 'order.completed',
payload: {
orderId: order.id,
total: order.total,
completedAt: new Date().toISOString(),
},
secret: customer.webhookSecret,
signingStyle: 'standard', // or 'stripe' or 'github'
});
// store webhookId for delivery log lookups
}
}
The webhook is non-blocking — it's enqueued immediately and processed by a background worker. Your API response isn't delayed.
Three signing styles
This was actually one of the trickier design decisions. Different consumers expect different signature formats:
Standard Webhooks spec (default)
webhook-id: wh_01HXYZ...
webhook-timestamp: 1715000000
webhook-signature: v1,base64encodedhmac
Follows the Standard Webhooks spec. Good default for new systems.
Stripe-style
stripe-signature: t=1715000000,v1=hexencodedhmac
Use this if your customers already have Stripe SDK webhook verification set up — it's drop-in compatible.
GitHub-style
x-hub-signature-256: sha256=hexencodedhmac
Use this for integrations with GitHub Apps or any system expecting GitHub-format signatures.
All three use HMAC-SHA256 with crypto.timingSafeEqual for constant-time comparison (no timing attacks).
Smart retry logic
Not all failures are equal. The module routes them differently:
// 5xx, 429, network errors → retry (transient failure)
// 400, 401, 403, 404 → DLQ immediately (permanent failure, no point retrying)
// 410 Gone → DLQ immediately (endpoint intentionally disabled)
This matters a lot in practice. If a customer deletes their endpoint, you don't want to burn through 6 retry attempts over 5 hours — you want to know immediately.
Dead-letter queue + replay
When a webhook exhausts all retries, it goes to the DLQ:
// Inspect a failed webhook
const entry = await this.webhooks.getDlqEntry(webhookId);
// {
// webhookId: 'wh_01HXYZ...',
// url: 'https://...',
// payload: { ... },
// reason: 'HTTP 404 Not Found',
// failedAt: '2024-01-15T10:30:00.000Z'
// }
// After the customer fixes their endpoint — replay it
await this.webhooks.replay(webhookId);
// Re-enqueues with fresh attempt count
This is what makes support tickets answerable. "Did the webhook get sent?" → yes, here are the 6 delivery attempts. "Can you resend it?" → yes, one line of code.
Delivery logs
Every attempt (success or failure) is logged to Redis with a 7-day TTL:
const deliveries = await this.webhooks.listDeliveries(webhookId);
// [
// { attemptNumber: 1, status: 'failed', statusCode: 503, timestamp: '...' },
// { attemptNumber: 2, status: 'success', statusCode: 200, timestamp: '...' }
// ]
You can surface this directly in a customer dashboard:
@Get('webhooks/:id/deliveries')
getDeliveries(@Param('id') id: string) {
return this.webhooks.listDeliveries(id);
}
@Post('webhooks/:id/replay')
replay(@Param('id') id: string) {
return this.webhooks.replay(id);
}
Signature verification (consumer side)
On the receiving end, the module exports a WebhookSigner.verifyStandard() helper:
import { WebhookSigner } from 'nestjs-webhook-sender';
@Post('webhooks/orders')
receiveOrder(@Req() req: Request, @Headers() headers: Record<string, string>) {
const isValid = WebhookSigner.verifyStandard({
webhookId: headers['webhook-id'],
timestamp: headers['webhook-timestamp'],
signature: headers['webhook-signature'],
rawBody: JSON.stringify(req.body),
secret: process.env.WEBHOOK_SECRET,
});
if (!isValid) throw new UnauthorizedException('Invalid signature');
// process verified event...
}
Testing
The package ships with 57 tests across three suites:
test/webhook-sender.signer.spec.ts — 20 tests (all 3 signing styles)
test/webhook-sender.processor.spec.ts — 19 tests (HTTP delivery with nock mocking)
test/webhook-sender.service.spec.ts — 18 tests (BullMQ mocked)
The processor tests use nock to mock HTTP responses — no real server needed:
nock('https://example.com').post('/webhook').reply(503); // simulate server error
await expect(processor.process(job, dlqQueue)).rejects.toThrow('status 503');
// BullMQ will retry this job automatically
Async configuration with ConfigService
WebhookSenderModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
redis: {
host: config.get('REDIS_HOST'),
port: config.get<number>('REDIS_PORT'),
password: config.get('REDIS_PASSWORD'),
},
defaultSecret: config.get('WEBHOOK_SECRET'),
}),
})
Why not just use Svix or Hookdeck?
Those are great services — but they're external infrastructure with a SaaS pricing model. If you want:
- No external dependency — everything runs in your own Redis
- Full control over retry logic, signing, and storage
- No per-event pricing — send 10 or 10 million, same cost
- Native NestJS DI — inject the service anywhere, mock it in tests
...then a library makes more sense.
Links
- npm: npmjs.com/package/nestjs-webhook-sender
- GitHub: github.com/SaifuddinTipu/nestjs-webhook-sender
- Detailed USAGE.md with 5 real-world scenarios, Redis CLI debugging, and E2E test examples
If this is useful, a ⭐ on GitHub goes a long way. And if you find a bug or have a feature request, open an issue — I'm actively maintaining it.
Top comments (0)