🔥 0
0
Lesson 4 of 5 25 min +250 XP

Building Webhooks

Time to build both sides: sending webhooks to your users, and receiving webhooks from external services.

Part 1: Building a Webhook Sender

When your platform needs to notify users of events, you become the webhook sender.

Architecture Overview

┌─────────────────────────────────────────────────────────────────┐
│  YOUR PLATFORM                                                  │
│                                                                 │
│  ┌──────────┐    ┌─────────────┐    ┌──────────────────┐       │
│  │  Event   │───>│  Webhook    │───>│  Delivery        │       │
│  │  Source  │    │  Queue      │    │  Worker          │       │
│  └──────────┘    └─────────────┘    └────────┬─────────┘       │
│                                              │                  │
└──────────────────────────────────────────────│──────────────────┘
                                               │
                                               ▼
                              ┌────────────────────────────────┐
                              │  User's Webhook Endpoint       │
                              │  https://userapp.com/webhooks  │
                              └────────────────────────────────┘

Database Schema

-- Webhook configurations per customer
CREATE TABLE webhook_endpoints (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  customer_id UUID NOT NULL REFERENCES customers(id),
  url VARCHAR(2048) NOT NULL,
  secret VARCHAR(255) NOT NULL, -- For signing
  events TEXT[] NOT NULL, -- ['order.created', 'order.updated']
  enabled BOOLEAN DEFAULT true,
  created_at TIMESTAMP DEFAULT NOW()
);

-- Webhook deliveries (for retry and audit)
CREATE TABLE webhook_deliveries (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  endpoint_id UUID NOT NULL REFERENCES webhook_endpoints(id),
  event_id VARCHAR(255) NOT NULL,
  event_type VARCHAR(100) NOT NULL,
  payload JSONB NOT NULL,
  status VARCHAR(20) DEFAULT 'pending', -- pending, success, failed
  attempts INT DEFAULT 0,
  last_attempt_at TIMESTAMP,
  response_status INT,
  response_body TEXT,
  next_retry_at TIMESTAMP,
  created_at TIMESTAMP DEFAULT NOW(),
  INDEX idx_status_retry (status, next_retry_at)
);

Webhook Sender Service

const crypto = require('crypto');
const Queue = require('bull');

class WebhookService {
  constructor() {
    this.queue = new Queue('webhooks', process.env.REDIS_URL);
    this.setupWorker();
  }

  // Sign payload using HMAC-SHA256
  signPayload(payload, secret, timestamp) {
    const signedPayload = `${timestamp}.${JSON.stringify(payload)}`;
    return crypto
      .createHmac('sha256', secret)
      .update(signedPayload)
      .digest('hex');
  }

  // Queue webhook for delivery
  async send(customerId, eventType, payload) {
    // Find all endpoints subscribed to this event
    const endpoints = await db.query(
      `SELECT * FROM webhook_endpoints
       WHERE customer_id = $1 AND enabled = true AND $2 = ANY(events)`,
      [customerId, eventType]
    );

    for (const endpoint of endpoints) {
      const eventId = `evt_${crypto.randomBytes(16).toString('hex')}`;
      const timestamp = Math.floor(Date.now() / 1000);

      const webhookPayload = {
        id: eventId,
        type: eventType,
        created: timestamp,
        data: payload
      };

      // Store delivery record
      await db.query(
        `INSERT INTO webhook_deliveries
         (endpoint_id, event_id, event_type, payload, status)
         VALUES ($1, $2, $3, $4, 'pending')`,
        [endpoint.id, eventId, eventType, webhookPayload]
      );

      // Queue for delivery
      await this.queue.add('deliver', {
        endpointId: endpoint.id,
        eventId,
        url: endpoint.url,
        secret: endpoint.secret,
        payload: webhookPayload,
        timestamp
      }, {
        attempts: 5,
        backoff: { type: 'exponential', delay: 60000 }
      });
    }
  }

  // Worker processes the queue
  setupWorker() {
    this.queue.process('deliver', async (job) => {
      const { endpointId, eventId, url, secret, payload, timestamp } = job.data;

      // Generate signature
      const signature = this.signPayload(payload, secret, timestamp);

      try {
        const response = await fetch(url, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'X-Webhook-Signature': `t=${timestamp},v1=${signature}`,
            'X-Webhook-ID': eventId,
            'User-Agent': 'YourPlatform-Webhooks/1.0'
          },
          body: JSON.stringify(payload),
          timeout: 30000 // 30 second timeout
        });

        // Update delivery record
        await db.query(
          `UPDATE webhook_deliveries
           SET status = $1, attempts = attempts + 1,
               last_attempt_at = NOW(), response_status = $2
           WHERE event_id = $3`,
          [response.ok ? 'success' : 'failed', response.status, eventId]
        );

        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }

        return { success: true };

      } catch (error) {
        // Update with failure
        await db.query(
          `UPDATE webhook_deliveries
           SET attempts = attempts + 1, last_attempt_at = NOW(),
               next_retry_at = NOW() + INTERVAL '1 minute' * POW(2, attempts)
           WHERE event_id = $1`,
          [eventId]
        );

        throw error; // Bull will retry
      }
    });
  }
}

