Home/Blog/Building a Webhook Provider: Design, Delivery, Documentation & SDK Guide

Building a Webhook Provider: Design, Delivery, Documentation & SDK Guide

Learn to build production-grade webhook delivery systems. Master webhook API design, reliable delivery infrastructure, signature verification, retry logic, documentation standards, and client SDK development.

By Inventive Software Engineering
Building a Webhook Provider: Design, Delivery, Documentation & SDK Guide

Building a webhook system that other developers will integrate with requires careful API design, reliable infrastructure, comprehensive documentation, and developer-friendly SDKs. This guide covers everything you need to build a production-grade webhook provider from the ground up.

Webhook Provider Architecture

┌──────────────────────────────────────────────────────────────────────────────┐
│                    WEBHOOK PROVIDER ARCHITECTURE                              │
├──────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│   Your Application                                                           │
│   ─────────────────                                                          │
│   ┌──────────────┐                                                          │
│   │ Event Source │  (User actions, system events, scheduled jobs)            │
│   └──────┬───────┘                                                          │
│          │                                                                   │
│          ▼                                                                   │
│   ┌──────────────┐     ┌──────────────┐     ┌──────────────┐               │
│   │   Event      │────▶│   Webhook    │────▶│   Delivery   │               │
│   │   Emitter    │     │   Queue      │     │   Workers    │               │
│   └──────────────┘     └──────────────┘     └──────┬───────┘               │
│                                                     │                        │
│                                                     │                        │
│   ┌─────────────────────────────────────────────────┼────────────────────┐  │
│   │                    Delivery Pipeline            │                    │  │
│   │                    ─────────────────            │                    │  │
│   │                                                 ▼                    │  │
│   │  ┌──────────┐   ┌──────────┐   ┌──────────┐   ┌──────────┐         │  │
│   │  │ Lookup   │──▶│  Sign    │──▶│  Send    │──▶│  Record  │         │  │
│   │  │ Endpoint │   │  Payload │   │  Request │   │  Result  │         │  │
│   │  └──────────┘   └──────────┘   └──────────┘   └──────────┘         │  │
│   │                                      │                               │  │
│   │                                      ▼                               │  │
│   │                              ┌──────────────┐                        │  │
│   │                              │ Retry Queue  │                        │  │
│   │                              │ (on failure) │                        │  │
│   │                              └──────────────┘                        │  │
│   └──────────────────────────────────────────────────────────────────────┘  │
│                                                                              │
│   Customer Endpoints                                                         │
│   ──────────────────                                                         │
│   ┌──────────────┐  ┌──────────────┐  ┌──────────────┐                     │
│   │  Customer A  │  │  Customer B  │  │  Customer C  │                     │
│   │  Endpoint    │  │  Endpoint    │  │  Endpoint    │                     │
│   └──────────────┘  └──────────────┘  └──────────────┘                     │
│                                                                              │
└──────────────────────────────────────────────────────────────────────────────┘

Event Model Design

Standard Event Envelope

// event-types.ts
interface WebhookEvent<T = any> {
  // Unique identifier for this event
  id: string;

  // Event type using dot notation
  type: string;

  // API version for payload schema
  api_version: string;

  // When the event was created
  created_at: string; // ISO 8601

  // The actual event data
  data: {
    // The primary object that triggered the event
    object: T;

    // For update events, include previous values
    previous_attributes?: Partial<T>;
  };

  // Indicates if this is from a live or test environment
  livemode: boolean;

  // For webhook deliveries
  webhook_metadata?: {
    delivery_id: string;
    attempt: number;
    endpoint_id: string;
  };
}

// Example event types
type OrderCreatedEvent = WebhookEvent<{
  id: string;
  customer_id: string;
  total: number;
  currency: string;
  status: 'pending';
  items: OrderItem[];
  created_at: string;
}>;

type OrderUpdatedEvent = WebhookEvent<{
  id: string;
  status: 'processing' | 'shipped' | 'delivered';
  updated_at: string;
}>;

Event Type Naming Convention

