Skip to content

Building a Webhook Provider — Complete Guide

DodaTech Updated 2026-06-28 8 min read

In this tutorial, you will learn about Building a Webhook Provider. We cover key concepts, practical examples, and best practices to help you master this topic.

Learn to build a webhook provider: subscriber management, event dispatch, signing, retry logic, webhook dashboard, delivery logs, and webhook registration API with Express.

What You Learn

You will build a complete webhook provider system: subscriber CRUD, event publishing, payload signing, delivery with retries, webhook logs, and a consumer-facing dashboard. This is the same architecture used by Stripe, GitHub, and Svix.

Why It Matters

Building a webhook provider gives you full control over how external services integrate with your platform. You define the events, payload format, delivery guarantees, and retry policies. This is essential for SaaS platforms, API products, and enterprise integration features.

Real-World Use

DodaTech's antivirus platform exposes Webhooks for threat detection, scan completion, and license events. The provider system handles 12000 registered endpoints and delivers 500K webhooks daily with 99.95% success rate.

Provider Architecture

graph TD
    API[Registration API] --> DB[(Subscribers DB)]
    App[Your Application] -->|Event occurs| Dispatcher[Event Dispatcher]
    DB --> Dispatcher
    Dispatcher --> Signer[Payload Signer]
    Signer --> Delivery[Delivery Service]
    Delivery --> Retry[Retry Queue]
    Delivery --> Log[Delivery Log]
    Retry --> Delivery
    Log --> Dashboard[Dashboard UI]

The provider system has four main components: registration API for managing subscribers, event dispatcher for routing events, delivery service with retry logic, and dashboard for monitoring.

Subscriber Management

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

class WebhookSubscriberManager {
    constructor() {
        this.subscribers = []; // In production: database
    }

    async registerSubscriber(url, events, options = {}) {
        const subscriber = {
            id: `sub_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
            url,
            events,
            secret: crypto.randomBytes(32).toString('hex'),
            active: true,
            createdAt: new Date().toISOString(),
            options: {
                retryCount: options.retryCount || 5,
                rateLimit: options.rateLimit || 100,
                ...options,
            },
            stats: {
                totalDelivered: 0,
                totalFailed: 0,
                lastDelivery: null,
            },
        };

        this.subscribers.push(subscriber);
        console.log(`Registered subscriber ${subscriber.id} for ${url}`);

        // Send verification webhook
        await this.sendVerification(subscriber);

        return subscriber;
    }

    getSubscriber(id) {
        return this.subscribers.find(s => s.id === id);
    }

    getSubscribersForEvent(eventType) {
        return this.subscribers.filter(
            s => s.active && s.events.includes(eventType)
        );
    }

    async updateSubscriber(id, updates) {
        const subscriber = this.getSubscriber(id);
        if (!subscriber) throw new Error('Subscriber not found');

        Object.assign(subscriber, updates);
        return subscriber;
    }

    async deleteSubscriber(id) {
        const index = this.subscribers.findIndex(s => s.id === id);
        if (index === -1) throw new Error('Subscriber not found');
        this.subscribers.splice(index, 1);
    }

    async sendVerification(subscriber) {
        const challenge = crypto.randomBytes(16).toString('hex');
        // Send GET or POST with challenge to verify endpoint ownership
        console.log(`Verification sent to ${subscriber.url}: ${challenge}`);
    }
}

const subscriberManager = new WebhookSubscriberManager();

// Registration API
const app = express();
app.use(express.json());

app.post('/api/webhooks/subscribers', async (req, res) => {
    try {
        const { url, events, options } = req.body;
        const subscriber = await subscriberManager.registerSubscriber(url, events, options);
        res.status(201).json(subscriber);
    } catch (err) {
        res.status(400).json({ error: err.message });
    }
});

Expected output: Registration creates a subscriber with unique ID, generates a shared secret, and sends verification. The API returns the subscriber object including the secret.

Event Dispatch

class EventDispatcher {
    constructor(subscriberManager, deliveryService) {
        this.subscriberManager = subscriberManager;
        this.deliveryService = deliveryService;
    }

    async dispatch(eventType, data) {
        const subscribers = this.subscriberManager.getSubscribersForEvent(eventType);

        if (subscribers.length === 0) {
            console.log(`No subscribers for event ${eventType}`);
            return { dispatched: 0 };
        }

        const payload = this.buildPayload(eventType, data);
        console.log(`Dispatching ${eventType} to ${subscribers.length} subscribers`);

        const results = await Promise.allSettled(
            subscribers.map(subscriber =>
                this.deliveryService.deliver(subscriber, payload)
            )
        );

        const summary = {
            total: subscribers.length,
            success: results.filter(r => r.status === 'fulfilled' && r.value.success).length,
            failed: results.filter(r => r.status === 'rejected' || !r.value?.success).length,
        };

        console.log(`Dispatch complete: ${summary.success}/${summary.total} delivered`);
        return summary;
    }

    buildPayload(eventType, data) {
        return {
            specversion: '1.0',
            id: `evt_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,
            source: '/events',
            type: eventType,
            datacontenttype: 'application/json',
            time: new Date().toISOString(),
            data,
        };
    }
}