// Usage
const webhooks = new WebhookService();

// When an order is created
app.post('/orders', async (req, res) => {
  const order = await createOrder(req.body);

  // Send webhook
  await webhooks.send(order.customerId, 'order.created', {
    order_id: order.id,
    total: order.total,
    items: order.items
  });

  res.json(order);
});

Webhook Management API

Let your users configure their webhooks:

// List webhook endpoints
app.get('/api/webhooks', async (req, res) => {
  const endpoints = await db.query(
    `SELECT id, url, events, enabled, created_at FROM webhook_endpoints
     WHERE customer_id = $1`,
    [req.user.customerId]
  );
  res.json(endpoints);
});

// Create webhook endpoint
app.post('/api/webhooks', async (req, res) => {
  const { url, events } = req.body;

  // Validate URL
  try {
    new URL(url);
  } catch {
    return res.status(400).json({ error: 'Invalid URL' });
  }

  // Generate secret
  const secret = `whsec_${crypto.randomBytes(24).toString('hex')}`;

  const endpoint = await db.query(
    `INSERT INTO webhook_endpoints (customer_id, url, secret, events)
     VALUES ($1, $2, $3, $4)
     RETURNING id, url, secret, events, created_at`,
    [req.user.customerId, url, secret, events]
  );

  res.status(201).json(endpoint);
});

// Test webhook endpoint
app.post('/api/webhooks/:id/test', async (req, res) => {
  const endpoint = await db.query(
    `SELECT * FROM webhook_endpoints WHERE id = $1 AND customer_id = $2`,
    [req.params.id, req.user.customerId]
  );

  if (!endpoint) {
    return res.status(404).json({ error: 'Endpoint not found' });
  }

  // Send test event
  await webhooks.send(req.user.customerId, 'webhook.test', {
    message: 'This is a test webhook'
  });

  res.json({ success: true, message: 'Test webhook sent' });
});

// Get delivery history
app.get('/api/webhooks/:id/deliveries', async (req, res) => {
  const deliveries = await db.query(
    `SELECT id, event_type, status, attempts, response_status, created_at
     FROM webhook_deliveries
     WHERE endpoint_id = $1
     ORDER BY created_at DESC
     LIMIT 100`,
    [req.params.id]
  );
  res.json(deliveries);
});

// Retry failed delivery
app.post('/api/webhooks/deliveries/:id/retry', async (req, res) => {
  const delivery = await db.query(
    `SELECT d.*, e.url, e.secret FROM webhook_deliveries d
     JOIN webhook_endpoints e ON d.endpoint_id = e.id
     WHERE d.id = $1 AND e.customer_id = $2`,
    [req.params.id, req.user.customerId]
  );

  if (!delivery || delivery.status !== 'failed') {
    return res.status(400).json({ error: 'Cannot retry' });
  }

  // Re-queue
  await webhooks.queue.add('deliver', {
    endpointId: delivery.endpoint_id,
    eventId: delivery.event_id,
    url: delivery.url,
    secret: delivery.secret,
    payload: delivery.payload,
    timestamp: Math.floor(Date.now() / 1000)
  });

  res.json({ success: true });
});

Part 2: Building a Webhook Receiver

Now let's build a robust receiver for incoming webhooks.

Full-Featured Receiver

const express = require('express');
const crypto = require('crypto');
const Queue = require('bull');

const app = express();
const webhookQueue = new Queue('incoming-webhooks', process.env.REDIS_URL);

// Webhook receiver for Stripe
app.post('/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const signature = req.headers['stripe-signature'];

    // 1. Verify signature
    let event;
    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        signature,
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (err) {
      console.error('Signature verification failed:', err.message);
      return res.status(401).json({ error: 'Invalid signature' });
    }

    // 2. Check idempotency (quick check before queueing)
    const exists = await redis.get(`webhook:stripe:${event.id}`);
    if (exists) {
      console.log(`Duplicate event ${event.id}, acknowledging`);
      return res.status(200).json({ received: true });
    }

    // 3. Queue for async processing
    await webhookQueue.add('stripe', {
      eventId: event.id,
      eventType: event.type,
      payload: event.data.object,
      receivedAt: Date.now()
    }, {
      attempts: 3,
      backoff: { type: 'exponential', delay: 5000 },
      jobId: event.id // Prevents duplicate jobs
    });

    // 4. Respond immediately
    res.status(200).json({ received: true });
  }
);

// Webhook receiver for GitHub
app.post('/webhooks/github',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const signature = req.headers['x-hub-signature-256'];
    const eventType = req.headers['x-github-event'];
    const deliveryId = req.headers['x-github-delivery'];

    // Verify signature
    const expectedSignature = 'sha256=' + crypto
      .createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET)
      .update(req.body)
      .digest('hex');

    if (!crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expectedSignature)
    )) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    // Queue for processing
    await webhookQueue.add('github', {
      eventId: deliveryId,
      eventType,
      payload: JSON.parse(req.body),
      receivedAt: Date.now()
    }, {
      attempts: 3,
      jobId: deliveryId
    });

    res.status(200).json({ received: true });
  }
);