// event-catalog.ts
export const EventTypes = {
  // Resource lifecycle events
  'order.created': 'When a new order is placed',
  'order.updated': 'When an order is modified',
  'order.paid': 'When payment is confirmed',
  'order.fulfilled': 'When order is shipped',
  'order.cancelled': 'When order is cancelled',
  'order.refunded': 'When order is refunded',

  'customer.created': 'When a new customer registers',
  'customer.updated': 'When customer info changes',
  'customer.deleted': 'When customer account is deleted',

  'subscription.created': 'When subscription starts',
  'subscription.updated': 'When subscription changes',
  'subscription.cancelled': 'When subscription is cancelled',
  'subscription.renewed': 'When subscription auto-renews',

  'invoice.created': 'When invoice is generated',
  'invoice.paid': 'When invoice is paid',
  'invoice.payment_failed': 'When payment attempt fails',

  // System events
  'ping': 'Test event for endpoint verification',
} as const;

export type EventType = keyof typeof EventTypes;

Creating Events

// event-emitter.ts
import { nanoid } from 'nanoid';
import { webhookQueue } from './queue';

class EventEmitter {
  private apiVersion = '2024-01-15';

  async emit<T>(
    type: EventType,
    data: T,
    options: {
      previousAttributes?: Partial<T>;
      livemode?: boolean;
      customerId?: string;
    } = {}
  ): Promise<string> {
    const eventId = `evt_${nanoid(24)}`;

    const event: WebhookEvent<T> = {
      id: eventId,
      type,
      api_version: this.apiVersion,
      created_at: new Date().toISOString(),
      data: {
        object: data,
        ...(options.previousAttributes && {
          previous_attributes: options.previousAttributes
        })
      },
      livemode: options.livemode ?? process.env.NODE_ENV === 'production'
    };

    // Store event for replay capability
    await this.storeEvent(event);

    // Queue for delivery
    await this.queueDeliveries(event, options.customerId);

    return eventId;
  }

  private async storeEvent(event: WebhookEvent): Promise<void> {
    await db.query(`
      INSERT INTO webhook_events (id, type, api_version, data, created_at)
      VALUES ($1, $2, $3, $4, $5)
    `, [event.id, event.type, event.api_version, JSON.stringify(event.data), event.created_at]);
  }

  private async queueDeliveries(
    event: WebhookEvent,
    customerId?: string
  ): Promise<void> {
    // Find all endpoints subscribed to this event type
    const endpoints = await this.getSubscribedEndpoints(event.type, customerId);

    for (const endpoint of endpoints) {
      await webhookQueue.add('delivery', {
        eventId: event.id,
        endpointId: endpoint.id,
        url: endpoint.url,
        secret: endpoint.secret,
        event
      }, {
        attempts: 5,
        backoff: {
          type: 'exponential',
          delay: 60000 // Start at 1 minute
        }
      });
    }
  }

  private async getSubscribedEndpoints(
    eventType: string,
    customerId?: string
  ): Promise<WebhookEndpoint[]> {
    let query = `
      SELECT * FROM webhook_endpoints
      WHERE enabled = true
        AND (subscribed_events @> $1 OR subscribed_events @> '["*"]')
    `;
    const params: any[] = [JSON.stringify([eventType])];

    if (customerId) {
      query += ` AND customer_id = $2`;
      params.push(customerId);
    }

    const result = await db.query(query, params);
    return result.rows;
  }
}

export const eventEmitter = new EventEmitter();

Delivery Infrastructure

Webhook Delivery Worker

// delivery-worker.ts
import { Worker, Job } from 'bullmq';
import crypto from 'crypto';
import { metrics } from './metrics';

interface DeliveryJob {
  eventId: string;
  endpointId: string;
  url: string;
  secret: string;
  event: WebhookEvent;
}

