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
- Extract timestamp (
t) and signature (v1) from header - Concatenate timestamp + "." + raw request body
- Compute HMAC-SHA256 using your endpoint secret
- 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:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
Retries stop after:
- Successful delivery (2xx response)
- 4xx response (except 429)
- Maximum attempts reached
Best Practices
- Respond quickly - Return 200 within 30 seconds
- Process asynchronously - Queue events for processing
- Verify signatures - Always validate before processing
- Handle duplicates - Events may be delivered more than once
- 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:
- Event Model - Consistent envelope with unique IDs, types, and versioning
- Delivery Infrastructure - Reliable queue-based delivery with retries
- Security - HMAC signatures with timestamps to prevent replay attacks
- Management API - Full CRUD for endpoints with delivery history
- SDKs - Signature verification libraries in popular languages
- 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.