Create secure webhook endpoints with validation and resilience
Generates production-ready webhook endpoints with signature verification, idempotency, and async processing.
/plugin marketplace add jeremylongshore/claude-code-plugins-plus-skills/plugin install webhook-handler-creator@claude-code-plugins-plusAutomatically generate production-ready webhook endpoints with signature verification, idempotency, retry handling, event processing, and comprehensive security measures for reliable third-party integrations.
Use /create-webhook-handler when you need to:
DON'T use this when:
This command implements signature-based verification as the primary approach because:
Alternative considered: OAuth token validation
Alternative considered: mTLS (mutual TLS)
Before running this command:
Define secure POST endpoint with proper routing and middleware.
Validate webhook authenticity using HMAC signatures.
Prevent duplicate processing of retried events.
Dispatch events to appropriate processing functions.
Set up logging, metrics, and alerting for webhook health.
The command generates:
webhooks/handlers/ - Event-specific handler functionswebhooks/middleware/ - Signature verification, rate limitingwebhooks/routes.js - Webhook endpoint definitionswebhooks/processors/ - Async event processorswebhooks/schemas/ - Event validation schemasconfig/webhooks.json - Provider configurationstests/webhooks/ - Integration tests with mock events// webhooks/middleware/signature.js
const crypto = require('crypto');
const { WebhookError } = require('../errors');
class SignatureVerifier {
constructor(config) {
this.providers = config.providers;
this.timestampTolerance = config.timestampTolerance || 300; // 5 minutes
}
// Stripe signature verification
verifyStripe(payload, signature, secret) {
const elements = signature.split(',');
const timestamp = elements.find(e => e.startsWith('t=')).substring(2);
const signatures = elements
.filter(e => e.startsWith('v1='))
.map(e => e.substring(3));
// Check timestamp to prevent replay attacks
const currentTime = Math.floor(Date.now() / 1000);
if (currentTime - parseInt(timestamp) > this.timestampTolerance) {
throw new WebhookError('Webhook timestamp too old', 'TIMESTAMP_TOO_OLD');
}
// Calculate expected signature
const signedPayload = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Compare signatures
const validSignature = signatures.some(sig =>
crypto.timingSafeEqual(
Buffer.from(sig),
Buffer.from(expectedSignature)
)
);
if (!validSignature) {
throw new WebhookError('Invalid signature', 'INVALID_SIGNATURE');
}
return true;
}
// GitHub signature verification
verifyGitHub(payload, signature, secret) {
if (!signature.startsWith('sha256=')) {
throw new WebhookError('Invalid signature format', 'INVALID_FORMAT');
}
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
const isValid = crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
if (!isValid) {
throw new WebhookError('Invalid signature', 'INVALID_SIGNATURE');
}
return true;
}
// Shopify signature verification
verifyShopify(payload, signature, secret) {
const hash = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('base64');
const isValid = crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(hash)
);
if (!isValid) {
throw new WebhookError('Invalid signature', 'INVALID_SIGNATURE');
}
return true;
}
// Generic HMAC verification
verifyHMAC(payload, signature, secret, algorithm = 'sha256') {
const expectedSignature = crypto
.createHmac(algorithm, secret)
.update(payload)
.digest('hex');
const isValid = crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
if (!isValid) {
throw new WebhookError('Invalid signature', 'INVALID_SIGNATURE');
}
return true;
}
// Middleware factory
createMiddleware(provider) {
return async (req, res, next) => {
try {
const config = this.providers[provider];
if (!config) {
throw new WebhookError('Unknown provider', 'UNKNOWN_PROVIDER');
}
const rawBody = req.rawBody || JSON.stringify(req.body);
const signature = req.headers[config.headerName];
if (!signature) {
throw new WebhookError('Missing signature', 'MISSING_SIGNATURE');
}
// Verify based on provider
switch (provider) {
case 'stripe':
this.verifyStripe(rawBody, signature, config.secret);
break;
case 'github':
this.verifyGitHub(rawBody, signature, config.secret);
break;
case 'shopify':
this.verifyShopify(rawBody, signature, config.secret);
break;
default:
this.verifyHMAC(rawBody, signature, config.secret, config.algorithm);
}
// Add provider info to request
req.webhookProvider = provider;
req.webhookVerified = true;
next();
} catch (error) {
console.error(`Webhook verification failed: ${error.message}`);
res.status(401).json({
error: 'Webhook verification failed',
code: error.code || 'VERIFICATION_FAILED'
});
}
};
}
}
// webhooks/middleware/idempotency.js
class IdempotencyHandler {
constructor(store) {
this.store = store; // Redis or database
this.ttl = 86400; // 24 hours
}
async middleware(req, res, next) {
try {
// Extract idempotency key
const idempotencyKey =
req.headers['idempotency-key'] ||
req.body.id ||
req.body.event_id ||
this.generateKey(req);
// Check if already processed
const existing = await this.store.get(idempotencyKey);
if (existing) {
console.log(`Duplicate webhook detected: ${idempotencyKey}`);
const result = JSON.parse(existing);
return res.status(result.status).json(result.body);
}
// Store the key immediately to prevent race conditions
await this.store.setNX(idempotencyKey, 'PROCESSING', this.ttl);
// Attach key to request
req.idempotencyKey = idempotencyKey;
// Capture response
const originalSend = res.json;
res.json = function(body) {
// Store the response
const response = {
status: res.statusCode,
body: body
};
this.store.set(
idempotencyKey,
JSON.stringify(response),
this.ttl
).catch(err => console.error('Failed to store response:', err));
// Send the original response
return originalSend.call(res, body);
}.bind(this);
next();
} catch (error) {
console.error('Idempotency check failed:', error);
next(); // Continue processing even if idempotency fails
}
}
generateKey(req) {
const content = JSON.stringify({
provider: req.webhookProvider,
body: req.body,
timestamp: Math.floor(Date.now() / 60000) // 1-minute window
});
return crypto
.createHash('sha256')
.update(content)
.digest('hex');
}
}
// webhooks/handlers/stripe.js
const { Queue } = require('bull');
const { EventEmitter } = require('events');
class StripeWebhookHandler extends EventEmitter {
constructor(config) {
super();
this.config = config;
this.queue = new Queue('stripe-events', config.redis);
this.setupProcessors();
}
async handleWebhook(req, res) {
const event = req.body;
// Log webhook receipt
console.log(`Received Stripe webhook: ${event.type} (${event.id})`);
try {
// Validate event data
this.validateEvent(event);
// Queue for async processing
const job = await this.queue.add(event.type, event, {
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000
},
removeOnComplete: false,
removeOnFail: false
});
// Emit event for real-time processing if needed
this.emit(event.type, event);
// Respond quickly to acknowledge receipt
res.status(200).json({
received: true,
jobId: job.id,
eventId: event.id
});
} catch (error) {
console.error('Webhook processing error:', error);
// Still return 200 to prevent retries if it's our error
if (error.code === 'VALIDATION_ERROR') {
res.status(200).json({
received: true,
error: 'Validation failed, event ignored'
});
} else {
// Return 500 for Stripe to retry
res.status(500).json({
error: 'Processing failed',
message: error.message
});
}
}
}
validateEvent(event) {
if (!event.type || !event.id || !event.data) {
throw new Error('Invalid event structure');
}
// Additional validation based on event type
switch (event.type) {
case 'payment_intent.succeeded':
if (!event.data.object.amount) {
throw new Error('Missing amount in payment_intent');
}
break;
case 'customer.subscription.deleted':
if (!event.data.object.customer) {
throw new Error('Missing customer in subscription');
}
break;
}
}
setupProcessors() {
// Payment succeeded
this.queue.process('payment_intent.succeeded', async (job) => {
const event = job.data;
const payment = event.data.object;
// Update order status
await this.updateOrderStatus(payment.metadata.orderId, 'paid');
// Send confirmation email
await this.sendPaymentConfirmation(payment);
// Update inventory
await this.updateInventory(payment.metadata.orderId);
// Analytics
await this.trackRevenue(payment.amount / 100, payment.currency);
return { processed: true, orderId: payment.metadata.orderId };
});
// Subscription canceled
this.queue.process('customer.subscription.deleted', async (job) => {
const event = job.data;
const subscription = event.data.object;
// Update user account
await this.updateUserSubscription(subscription.customer, null);
// Send cancellation email
await this.sendCancellationEmail(subscription);
// Clean up resources
await this.cleanupUserResources(subscription.customer);
return { processed: true, customerId: subscription.customer };
});
// Payment failed
this.queue.process('payment_intent.payment_failed', async (job) => {
const event = job.data;
const payment = event.data.object;
// Notify customer
await this.sendPaymentFailedNotification(payment);
// Update order status
await this.updateOrderStatus(payment.metadata.orderId, 'payment_failed');
// Alert operations team if high-value
if (payment.amount > 100000) { // $1000+
await this.alertOperationsTeam(payment);
}
return { processed: true, orderId: payment.metadata.orderId };
});
// Handle job failures
this.queue.on('failed', (job, err) => {
console.error(`Job ${job.id} failed:`, err);
this.alertOnFailure(job, err);
});
// Monitor job completion
this.queue.on('completed', (job, result) => {
console.log(`Job ${job.id} completed:`, result);
});
}
// Helper methods
async updateOrderStatus(orderId, status) {
// Implementation
}
async sendPaymentConfirmation(payment) {
// Implementation
}
async updateInventory(orderId) {
// Implementation
}
async trackRevenue(amount, currency) {
// Implementation
}
async alertOnFailure(job, error) {
// Send to monitoring service
}
}
// webhooks/routes.js
const express = require('express');
const router = express.Router();
const { SignatureVerifier } = require('./middleware/signature');
const { IdempotencyHandler } = require('./middleware/idempotency');
const { RateLimiter } = require('./middleware/rateLimiter');
const { StripeWebhookHandler } = require('./handlers/stripe');
const { GitHubWebhookHandler } = require('./handlers/github');
const Redis = require('ioredis');
// Initialize middleware
const redis = new Redis(process.env.REDIS_URL);
const signatureVerifier = new SignatureVerifier({
providers: {
stripe: {
secret: process.env.STRIPE_WEBHOOK_SECRET,
headerName: 'stripe-signature'
},
github: {
secret: process.env.GITHUB_WEBHOOK_SECRET,
headerName: 'x-hub-signature-256'
},
shopify: {
secret: process.env.SHOPIFY_WEBHOOK_SECRET,
headerName: 'x-shopify-hmac-sha256'
}
},
timestampTolerance: 300
});
const idempotencyHandler = new IdempotencyHandler(redis);
const rateLimiter = new RateLimiter({
windowMs: 60000,
maxRequests: 100,
keyGenerator: (req) => req.webhookProvider
});
// Initialize handlers
const stripeHandler = new StripeWebhookHandler({ redis });
const githubHandler = new GitHubWebhookHandler({ redis });
// Stripe webhooks
router.post('/stripe',
express.raw({ type: 'application/json' }),
signatureVerifier.createMiddleware('stripe'),
idempotencyHandler.middleware.bind(idempotencyHandler),
rateLimiter.middleware(),
stripeHandler.handleWebhook.bind(stripeHandler)
);
// GitHub webhooks
router.post('/github',
express.json({ limit: '10mb' }),
signatureVerifier.createMiddleware('github'),
idempotencyHandler.middleware.bind(idempotencyHandler),
rateLimiter.middleware(),
githubHandler.handleWebhook.bind(githubHandler)
);
// Health check endpoint
router.get('/health', (req, res) => {
res.json({
status: 'healthy',
providers: ['stripe', 'github', 'shopify'],
timestamp: new Date().toISOString()
});
});
module.exports = router;
# webhook_handler.py - AWS Lambda function
import json
import hmac
import hashlib
import time
import boto3
from typing import Dict, Any, Optional
from dataclasses import dataclass
from enum import Enum
dynamodb = boto3.resource('dynamodb')
sqs = boto3.client('sqs')
sns = boto3.client('sns')
class WebhookProvider(Enum):
STRIPE = "stripe"
GITHUB = "github"
SLACK = "slack"
SENDGRID = "sendgrid"
@dataclass
class WebhookConfig:
provider: WebhookProvider
secret: str
header_name: str
algorithm: str = "sha256"
timestamp_tolerance: int = 300
class WebhookValidator:
"""Validates webhook signatures from various providers."""
@staticmethod
def validate_stripe(body: str, signature: str, secret: str) -> bool:
"""Validate Stripe webhook signature."""
elements = signature.split(',')
timestamp = None
signatures = []
for element in elements:
key, value = element.split('=')
if key == 't':
timestamp = value
elif key == 'v1':
signatures.append(value)
if not timestamp:
raise ValueError("No timestamp in signature")
# Check timestamp
current_time = int(time.time())
if current_time - int(timestamp) > 300: # 5 minutes
raise ValueError("Timestamp too old")
# Calculate expected signature
signed_payload = f"{timestamp}.{body}"
expected_sig = hmac.new(
secret.encode('utf-8'),
signed_payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
# Compare signatures
return any(
hmac.compare_digest(sig, expected_sig)
for sig in signatures
)
@staticmethod
def validate_github(body: str, signature: str, secret: str) -> bool:
"""Validate GitHub webhook signature."""
if not signature.startswith('sha256='):
raise ValueError("Invalid signature format")
expected = 'sha256=' + hmac.new(
secret.encode('utf-8'),
body.encode('utf-8'),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
@staticmethod
def validate_generic(
body: str,
signature: str,
secret: str,
algorithm: str = "sha256"
) -> bool:
"""Generic HMAC validation."""
hash_func = getattr(hashlib, algorithm)
expected = hmac.new(
secret.encode('utf-8'),
body.encode('utf-8'),
hash_func
).hexdigest()
return hmac.compare_digest(signature, expected)
class IdempotencyManager:
"""Manages idempotent processing of webhooks."""
def __init__(self, table_name: str):
self.table = dynamodb.Table(table_name)
async def check_and_record(self, event_id: str) -> bool:
"""Check if event was already processed and record it."""
try:
# Try to create item with conditional check
self.table.put_item(
Item={
'event_id': event_id,
'processed_at': int(time.time()),
'ttl': int(time.time()) + 86400 # 24 hour TTL
},
ConditionExpression='attribute_not_exists(event_id)'
)
return False # Not processed before
except Exception as e:
if 'ConditionalCheckFailedException' in str(e):
return True # Already processed
raise
class WebhookProcessor:
"""Processes webhook events asynchronously."""
def __init__(self, queue_url: str, topic_arn: str):
self.queue_url = queue_url
self.topic_arn = topic_arn
async def process_stripe_event(self, event: Dict[str, Any]) -> Dict[str, Any]:
"""Process Stripe webhook events."""
event_type = event.get('type')
event_data = event.get('data', {}).get('object', {})
handlers = {
'payment_intent.succeeded': self.handle_payment_success,
'payment_intent.failed': self.handle_payment_failure,
'customer.subscription.created': self.handle_subscription_created,
'customer.subscription.deleted': self.handle_subscription_deleted,
'invoice.payment_failed': self.handle_invoice_failed
}
handler = handlers.get(event_type)
if handler:
return await handler(event_data)
else:
print(f"No handler for event type: {event_type}")
return {'processed': False, 'reason': 'No handler'}
async def handle_payment_success(self, payment: Dict[str, Any]) -> Dict[str, Any]:
"""Handle successful payment."""
# Queue order fulfillment
await self.queue_message({
'action': 'fulfill_order',
'order_id': payment.get('metadata', {}).get('order_id'),
'amount': payment.get('amount'),
'currency': payment.get('currency')
})
# Send notification
await self.send_notification({
'type': 'payment_success',
'customer_email': payment.get('receipt_email'),
'amount': payment.get('amount') / 100
})
return {'processed': True, 'action': 'payment_processed'}
async def queue_message(self, message: Dict[str, Any]):
"""Queue message for async processing."""
sqs.send_message(
QueueUrl=self.queue_url,
MessageBody=json.dumps(message),
MessageAttributes={
'Type': {
'StringValue': message.get('action', 'unknown'),
'DataType': 'String'
}
}
)
async def send_notification(self, notification: Dict[str, Any]):
"""Send SNS notification."""
sns.publish(
TopicArn=self.topic_arn,
Message=json.dumps(notification),
Subject=f"Webhook Event: {notification.get('type')}"
)
def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
"""AWS Lambda handler for webhook processing."""
# Parse request
body = event.get('body', '')
headers = event.get('headers', {})
path = event.get('path', '')
# Determine provider from path
provider = path.split('/')[-1] # /webhooks/stripe -> stripe
try:
# Get configuration
config = WebhookConfig(
provider=WebhookProvider(provider),
secret=os.environ[f'{provider.upper()}_WEBHOOK_SECRET'],
header_name=headers.get('x-webhook-header', 'signature')
)
# Validate signature
validator = WebhookValidator()
signature = headers.get(config.header_name)
if not signature:
return {
'statusCode': 401,
'body': json.dumps({'error': 'Missing signature'})
}
is_valid = False
if config.provider == WebhookProvider.STRIPE:
is_valid = validator.validate_stripe(body, signature, config.secret)
elif config.provider == WebhookProvider.GITHUB:
is_valid = validator.validate_github(body, signature, config.secret)
else:
is_valid = validator.validate_generic(
body, signature, config.secret, config.algorithm
)
if not is_valid:
return {
'statusCode': 401,
'body': json.dumps({'error': 'Invalid signature'})
}
# Parse body
webhook_data = json.loads(body)
# Check idempotency
idempotency = IdempotencyManager('webhook-events')
event_id = webhook_data.get('id') or webhook_data.get('event_id')
if await idempotency.check_and_record(event_id):
return {
'statusCode': 200,
'body': json.dumps({'message': 'Already processed'})
}
# Process webhook
processor = WebhookProcessor(
queue_url=os.environ['SQS_QUEUE_URL'],
topic_arn=os.environ['SNS_TOPIC_ARN']
)
if config.provider == WebhookProvider.STRIPE:
result = await processor.process_stripe_event(webhook_data)
else:
# Queue for processing
await processor.queue_message({
'provider': provider,
'event': webhook_data
})
result = {'queued': True}
return {
'statusCode': 200,
'body': json.dumps(result)
}
except Exception as e:
print(f"Webhook processing error: {str(e)}")
# Return 200 to prevent retries for our errors
if 'Validation' in str(e):
return {
'statusCode': 200,
'body': json.dumps({'error': 'Validation failed'})
}
# Return 500 for provider to retry
return {
'statusCode': 500,
'body': json.dumps({'error': 'Processing failed'})
}
// tests/webhook.test.js
const request = require('supertest');
const crypto = require('crypto');
const app = require('../app');
const Redis = require('ioredis-mock');
describe('Webhook Handler Tests', () => {
let redis;
beforeEach(() => {
redis = new Redis();
});
describe('Stripe Webhooks', () => {
const secret = 'whsec_test_secret';
function generateStripeSignature(payload, secret) {
const timestamp = Math.floor(Date.now() / 1000);
const signedPayload = `${timestamp}.${payload}`;
const signature = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
return `t=${timestamp},v1=${signature}`;
}
it('should accept valid webhook with correct signature', async () => {
const payload = JSON.stringify({
id: 'evt_test_123',
type: 'payment_intent.succeeded',
data: {
object: {
id: 'pi_test_123',
amount: 2000,
currency: 'usd',
metadata: {
order_id: 'order_123'
}
}
}
});
const signature = generateStripeSignature(payload, secret);
const response = await request(app)
.post('/webhooks/stripe')
.set('stripe-signature', signature)
.set('Content-Type', 'application/json')
.send(payload)
.expect(200);
expect(response.body.received).toBe(true);
expect(response.body.eventId).toBe('evt_test_123');
});
it('should reject webhook with invalid signature', async () => {
const payload = JSON.stringify({
id: 'evt_test_123',
type: 'payment_intent.succeeded'
});
await request(app)
.post('/webhooks/stripe')
.set('stripe-signature', 'invalid_signature')
.send(payload)
.expect(401);
});
it('should handle idempotent requests', async () => {
const payload = JSON.stringify({
id: 'evt_test_duplicate',
type: 'payment_intent.succeeded',
data: { object: { amount: 1000 } }
});
const signature = generateStripeSignature(payload, secret);
// First request
const response1 = await request(app)
.post('/webhooks/stripe')
.set('stripe-signature', signature)
.send(payload)
.expect(200);
// Duplicate request
const response2 = await request(app)
.post('/webhooks/stripe')
.set('stripe-signature', signature)
.send(payload)
.expect(200);
// Both should return same response
expect(response1.body).toEqual(response2.body);
});
it('should reject old timestamps', async () => {
const oldTimestamp = Math.floor(Date.now() / 1000) - 400; // 6+ minutes old
const payload = JSON.stringify({
id: 'evt_old',
type: 'payment_intent.succeeded'
});
const signedPayload = `${oldTimestamp}.${payload}`;
const signature = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
const signatureHeader = `t=${oldTimestamp},v1=${signature}`;
await request(app)
.post('/webhooks/stripe')
.set('stripe-signature', signatureHeader)
.send(payload)
.expect(401);
});
});
describe('GitHub Webhooks', () => {
const secret = 'github_secret';
function generateGitHubSignature(payload, secret) {
return 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
}
it('should process push event', async () => {
const payload = JSON.stringify({
ref: 'refs/heads/main',
repository: {
name: 'test-repo',
full_name: 'user/test-repo'
},
commits: [{
id: 'abc123',
message: 'Test commit'
}]
});
const signature = generateGitHubSignature(payload, secret);
const response = await request(app)
.post('/webhooks/github')
.set('x-hub-signature-256', signature)
.set('x-github-event', 'push')
.send(payload)
.expect(200);
expect(response.body.received).toBe(true);
});
});
describe('Rate Limiting', () => {
it('should rate limit excessive requests', async () => {
const payload = JSON.stringify({
id: 'evt_rate_test',
type: 'test.event'
});
const signature = generateStripeSignature(payload, 'test_secret');
// Make many requests
const requests = [];
for (let i = 0; i < 150; i++) {
requests.push(
request(app)
.post('/webhooks/stripe')
.set('stripe-signature', signature)
.send(payload)
);
}
const responses = await Promise.all(requests);
const rateLimited = responses.filter(r => r.status === 429);
expect(rateLimited.length).toBeGreaterThan(0);
});
});
});
| Error | Cause | Solution |
|---|---|---|
| "Invalid signature" | Wrong secret or tampered payload | Verify secret key configuration |
| "Timestamp too old" | Webhook delivered late | Increase tolerance or check delays |
| "Duplicate event" | Webhook retried by provider | Idempotency working correctly |
| "Processing timeout" | Long-running handler | Move to async queue processing |
| "Rate limit exceeded" | Too many webhooks | Increase limits or optimize |
Security Options
signatureAlgorithm: HMAC-SHA256, HMAC-SHA512timestampTolerance: Maximum age of webhook (seconds)ipAllowlist: Restrict to provider IPsrateLimits: Per-provider rate limitingProcessing Options
asyncProcessing: Queue events for background processingretryAttempts: Number of processing retriesdeadLetterQueue: Failed event storageparallelProcessing: Concurrent event handlingDO:
DON'T:
/api-event-emitter - Create event systems/api-gateway-builder - Build API gateways/message-queue-setup - Configure message queues/serverless-function - Create Lambda handlers/monitoring-setup - Add monitoring