const deliveryWorker = new Worker<DeliveryJob>(
  'webhook-delivery',
  async (job: Job<DeliveryJob>) => {
    const { eventId, endpointId, url, secret, event } = job.data;
    const deliveryId = `del_${nanoid(24)}`;
    const attempt = job.attemptsMade + 1;

    // Add delivery metadata
    const eventWithMetadata = {
      ...event,
      webhook_metadata: {
        delivery_id: deliveryId,
        attempt,
        endpoint_id: endpointId
      }
    };

    // Sign the payload
    const timestamp = Math.floor(Date.now() / 1000);
    const payload = JSON.stringify(eventWithMetadata);
    const signature = signPayload(payload, timestamp, secret);

    const startTime = Date.now();

    try {
      const response = await fetch(url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'User-Agent': 'YourApp-Webhooks/1.0',
          'X-Webhook-ID': deliveryId,
          'X-Webhook-Timestamp': timestamp.toString(),
          'X-Webhook-Signature': signature,
          'X-Webhook-Event': event.type
        },
        body: payload,
        signal: AbortSignal.timeout(30000) // 30 second timeout
      });

      const duration = Date.now() - startTime;
      const responseBody = await response.text().catch(() => '');

      // Log delivery attempt
      await logDeliveryAttempt({
        deliveryId,
        eventId,
        endpointId,
        attempt,
        url,
        statusCode: response.status,
        responseBody: responseBody.slice(0, 1000),
        duration,
        success: response.ok
      });

      // Update metrics
      metrics.deliveryDuration.observe({ endpoint_id: endpointId }, duration / 1000);

      if (!response.ok) {
        metrics.deliveryFailures.inc({
          endpoint_id: endpointId,
          status_code: response.status
        });

        // Don't retry on client errors (except 429)
        if (response.status >= 400 && response.status < 500 && response.status !== 429) {
          // Log but don't retry
          return {
            success: false,
            statusCode: response.status,
            noRetry: true
          };
        }

        throw new Error(`HTTP ${response.status}: ${responseBody.slice(0, 200)}`);
      }

      metrics.deliverySuccesses.inc({ endpoint_id: endpointId });

      return {
        success: true,
        statusCode: response.status,
        duration
      };

    } catch (error: any) {
      const duration = Date.now() - startTime;

      await logDeliveryAttempt({
        deliveryId,
        eventId,
        endpointId,
        attempt,
        url,
        statusCode: 0,
        error: error.message,
        duration,
        success: false
      });

      // Check if endpoint is consistently failing
      await checkEndpointHealth(endpointId);

      throw error;
    }
  },
  {
    concurrency: 50,
    limiter: {
      max: 100,
      duration: 1000
    }
  }
);

function signPayload(payload: string, timestamp: number, secret: string): string {
  const signedPayload = `${timestamp}.${payload}`;
  const signature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');
  return `t=${timestamp},v1=${signature}`;
}

Endpoint Health Management

// endpoint-health.ts
interface EndpointHealth {
  endpointId: string;
  consecutiveFailures: number;
  lastSuccess: Date | null;
  lastFailure: Date | null;
  status: 'healthy' | 'degraded' | 'disabled';
}

async function checkEndpointHealth(endpointId: string): Promise<void> {
  const recentDeliveries = await db.query(`
    SELECT success, created_at
    FROM webhook_deliveries
    WHERE endpoint_id = $1
    ORDER BY created_at DESC
    LIMIT 10
  `, [endpointId]);

  const failures = recentDeliveries.rows.filter(d => !d.success);

  if (failures.length >= 10) {
    // All recent deliveries failed - disable endpoint
    await disableEndpoint(endpointId, 'Consecutive delivery failures');
  } else if (failures.length >= 5) {
    // Many failures - mark as degraded
    await markEndpointDegraded(endpointId);
  }
}

async function disableEndpoint(endpointId: string, reason: string): Promise<void> {
  await db.query(`
    UPDATE webhook_endpoints
    SET enabled = false, disabled_reason = $2, disabled_at = NOW()
    WHERE id = $1
  `, [endpointId, reason]);

  // Notify the customer
  const endpoint = await getEndpoint(endpointId);
  await sendEndpointDisabledNotification(endpoint.customer_id, {
    endpointId,
    url: endpoint.url,
    reason
  });
}