Expected output: Event dispatch finds all subscribers for the event type, builds a standardized payload, and delivers to each subscriber concurrently. Results are summarized.

Delivery Service with Retry

class DeliveryService {
    constructor(subscriberManager) {
        this.subscriberManager = subscriberManager;
        this.deliveryLog = [];
        this.maxLogEntries = 10000;
    }

    async deliver(subscriber, payload) {
        const startTime = Date.now();
        const body = JSON.stringify(payload);
        const signature = this.signPayload(body, subscriber.secret);

        let lastError = null;

        for (let attempt = 1; attempt <= subscriber.options.retryCount; attempt++) {
            try {
                const response = await fetch(subscriber.url, {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                        'X-Webhook-ID': payload.id,
                        'X-Webhook-Signature': `sha256=${signature}`,
                        'X-Webhook-Timestamp': payload.time,
                        'User-Agent': 'DodaTech-Webhook/1.0',
                    },
                    body,
                    signal: AbortSignal.timeout(10000),
                });

                const duration = Date.now() - startTime;
                const success = response.ok;

                this.logDelivery({
                    subscriberId: subscriber.id,
                    webhookId: payload.id,
                    attempt,
                    success,
                    statusCode: response.status,
                    duration,
                });

                if (success) {
                    subscriber.stats.totalDelivered++;
                    subscriber.stats.lastDelivery = new Date().toISOString();
                    return { success: true, attempt };
                }

                if (response.status >= 400 && response.status < 500 &&
                    response.status !== 429) {
                    // Client error, no point retrying
                    return { success: false, status: response.status, attempt };
                }

                lastError = `HTTP ${response.status}`;

            } catch (err) {
                lastError = err.message;
                this.logDelivery({
                    subscriberId: subscriber.id,
                    webhookId: payload.id,
                    attempt,
                    success: false,
                    error: err.message,
                    duration: Date.now() - startTime,
                });
            }

            if (attempt < subscriber.options.retryCount) {
                const delay = Math.min(1000 * Math.pow(2, attempt), 60000);
                await new Promise(r => setTimeout(r, delay));
            }
        }

        subscriber.stats.totalFailed++;
        return { success: false, error: lastError, attempts: subscriber.options.retryCount };
    }

    signPayload(body, secret) {
        return crypto.createHmac('sha256', secret).update(body).digest('hex');
    }

    logDelivery(entry) {
        this.deliveryLog.push({
            ...entry,
            timestamp: new Date().toISOString(),
        });

        if (this.deliveryLog.length > this.maxLogEntries) {
            this.deliveryLog.shift();
        }
    }

    getDeliveryLog(subscriberId, limit = 50) {
        const entries = subscriberId
            ? this.deliveryLog.filter(e => e.subscriberId === subscriberId)
            : this.deliveryLog;
        return entries.slice(-limit);
    }
}

Expected output: Delivery service signs the payload with the subscriber's secret, delivers with retry and exponential backoff, logs every attempt, and updates subscriber statistics.

Provider Dashboard

// Dashboard API endpoints
app.get('/api/webhooks/subscribers', (req, res) => {
    const subscribers = subscriberManager.subscribers.map(s => ({
        id: s.id,
        url: s.url,
        events: s.events,
        active: s.active,
        createdAt: s.createdAt,
        stats: s.stats,
    }));
    res.json(subscribers);
});

app.get('/api/webhooks/subscribers/:id', (req, res) => {
    const subscriber = subscriberManager.getSubscriber(req.params.id);
    if (!subscriber) return res.status(404).json({ error: 'Not found' });
    res.json(subscriber);
});

app.get('/api/webhooks/deliveries', (req, res) => {
    const { subscriberId, limit } = req.query;
    const log = deliveryService.getDeliveryLog(subscriberId, parseInt(limit) || 50);
    res.json(log);
});