// Background worker
webhookQueue.process('stripe', async (job) => {
  const { eventId, eventType, payload } = job.data;

  // Idempotency check
  const processed = await redis.get(`webhook:stripe:${eventId}`);
  if (processed) {
    return { skipped: true, reason: 'duplicate' };
  }

  // Process based on event type
  switch (eventType) {
    case 'payment_intent.succeeded':
      await handlePaymentSuccess(payload);
      break;
    case 'customer.subscription.created':
      await handleSubscriptionCreated(payload);
      break;
    case 'customer.subscription.deleted':
      await handleSubscriptionCanceled(payload);
      break;
    case 'invoice.payment_failed':
      await handlePaymentFailed(payload);
      break;
    default:
      console.log(`Unhandled event type: ${eventType}`);
  }

  // Mark as processed (with TTL)
  await redis.setex(`webhook:stripe:${eventId}`, 86400 * 7, 'processed');

  return { success: true };
});

webhookQueue.process('github', async (job) => {
  const { eventId, eventType, payload } = job.data;

  switch (eventType) {
    case 'push':
      await handlePush(payload);
      break;
    case 'pull_request':
      await handlePullRequest(payload);
      break;
    case 'issues':
      await handleIssue(payload);
      break;
  }

  return { success: true };
});

Event Handlers

async function handlePaymentSuccess(paymentIntent) {
  const { id, customer, amount, metadata } = paymentIntent;

  // Find or create order
  const order = await db.query(
    `UPDATE orders SET status = 'paid', paid_at = NOW()
     WHERE payment_intent_id = $1
     RETURNING *`,
    [id]
  );

  if (order) {
    // Send confirmation email
    await emailService.send({
      to: order.customerEmail,
      template: 'order-confirmation',
      data: { order }
    });

    // Notify fulfillment
    await fulfillmentService.queue(order);
  }
}

async function handleSubscriptionCreated(subscription) {
  const { id, customer, status, items } = subscription;

  // Provision access
  await db.query(
    `INSERT INTO subscriptions (stripe_id, customer_id, status, plan_id)
     VALUES ($1, $2, $3, $4)
     ON CONFLICT (stripe_id) DO UPDATE SET status = $3`,
    [id, customer, status, items.data[0].price.id]
  );

  // Send welcome email
  const user = await db.query(
    `SELECT email FROM users WHERE stripe_customer_id = $1`,
    [customer]
  );

  await emailService.send({
    to: user.email,
    template: 'subscription-welcome',
    data: { planName: items.data[0].price.nickname }
  });
}

async function handlePush(payload) {
  const { repository, ref, commits, pusher } = payload;

  // Trigger CI/CD pipeline
  if (ref === 'refs/heads/main') {
    await ciService.triggerBuild({
      repo: repository.full_name,
      branch: 'main',
      commit: commits[0]?.id,
      author: pusher.name
    });
  }
}

Webhook Dashboard UI

Build a dashboard for users to manage webhooks.

Dashboard Features

  • List all webhook endpoints
  • Create/edit/delete endpoints
  • View delivery history and status
  • Retry failed deliveries
  • Send test webhooks
  • View request/response details for debugging
// API for delivery details (for debugging)
app.get('/api/webhooks/deliveries/:id/details', async (req, res) => {
  const delivery = await db.query(
    `SELECT d.*, e.url FROM webhook_deliveries d
     JOIN webhook_endpoints e ON d.endpoint_id = e.id
     WHERE d.id = $1 AND e.customer_id = $2`,
    [req.params.id, req.user.customerId]
  );

  if (!delivery) {
    return res.status(404).json({ error: 'Not found' });
  }

  res.json({
    id: delivery.id,
    eventType: delivery.event_type,
    status: delivery.status,
    attempts: delivery.attempts,
    request: {
      url: delivery.url,
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Webhook-ID': delivery.event_id
      },
      body: delivery.payload
    },
    response: {
      status: delivery.response_status,
      body: delivery.response_body
    },
    timestamps: {
      created: delivery.created_at,
      lastAttempt: delivery.last_attempt_at
    }
  });
});

Key Takeaways

  • Sender: Sign + Retry + Log - HMAC signatures, exponential backoff, delivery records
  • Receiver: Verify + Queue + Idempotent - Signature check, async processing, deduplication
  • Provide management APIs - Let users configure and debug their webhooks
  • Build observability - Delivery history, retry controls, debugging details

Next up: Webhook Assessment - Test your knowledge with real-world scenarios.

🧠 Quick Quiz

Test your understanding of this lesson.

1

When building a webhook sender, what should you do if the receiver returns a 500 error?

2

What header should your webhook sender include for signature verification?

3

Why should webhook senders include event timestamps in payloads?

Reliability Patterns