async function markEndpointDegraded(endpointId: string): Promise<void> {
  await db.query(`
    UPDATE webhook_endpoints
    SET status = 'degraded', degraded_at = NOW()
    WHERE id = $1
  `, [endpointId]);
}

Webhook Registration API

Endpoint Management

// webhook-api.ts
import express from 'express';
import { nanoid } from 'nanoid';
import crypto from 'crypto';

const router = express.Router();

// List webhook endpoints
router.get('/webhook-endpoints', async (req, res) => {
  const customerId = req.auth.customerId;

  const endpoints = await db.query(`
    SELECT id, url, description, subscribed_events, enabled, status,
           created_at, disabled_reason
    FROM webhook_endpoints
    WHERE customer_id = $1
    ORDER BY created_at DESC
  `, [customerId]);

  res.json({ data: endpoints.rows });
});

// Create webhook endpoint
router.post('/webhook-endpoints', async (req, res) => {
  const customerId = req.auth.customerId;
  const { url, description, events } = req.body;

  // Validate URL
  if (!isValidWebhookUrl(url)) {
    return res.status(400).json({
      error: {
        code: 'invalid_url',
        message: 'URL must be HTTPS and publicly accessible'
      }
    });
  }

  // Validate events
  const validEvents = events.filter((e: string) =>
    e === '*' || EventTypes[e as EventType]
  );

  if (validEvents.length === 0) {
    return res.status(400).json({
      error: {
        code: 'invalid_events',
        message: 'At least one valid event type required'
      }
    });
  }

  // Generate endpoint ID and secret
  const endpointId = `we_${nanoid(24)}`;
  const secret = `whsec_${crypto.randomBytes(32).toString('base64url')}`;

  await db.query(`
    INSERT INTO webhook_endpoints (
      id, customer_id, url, description, secret,
      subscribed_events, enabled, status
    ) VALUES ($1, $2, $3, $4, $5, $6, true, 'healthy')
  `, [endpointId, customerId, url, description, secret, JSON.stringify(validEvents)]);

  // Send test ping
  await sendTestPing(endpointId, url, secret);

  res.status(201).json({
    data: {
      id: endpointId,
      url,
      description,
      subscribed_events: validEvents,
      secret, // Only shown once!
      enabled: true,
      status: 'healthy',
      created_at: new Date().toISOString()
    }
  });
});

// Update webhook endpoint
router.patch('/webhook-endpoints/:id', async (req, res) => {
  const customerId = req.auth.customerId;
  const endpointId = req.params.id;
  const { url, description, events, enabled } = req.body;

  // Verify ownership
  const endpoint = await getEndpointIfOwned(endpointId, customerId);
  if (!endpoint) {
    return res.status(404).json({ error: { code: 'not_found' } });
  }

  const updates: string[] = [];
  const values: any[] = [];
  let paramIndex = 1;

  if (url !== undefined) {
    if (!isValidWebhookUrl(url)) {
      return res.status(400).json({
        error: { code: 'invalid_url' }
      });
    }
    updates.push(`url = $${paramIndex++}`);
    values.push(url);
  }

  if (description !== undefined) {
    updates.push(`description = $${paramIndex++}`);
    values.push(description);
  }

  if (events !== undefined) {
    updates.push(`subscribed_events = $${paramIndex++}`);
    values.push(JSON.stringify(events));
  }

  if (enabled !== undefined) {
    updates.push(`enabled = $${paramIndex++}`);
    values.push(enabled);
    if (enabled) {
      updates.push(`disabled_reason = NULL, disabled_at = NULL`);
      updates.push(`status = 'healthy'`);
    }
  }

  if (updates.length === 0) {
    return res.status(400).json({
      error: { code: 'no_updates' }
    });
  }

  values.push(endpointId);
  await db.query(`
    UPDATE webhook_endpoints
    SET ${updates.join(', ')}, updated_at = NOW()
    WHERE id = $${paramIndex}
  `, values);

  const updated = await getEndpoint(endpointId);
  res.json({ data: updated });
});

