📚 Part of the Webhook Development Complete Guide: Architecture, Security, and Best Practices series.
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 Payload Generator
Generate webhook payloads with HMAC signatures for Stripe, GitHub, Slack, Shopify, Twilio, SendGrid, Discord and more
Open the full Webhook Payload Generator tool →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.