DEV Community

Saifuddin Tipu
Saifuddin Tipu

Posted on

I Built a Stripe-Grade Webhook Delivery System for NestJS (Open Source)

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Setup (5 lines)

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

@Module({
  imports: [
    WebhookSenderModule.register({
      redis: { host: 'localhost', port: 6379 },
    }),
  ],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Follows the Standard Webhooks spec. Good default for new systems.

Stripe-style

stripe-signature: t=1715000000,v1=hexencodedhmac
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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: '...' }
// ]
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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...
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'),
  }),
})
Enter fullscreen mode Exit fullscreen mode

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

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)