// Delete webhook endpoint
router.delete('/webhook-endpoints/:id', async (req, res) => {
  const customerId = req.auth.customerId;
  const endpointId = req.params.id;

  const endpoint = await getEndpointIfOwned(endpointId, customerId);
  if (!endpoint) {
    return res.status(404).json({ error: { code: 'not_found' } });
  }

  await db.query('DELETE FROM webhook_endpoints WHERE id = $1', [endpointId]);

  res.status(204).send();
});

// Rotate secret
router.post('/webhook-endpoints/:id/rotate-secret', async (req, res) => {
  const customerId = req.auth.customerId;
  const endpointId = req.params.id;

  const endpoint = await getEndpointIfOwned(endpointId, customerId);
  if (!endpoint) {
    return res.status(404).json({ error: { code: 'not_found' } });
  }

  const newSecret = `whsec_${crypto.randomBytes(32).toString('base64url')}`;

  // Keep old secret valid for 24 hours
  await db.query(`
    UPDATE webhook_endpoints
    SET secret = $1,
        previous_secret = secret,
        previous_secret_expires_at = NOW() + INTERVAL '24 hours'
    WHERE id = $2
  `, [newSecret, endpointId]);

  res.json({
    data: {
      secret: newSecret,
      previous_secret_expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
    }
  });
});

export default router;

Delivery History API

// delivery-history-api.ts
router.get('/webhook-endpoints/:id/deliveries', async (req, res) => {
  const customerId = req.auth.customerId;
  const endpointId = req.params.id;

  const endpoint = await getEndpointIfOwned(endpointId, customerId);
  if (!endpoint) {
    return res.status(404).json({ error: { code: 'not_found' } });
  }

  const { limit = 20, starting_after, event_type, status } = req.query;

  let query = `
    SELECT
      d.id as delivery_id,
      d.event_id,
      e.type as event_type,
      d.attempt,
      d.status_code,
      d.success,
      d.duration_ms,
      d.error,
      d.created_at
    FROM webhook_deliveries d
    JOIN webhook_events e ON d.event_id = e.id
    WHERE d.endpoint_id = $1
  `;
  const params: any[] = [endpointId];
  let paramIndex = 2;

  if (starting_after) {
    query += ` AND d.created_at < (SELECT created_at FROM webhook_deliveries WHERE id = $${paramIndex++})`;
    params.push(starting_after);
  }

  if (event_type) {
    query += ` AND e.type = $${paramIndex++}`;
    params.push(event_type);
  }

  if (status === 'success') {
    query += ` AND d.success = true`;
  } else if (status === 'failed') {
    query += ` AND d.success = false`;
  }

  query += ` ORDER BY d.created_at DESC LIMIT $${paramIndex}`;
  params.push(parseInt(limit as string) + 1);

  const result = await db.query(query, params);
  const deliveries = result.rows.slice(0, parseInt(limit as string));
  const hasMore = result.rows.length > parseInt(limit as string);

  res.json({
    data: deliveries,
    has_more: hasMore
  });
});

// Retry a failed delivery
router.post('/webhook-endpoints/:id/deliveries/:deliveryId/retry', async (req, res) => {
  const customerId = req.auth.customerId;
  const { id: endpointId, deliveryId } = req.params;

  const endpoint = await getEndpointIfOwned(endpointId, customerId);
  if (!endpoint) {
    return res.status(404).json({ error: { code: 'not_found' } });
  }

  // Get the original event
  const delivery = await db.query(`
    SELECT d.*, e.data as event_data, e.type as event_type
    FROM webhook_deliveries d
    JOIN webhook_events e ON d.event_id = e.id
    WHERE d.id = $1 AND d.endpoint_id = $2
  `, [deliveryId, endpointId]);

  if (!delivery.rows[0]) {
    return res.status(404).json({ error: { code: 'delivery_not_found' } });
  }

  // Queue for retry
  await webhookQueue.add('delivery', {
    eventId: delivery.rows[0].event_id,
    endpointId,
    url: endpoint.url,
    secret: endpoint.secret,
    event: {
      id: delivery.rows[0].event_id,
      type: delivery.rows[0].event_type,
      data: delivery.rows[0].event_data
    },
    manualRetry: true
  });

  res.json({
    data: {
      message: 'Delivery queued for retry'
    }
  });
});

Signature Verification SDKs

Node.js SDK

// sdk/node/src/index.ts
import crypto from 'crypto';

export interface WebhookEvent {
  id: string;
  type: string;
  api_version: string;
  created_at: string;
  data: {
    object: any;
    previous_attributes?: any;
  };
  livemode: boolean;
}

export interface VerifyOptions {
  tolerance?: number; // Timestamp tolerance in seconds
}

export class WebhookSignatureError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'WebhookSignatureError';
  }
}

export function constructEvent(
  payload: string | Buffer,
  signatureHeader: string,
  secret: string,
  options: VerifyOptions = {}
): WebhookEvent {
  const tolerance = options.tolerance ?? 300; // 5 minutes default

  // Parse signature header
  const parts = signatureHeader.split(',');
  const timestamp = parts.find(p => p.startsWith('t='))?.slice(2);
  const signatures = parts
    .filter(p => p.startsWith('v1='))
    .map(p => p.slice(3));

  if (!timestamp || signatures.length === 0) {
    throw new WebhookSignatureError('Invalid signature format');
  }

  // Check timestamp
  const timestampNum = parseInt(timestamp, 10);
  const now = Math.floor(Date.now() / 1000);

  if (Math.abs(now - timestampNum) > tolerance) {
    throw new WebhookSignatureError(
      `Timestamp outside tolerance. Event timestamp: ${timestampNum}, current: ${now}`
    );
  }

  // Verify signature
  const payloadString = typeof payload === 'string' ? payload : payload.toString('utf8');
  const signedPayload = `${timestamp}.${payloadString}`;
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  const signatureValid = signatures.some(sig => {
    try {
      return crypto.timingSafeEqual(
        Buffer.from(sig, 'hex'),
        Buffer.from(expectedSignature, 'hex')
      );
    } catch {
      return false;
    }
  });

  if (!signatureValid) {
    throw new WebhookSignatureError('Invalid signature');
  }

  // Parse and return event
  try {
    return JSON.parse(payloadString);
  } catch {
    throw new WebhookSignatureError('Invalid JSON payload');
  }
}

// Express middleware
export function webhookMiddleware(secret: string, options?: VerifyOptions) {
  return (req: any, res: any, next: any) => {
    const signature = req.headers['x-webhook-signature'];

    if (!signature) {
      return res.status(400).json({ error: 'Missing signature header' });
    }

    try {
      req.webhookEvent = constructEvent(req.body, signature, secret, options);
      next();
    } catch (error) {
      if (error instanceof WebhookSignatureError) {
        return res.status(401).json({ error: error.message });
      }
      throw error;
    }
  };
}

Python SDK

# sdk/python/yourapp/webhooks.py
import hmac
import hashlib
import time
import json
from typing import Any, Optional
from dataclasses import dataclass

class WebhookSignatureError(Exception):
    """Raised when webhook signature verification fails."""
    pass

@dataclass
class WebhookEvent:
    id: str
    type: str
    api_version: str
    created_at: str
    data: dict
    livemode: bool