app.get('/api/webhooks/stats', (req, res) => {
    const stats = {
        totalSubscribers: subscriberManager.subscribers.length,
        activeSubscribers: subscriberManager.subscribers.filter(s => s.active).length,
        totalDeliveries: deliveryService.deliveryLog.length,
        successRate: calculateSuccessRate(deliveryService.deliveryLog),
        deliveriesByHour: groupByHour(deliveryService.deliveryLog),
    };
    res.json(stats);
});

function calculateSuccessRate(log) {
    if (log.length === 0) return 100;
    const successful = log.filter(e => e.success).length;
    return (successful / log.length * 100).toFixed(2);
}

function groupByHour(log) {
    const groups = {};
    for (const entry of log) {
        const hour = new Date(entry.timestamp).toISOString().slice(0, 13);
        groups[hour] = (groups[hour] || 0) + 1;
    }
    return groups;
}

Expected output: Dashboard endpoints provide subscriber list, delivery log for debugging, and aggregate statistics for monitoring. Data supports building a UI dashboard.

Common Mistakes

1. Not Validating Subscriber URLs

Accepting any URL allows subscribers to point to internal services (localhost, 169.254.x.x). Validate URLs against a blocklist of private IP ranges. Verify endpoint ownership before activation.

2. No Rate Limiting Per Subscriber

One aggressive subscriber can flood your system with retries. Set per-subscriber rate limits. Reject webhooks from subscribers who exceed limits. Implement a fair queuing system.

3. Synchronous Dispatch Blocking Events

If dispatching to 1000 subscribers synchronously, a single slow subscriber blocks all others. Dispatch asynchronously. Set per-subscriber timeouts. Use a worker pool for delivery.

4. No Webhook IDempotency for Duplicate Events

If your system fires the same event twice, subscribers receive duplicates. Include unique event IDs. Subscribers use them for idempotency. Deduplicate at the provider level if possible.

5. Not Providing a Test Mode

Subscribers cannot test integration without real events. Provide a test mode: send test webhooks from the dashboard, simulate event types, and show delivery logs in real time.

Practice Questions

1. What components does a webhook provider need?

Registration API, subscriber database, event dispatcher, payload signer, delivery service with retry, delivery log, and monitoring dashboard. Optional: dead letter queue, rate limiter, test mode.

2. Why must subscriber URLs be validated?

To prevent SSRF Attacks. Subscribers could register URLs pointing to internal services (localhost, cloud metadata endpoints). Validate against private IP ranges and verify endpoint ownership.

3. How do you handle slow subscribers?

Set per-subscriber timeouts (10-30 seconds). Deliver asynchronously. If a subscriber is consistently slow, reduce their priority or move them to a separate delivery queue.

4. What information should the delivery log contain?

Webhook ID, subscriber ID, attempt number, success/failure, status code, error message, duration, and timestamp. This enables debugging delivery issues.

Challenge

Build the complete provider system: subscriber CRUD API, event dispatch for 5 event types, HMAC-SHA256 signing, delivery with exponential backoff (max 5 retries), delivery log with 1000 entry cap, dashboard stats endpoint, and URL validation blocking private IPs.

FAQ

How do I verify a subscriber owns the URL?

Send a verification request with a challenge token. The subscriber must respond with the token or echo it back. This proves control of the endpoint and prevents unauthorized registrations.

What happens if a subscriber changes their URL?

Provide an update endpoint. The subscriber sends the new URL. Send a verification request to the new URL. Mark the subscriber inactive until verification completes.

How many subscribers can one event have?

Unlimited in theory. In practice, delivery latency increases with subscriber count. Use async dispatch with worker pools. Consider fan-out to a message queue for high subscriber counts.

Should I charge for webhook delivery?

Common for SaaS platforms. Free tier: 1000 webhooks/month. Paid tiers: higher limits. Monitor delivery costs. Webhook delivery can be a significant infrastructure expense at scale.

How do I handle subscriber deactivation?

Provide activate/deactivate endpoints. Disabled subscribers do not receive events. Queue events during deactivation and deliver on reactivation (optional). Log deactivation events.

Mini Project: Provider Dashboard UI

Build a web UI for the provider system: subscriber list with search and filter, subscriber detail with delivery history, event dispatch simulator for testing, delivery log with status indicators, stats dashboard with charts, and webhook inspector for viewing raw payloads.

What's Next

Now that you can build a provider, learn how to Build a Webhook Consumer that receives, verifies, and processes webhooks reliably.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro