Webhook Delivery Tutorial · Module 03 of 10

Reliable Delivery & Retries

Implement the core: when an event is emitted, enqueue delivery jobs, run a worker to send them, and retry with exponential backoff. By the end, your webhook service will survive customer downtime.

~5–7 hrsAdvancedQueuing required
← Back to Module 03 overview
What You'll Have at the End

Definition of Done

  • When POST /events is called, delivery jobs are enqueued for each matching webhook.
  • A worker process consumes jobs and POSTs the event to the webhook URL with a signed body.
  • Exponential backoff retries: 1s, 4s, 16s, 64s, 256s (5 retries max).
  • Idempotency: each delivery has a unique delivery_id.
  • Delivery state transitions: pending → processing → success or → retry scheduled.
  • Worker can be stopped/restarted without losing jobs.
The Steps

Build It

STEP 1

Install a job queue library

npm install bull redis

We'll use Bull with Redis as the backing store. Make sure Redis is running:

docker run -d -p 6379:6379 redis:7
✓ Verify: redis-cli ping returns PONG.
STEP 2

Create a queue service

Create src/queue/deliveryQueue.ts:

import Queue from 'bull';

export interface DeliveryJob {
  delivery_id: string;
  webhook_id: string;
  event_id: string;
  webhook_url: string;
  webhook_secret: string;
  payload: any;
  attempt: number;
}

const deliveryQueue = new Queue('delivery', {
  redis: {
    host: process.env.REDIS_HOST || 'localhost',
    port: parseInt(process.env.REDIS_PORT || '6379')
  }
});

export default deliveryQueue;
✓ Verify: File exists at src/queue/deliveryQueue.ts.
STEP 3

Update POST /events to enqueue delivery jobs

Modify src/routes/events.ts:

import deliveryQueue from '../queue/deliveryQueue';

// In the POST /events handler, after inserting the event:
const eventRow = result.rows[0];

// Find all matching webhooks
const webhooksResult = await pool.query(
  'SELECT * FROM webhooks WHERE $1 = ANY(event_types)',
  [type]
);

// Create delivery records and enqueue jobs
for (const webhook of webhooksResult.rows) {
  const deliveryId = crypto.randomUUID();
  await pool.query(
    'INSERT INTO deliveries (id, webhook_id, event_id, status) VALUES ($1, $2, $3, $4)',
    [deliveryId, webhook.id, eventRow.id, 'pending']
  );

  await deliveryQueue.add({
    delivery_id: deliveryId,
    webhook_id: webhook.id,
    event_id: eventRow.id,
    webhook_url: webhook.url,
    webhook_secret: webhook.secret,
    payload: payload,
    attempt: 0
  });
}

res.status(201).json(eventRow);
✓ Verify: Emit an event and check Redis: redis-cli KEYS '*' shows the delivery queue.
STEP 4

Implement a worker to process deliveries

Create src/worker/deliveryWorker.ts:

import crypto from 'crypto';
import axios from 'axios';
import pool from '../db/pool';
import deliveryQueue, { DeliveryJob } from '../queue/deliveryQueue';

const BACKOFF_DELAYS = [1000, 4000, 16000, 64000, 256000]; // ms
const MAX_RETRIES = 5;

function calculateNextRetry(attempt: number): number {
  if (attempt >= MAX_RETRIES) return -1;
  return Date.now() + BACKOFF_DELAYS[attempt];
}

function signPayload(payload: any, secret: string): string {
  const body = JSON.stringify(payload);
  return crypto
    .createHmac('sha256', secret)
    .update(body)
    .digest('hex');
}

deliveryQueue.process(async (job) => {
  const { delivery_id, webhook_url, webhook_secret, payload, attempt } = job.data;

  try {
    // Update status to processing
    await pool.query(
      'UPDATE deliveries SET status = $1 WHERE id = $2',
      ['processing', delivery_id]
    );

    // Sign and send
    const signature = signPayload(payload, webhook_secret);
    const response = await axios.post(webhook_url, payload, {
      headers: {
        'Content-Type': 'application/json',
        'X-Webhook-Signature': `sha256=${signature}`,
        'X-Delivery-ID': delivery_id
      },
      timeout: 10000
    });

    if (response.status >= 200 && response.status < 300) {
      // Success
      await pool.query(
        'UPDATE deliveries SET status = $1, attempts = $2, updated_at = NOW() WHERE id = $3',
        ['success', attempt + 1, delivery_id]
      );
      return { success: true };
    } else {
      throw new Error(`HTTP ${response.status}`);
    }
  } catch (error) {
    const errorMsg = error instanceof Error ? error.message : String(error);
    const nextRetryAt = calculateNextRetry(attempt);

    if (nextRetryAt === -1) {
      // Permanent failure - will move to DLQ in Module 04
      await pool.query(
        'UPDATE deliveries SET status = $1, attempts = $2, last_error = $3, updated_at = NOW() WHERE id = $4',
        ['failed', attempt + 1, errorMsg, delivery_id]
      );
    } else {
      // Retry scheduled
      await pool.query(
        'UPDATE deliveries SET status = $1, attempts = $2, last_error = $3, next_retry_at = $4, updated_at = NOW() WHERE id = $5',
        ['pending', attempt + 1, errorMsg, new Date(nextRetryAt), delivery_id]
      );

      // Re-enqueue with delay
      throw job.queue.add(job.data, {
        delay: BACKOFF_DELAYS[attempt],
        attempts: 1
      });
    }
  }
});

export default deliveryQueue;

Install axios: npm install axios

✓ Verify: File exists at src/worker/deliveryWorker.ts.
STEP 5

Start the worker as a separate process

Create src/worker.ts:

import './worker/deliveryWorker';

console.log('Webhook delivery worker started');

Update package.json scripts:

"scripts": {
  "dev": "ts-node src/index.ts",
  "worker": "ts-node src/worker.ts",
  ...
}

Now you can run the server and worker separately:

npm run dev        # Terminal 1
npm run worker     # Terminal 2
✓ Verify: Both processes start without errors. Emit an event in Terminal 1, see it being processed in Terminal 2.
⚠ Gotcha: The worker needs access to the same database and Redis. Make sure environment variables are set correctly.
STEP 6

Test retry behavior

Create a test webhook endpoint locally:

// In a separate file or terminal, mock a failing webhook:
// This can be a simple Node server that returns 500 for testing
const express = require('express');
const app = express();
app.post('/webhook', (req, res) => {
  console.log('Received webhook:', req.body);
  res.status(500).send('Temporary failure'); // Will retry
});
app.listen(4000);

Register this webhook and emit an event. Watch the worker retry with exponential backoff.

✓ Verify: Delivery attempts increment. next_retry_at increases exponentially. After 5 failed attempts, status becomes failed.
STEP 7

Commit your work

git add -A
git commit -m "feat: implement reliable delivery with exponential backoff

- Bull + Redis job queue for webhook delivery
- Delivery worker with exponential backoff retries
- Idempotent delivery jobs with unique delivery_id
- Proper state transitions: pending → processing → success/retry"
git push origin main
✓ Verify: git log --oneline shows your commit.
Next Steps

Ready for Module 04?

You now have reliable delivery with retries. Next, you'll implement a dead-letter queue to handle permanent failures. Head to Module 04.