def construct_event(
    payload: bytes | str,
    signature_header: str,
    secret: str,
    tolerance: int = 300
) -> WebhookEvent:
    """
    Verify webhook signature and return the event.

    Args:
        payload: Raw request body
        signature_header: Value of X-Webhook-Signature header
        secret: Your webhook endpoint secret
        tolerance: Maximum age of event in seconds (default: 300)

    Returns:
        WebhookEvent object

    Raises:
        WebhookSignatureError: If signature verification fails
    """
    if isinstance(payload, str):
        payload = payload.encode('utf-8')

    # Parse signature header
    parts = dict(p.split('=', 1) for p in signature_header.split(',') if '=' in p)

    timestamp = parts.get('t')
    signature = parts.get('v1')

    if not timestamp or not signature:
        raise WebhookSignatureError("Invalid signature format")

    # Check timestamp
    try:
        timestamp_int = int(timestamp)
    except ValueError:
        raise WebhookSignatureError("Invalid timestamp")

    now = int(time.time())
    if abs(now - timestamp_int) > tolerance:
        raise WebhookSignatureError(
            f"Timestamp outside tolerance. Event: {timestamp_int}, Current: {now}"
        )

    # Verify signature
    signed_payload = f"{timestamp}.{payload.decode('utf-8')}"
    expected_signature = hmac.new(
        secret.encode('utf-8'),
        signed_payload.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(signature, expected_signature):
        raise WebhookSignatureError("Invalid signature")

    # Parse and return event
    try:
        data = json.loads(payload)
        return WebhookEvent(
            id=data['id'],
            type=data['type'],
            api_version=data['api_version'],
            created_at=data['created_at'],
            data=data['data'],
            livemode=data.get('livemode', True)
        )
    except (json.JSONDecodeError, KeyError) as e:
        raise WebhookSignatureError(f"Invalid payload: {e}")


# Flask decorator
def webhook_handler(secret: str, tolerance: int = 300):
    """
    Flask decorator for webhook handlers.

    Usage:
        @app.route('/webhooks', methods=['POST'])
        @webhook_handler(secret='whsec_...')
        def handle_webhook(event):
            if event.type == 'order.created':
                # Handle order
                pass
            return {'received': True}
    """
    from functools import wraps
    from flask import request, jsonify

    def decorator(f):
        @wraps(f)
        def wrapper(*args, **kwargs):
            signature = request.headers.get('X-Webhook-Signature')
            if not signature:
                return jsonify({'error': 'Missing signature'}), 400

            try:
                event = construct_event(
                    request.get_data(),
                    signature,
                    secret,
                    tolerance
                )
                return f(event, *args, **kwargs)
            except WebhookSignatureError as e:
                return jsonify({'error': str(e)}), 401

        return wrapper
    return decorator

Documentation Standards

API Documentation Template

# Webhooks

Webhooks allow you to receive real-time notifications when events happen in your account.

## Setting Up Webhooks

1. Create a webhook endpoint in your dashboard or via API
2. Configure which events to receive
3. Implement signature verification
4. Return a 2xx response within 30 seconds

## Event Types

| Event | Description |
|-------|-------------|
| `order.created` | Sent when a new order is placed |
| `order.paid` | Sent when payment is confirmed |
| `order.fulfilled` | Sent when order ships |
| `customer.created` | Sent when a customer registers |

## Payload Format

All events follow this structure:

```json
{
  "id": "evt_1234567890abcdef",
  "type": "order.created",
  "api_version": "2024-01-15",
  "created_at": "2024-01-15T10:30:00Z",
  "data": {
    "object": {
      // Resource data
    }
  },
  "livemode": true
}

Signature Verification

All webhook requests include a signature header for verification:

X-Webhook-Signature: t=1705312200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

Verification Steps

  1. Extract timestamp (t) and signature (v1) from header
  2. Concatenate timestamp + "." + raw request body
  3. Compute HMAC-SHA256 using your endpoint secret
  4. Compare signatures using constant-time comparison

Code Examples

Node.js:

const event = yourapp.webhooks.constructEvent(
  req.body,
  req.headers['x-webhook-signature'],
  process.env.WEBHOOK_SECRET
);

Python:

event = yourapp.webhooks.construct_event(
    request.get_data(),
    request.headers['X-Webhook-Signature'],
    os.environ['WEBHOOK_SECRET']
)

Retry Policy

Failed deliveries are retried with exponential backoff:

AttemptDelay
1Immediate
21 minute
35 minutes
430 minutes
52 hours

Retries stop after:

  • Successful delivery (2xx response)
  • 4xx response (except 429)
  • Maximum attempts reached

Best Practices

  1. Respond quickly - Return 200 within 30 seconds
  2. Process asynchronously - Queue events for processing
  3. Verify signatures - Always validate before processing
  4. Handle duplicates - Events may be delivered more than once
  5. Use HTTPS - Required for production endpoints

Testing

Use our CLI to test webhooks locally:

yourapp webhooks listen --forward-to localhost:3000/webhooks
yourapp webhooks trigger order.created

Or use the webhook simulator in your dashboard.


### OpenAPI Specification

```yaml
# openapi.yaml (webhook section)
paths:
  /webhook-endpoints:
    get:
      summary: List webhook endpoints
      tags: [Webhooks]
      responses:
        '200':
          description: List of endpoints
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/WebhookEndpoint'

    post:
      summary: Create webhook endpoint
      tags: [Webhooks]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url:
                  type: string
                  format: uri
                  description: HTTPS URL for webhook delivery
                description:
                  type: string
                events:
                  type: array
                  items:
                    type: string
                  description: Event types to subscribe to

components:
  schemas:
    WebhookEndpoint:
      type: object
      properties:
        id:
          type: string
          example: we_abc123
        url:
          type: string
          format: uri
        description:
          type: string
        subscribed_events:
          type: array
          items:
            type: string
        enabled:
          type: boolean
        status:
          type: string
          enum: [healthy, degraded, disabled]
        created_at:
          type: string
          format: date-time

    WebhookEvent:
      type: object
      properties:
        id:
          type: string
          example: evt_abc123
        type:
          type: string
          example: order.created
        api_version:
          type: string
          example: '2024-01-15'
        created_at:
          type: string
          format: date-time
        data:
          type: object
          properties:
            object:
              type: object
        livemode:
          type: boolean

Testing Infrastructure

Webhook Simulator

// simulator.ts
router.post('/webhook-endpoints/:id/test', async (req, res) => {
  const customerId = req.auth.customerId;
  const endpointId = req.params.id;
  const { event_type } = req.body;

  const endpoint = await getEndpointIfOwned(endpointId, customerId);
  if (!endpoint) {
    return res.status(404).json({ error: { code: 'not_found' } });
  }

  // Create test event
  const testEvent = createTestEvent(event_type || 'ping');

  // Deliver synchronously for immediate feedback
  const result = await deliverWebhook(endpoint, testEvent);

  res.json({
    data: {
      event: testEvent,
      delivery: {
        success: result.success,
        status_code: result.statusCode,
        response_body: result.responseBody?.slice(0, 500),
        duration_ms: result.duration
      }
    }
  });
});

function createTestEvent(type: string): WebhookEvent {
  const templates: Record<string, () => any> = {
    'ping': () => ({
      message: 'Webhook endpoint verified'
    }),
    'order.created': () => ({
      id: `ord_test_${nanoid(10)}`,
      customer_id: `cus_test_${nanoid(10)}`,
      total: 9999,
      currency: 'usd',
      status: 'pending',
      items: [
        { name: 'Test Product', quantity: 1, price: 9999 }
      ],
      created_at: new Date().toISOString()
    }),
    // Add more templates...
  };

  const dataGenerator = templates[type] || templates['ping'];

  return {
    id: `evt_test_${nanoid(24)}`,
    type,
    api_version: '2024-01-15',
    created_at: new Date().toISOString(),
    data: { object: dataGenerator() },
    livemode: false
  };
}

Summary

Building a webhook provider requires:

  1. Event Model - Consistent envelope with unique IDs, types, and versioning
  2. Delivery Infrastructure - Reliable queue-based delivery with retries
  3. Security - HMAC signatures with timestamps to prevent replay attacks
  4. Management API - Full CRUD for endpoints with delivery history
  5. SDKs - Signature verification libraries in popular languages
  6. Documentation - Comprehensive guides, examples, and testing tools

The key principles are: make integration easy (SDKs, clear docs), make debugging easy (delivery logs, simulator), and make it reliable (retries, health monitoring). A well-built webhook system becomes a critical integration point that your customers depend on.

Let's turn this knowledge into action

Get a free 30-minute consultation with our experts. We'll help you apply these insights to your specific situation.