Following webhook best practices
Learn best practices for implementing webhooks in your SCIM integration. Covers security measures, event handling, signature verification, and performance optimization techniques for real-time directory updates.
Webhooks are HTTP endpoints that you register with a system, allowing that system to inform your application about events by sending HTTP POST requests with event information in the body.
Developers register their applications’ webhook endpoints with Scalekit to listen to events from the directory providers of their enterprise customers. Here are some common best practices developers follow to ensure their apps are secure and performant:
Subscribe only to relevant events
Section titled “Subscribe only to relevant events”While you can listen to all events from Scalekit, it’s best to subscribe only to the events your app needs. This approach has several benefits:
- Your app doesn’t have to process every event
- You can avoid overloading a single execution context by handling every event type
Verify webhook signatures
Section titled “Verify webhook signatures”Scalekit sends POST requests to your registered webhook endpoint. To ensure the request is coming from Scalekit and not a malicious actor, you should verify the request using the signing secret found in the Scalekit dashboard > Webhook > Any Endpoint.
Here’s an example of how to verify webhooks using the Svix library:
app.post('/webhook', async (req, res) => { // Parse the JSON body of the request const event = await req.json();
// Get headers from the request const headers = req.headers;
// Secret from Scalekit dashboard > Webhooks const secret = process.env.SCALEKIT_WEBHOOK_SECRET;
try { // Verify the webhook payload await scalekit.verifyWebhookPayload(secret, headers, event); } catch (error) { return res.status(400).json({ error: 'Invalid signature', }); }});from fastapi import FastAPI, Request
app = FastAPI()
@app.post("/webhook")async def api_webhook(request: Request): # Get request data body = await request.body()
# Extract webhook headers headers = { 'webhook-id': request.headers.get('webhook-id'), 'webhook-signature': request.headers.get('webhook-signature'), 'webhook-timestamp': request.headers.get('webhook-timestamp') }
# Verify webhook signature is_valid = scalekit.verify_webhook_payload( secret='<secret>', headers=headers, payload=body ) print(is_valid)
return JSONResponse( status_code=201, content='' )mux.HandleFunc("POST /webhook", func(w http.ResponseWriter, r *http.Request) { webhookSecret := os.Getenv("SCALEKIT_WEBHOOK_SECRET")
// Read request body bodyBytes, err := io.ReadAll(r.Body) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return }
// Prepare headers for verification headers := map[string]string{ "webhook-id": r.Header.Get("webhook-id"), "webhook-signature": r.Header.Get("webhook-signature"), "webhook-timestamp": r.Header.Get("webhook-timestamp"), }
// Verify webhook signature _, err = sc.VerifyWebhookPayload( webhookSecret, headers, bodyBytes ) if err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) return }})@PostMapping("/webhook")public String webhook(@RequestBody String body, @RequestHeader Map<String, String> headers) { String secret = "<WEBHOOK SECRET>";
// Verify webhook signature boolean valid = scalekit.webhook().verifyWebhookPayload(secret, headers, body.getBytes());
if (!valid) { return "error"; }
ObjectMapper mapper = new ObjectMapper();
try { // Parse event data JsonNode node = mapper.readTree(body); String eventType = node.get("type").asText(); JsonNode data = node.get("data");
// Handle different event types switch (eventType) { case "organization.directory.user_created": handleUserCreate(data); break; case "organization.directory.user_updated": handleUserUpdate(data); break; default: System.out.println("Unhandled event type: " + eventType); } } catch (IOException e) { return "error"; }
return "ok";}Check the event type before processing
Section titled “Check the event type before processing”Make sure to check the event.type before consuming the data received by the webhook endpoint. This ensures that your application relies on accurate information, even if more events are added in the future.
app.post('/webhook', async (req, res) => { const event = req.body;
// Handle different event types switch (event.type) { case 'organization.directory.user_created': const { email, name } = event.data; await createUserAccount(email, name); break;
case 'organization.directory.user_updated': await updateUserAccount(event.data); break;
default: console.log('Unhandled event type:', event.type); }
return res.status(201).json({ status: 'success', });});
async function createUserAccount(email, name) { // Implement your user creation logic}from fastapi import FastAPI, Request
app = FastAPI()
@app.post("/webhook")async def api_webhook(request: Request): # Parse request body body = await request.body() payload = json.loads(body.decode()) event_type = payload['type']
# Handle different event types match event_type: case 'organization.directory.user_created': await handle_user_create(payload['data']) case 'organization.directory.user_updated': await handle_user_update(payload['data']) case _: print('Unhandled event type:', event_type)
return JSONResponse( status_code=201, content={'status': 'success'} )mux.HandleFunc("POST /webhook", func(w http.ResponseWriter, r *http.Request) { // Read and verify webhook payload bodyBytes, err := io.ReadAll(r.Body) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return }
// Parse event data var event map[string]interface{} err = json.Unmarshal(bodyBytes, &event) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return }
// Handle different event types eventType := event["type"] switch eventType { case "organization.directory.user_created": handleUserCreate(event["data"]) case "organization.directory.user_updated": handleUserUpdate(event["data"]) default: fmt.Println("Unhandled event type:", eventType) }
w.WriteHeader(http.StatusOK)})@PostMapping("/webhook")public String webhook(@RequestBody String body, @RequestHeader Map<String, String> headers) { // Verify webhook signature first String secret = "<WEBHOOK_SECRET>"; if (!verifyWebhookSignature(secret, headers, body)) { return "error"; }
try { // Parse event data ObjectMapper mapper = new ObjectMapper(); JsonNode node = mapper.readTree(body); String eventType = node.get("type").asText(); JsonNode data = node.get("data");
// Handle different event types switch (eventType) { case "organization.directory.user_created": handleUserCreate(data); break; case "organization.directory.user_updated": handleUserUpdate(data); break; default: System.out.println("Unhandled event type: " + eventType); } } catch (IOException e) { return "error"; }
return "ok";}Avoid webhook timeouts
Section titled “Avoid webhook timeouts”To avoid unnecessary timeouts, respond to the webhook trigger with a response code of 201 and process the event asynchronously.
By following these best practices, you can ensure that your application effectively handles events from Scalekit, maintaining optimal performance and security.
Do not ignore errors
Section titled “Do not ignore errors”Do not overlook repeated 4xx and 5xx error codes. Instead, verify that your API interactions are correct. For instance, if an endpoint expects a string but receives a numeric value, a validation error should occur. Likewise, trying to access an unauthorized or nonexistent endpoint will trigger a 4xx error.
Advanced signature verification
Section titled “Advanced signature verification”While using the Scalekit SDK is recommended for webhook signature verification, you can also verify signatures manually using HMAC-SHA256 libraries when the SDK isn’t available for your language.
Manual signature verification
Section titled “Manual signature verification”function verifySignatureManually(rawBody, signature, secret) { const crypto = require('crypto');
// Extract timestamp and signature from header // Header format: "t=<timestamp>,v1=<signature>" const elements = signature.split(','); const timestamp = elements.find(el => el.startsWith('t=')).substring(2); const receivedSignature = elements.find(el => el.startsWith('v1=')).substring(3);
// Create expected signature // Payload format: <timestamp>.<raw_body> const payload = `${timestamp}.${rawBody}`; const expectedSignature = crypto .createHmac('sha256', secret) .update(payload, 'utf8') .digest('hex');
// Compare signatures securely using timing-safe comparison // This prevents timing attacks return crypto.timingSafeEqual( Buffer.from(receivedSignature, 'hex'), Buffer.from(expectedSignature, 'hex') );}Timestamp validation
Section titled “Timestamp validation”Always validate the webhook timestamp to prevent replay attacks:
function validateWebhookTimestamp(timestamp, toleranceSeconds = 300) { // Convert timestamp to milliseconds const webhookTime = parseInt(timestamp) * 1000; const currentTime = Date.now(); const timeDifference = Math.abs(currentTime - webhookTime);
// Reject webhooks older than tolerance period (default 5 minutes) if (timeDifference > toleranceSeconds * 1000) { throw new Error('Webhook timestamp too old or too far in future'); }
return true;}Advanced error handling and reliability
Section titled “Advanced error handling and reliability”Implement comprehensive error handling to ensure reliable webhook processing across various failure scenarios.
Retry logic with exponential backoff
Section titled “Retry logic with exponential backoff”async function processWebhookWithRetry(event, maxRetries = 3) { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { await processWebhookEvent(event); return; // Success, exit retry loop
} catch (error) { console.error(`Webhook processing attempt ${attempt} failed:`, error);
if (attempt === maxRetries) { // Final attempt failed - log to dead letter queue await deadLetterQueue.add('failed_webhook', { event, error: error.message, attempts: attempt, timestamp: new Date() }); throw error; }
// Wait before retry with exponential backoff // Attempt 1: 1s, Attempt 2: 2s, Attempt 3: 4s const waitTime = Math.pow(2, attempt) * 1000; await new Promise(resolve => setTimeout(resolve, waitTime)); } }}Circuit breaker pattern
Section titled “Circuit breaker pattern”Prevent cascading failures by implementing a circuit breaker:
class WebhookCircuitBreaker { constructor(options = {}) { this.failureThreshold = options.failureThreshold || 5; this.recoveryTimeout = options.recoveryTimeout || 60000; // 60 seconds this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN this.failures = 0; this.nextAttempt = Date.now(); }
async execute(fn) { if (this.state === 'OPEN') { if (Date.now() < this.nextAttempt) { throw new Error('Circuit breaker is OPEN'); } // Try to recover this.state = 'HALF_OPEN'; }
try { const result = await fn(); this.onSuccess(); return result; } catch (error) { this.onFailure(); throw error; } }
onSuccess() { this.failures = 0; this.state = 'CLOSED'; }
onFailure() { this.failures++; if (this.failures >= this.failureThreshold) { this.state = 'OPEN'; this.nextAttempt = Date.now() + this.recoveryTimeout; } }}
// Usageconst circuitBreaker = new WebhookCircuitBreaker({ failureThreshold: 5, recoveryTimeout: 60000});
async function handleWebhook(event) { try { await circuitBreaker.execute(async () => { return await processWebhookEvent(event); }); } catch (error) { if (error.message === 'Circuit breaker is OPEN') { // Service is unhealthy, queue for later await queueForLater(event); } throw error; }}Advanced testing strategies
Section titled “Advanced testing strategies”Webhook testing utilities
Section titled “Webhook testing utilities”Create comprehensive testing utilities for your webhook handlers:
// Test webhook handler with sample eventsasync function testWebhookHandler() { const sampleUserCreatedEvent = { spec_version: '1', id: 'evt_test_123', type: 'organization.directory.user_created', occurred_at: new Date().toISOString(), environment_id: 'env_test_123', organization_id: 'org_test_123', object: 'DirectoryUser', data: { id: 'diruser_test_123', organization_id: 'org_test_123', email: 'test@example.com', given_name: 'Test', family_name: 'User', active: true, groups: [], roles: [] } };
// Test your webhook processing await processWebhookEvent(sampleUserCreatedEvent); console.log('Test webhook processed successfully');}
// Mock webhook signature for testingfunction createTestSignature(payload, secret) { const crypto = require('crypto'); const timestamp = Math.floor(Date.now() / 1000); const payloadString = typeof payload === 'string' ? payload : JSON.stringify(payload); const signature = crypto .createHmac('sha256', secret) .update(`${timestamp}.${payloadString}`) .digest('hex');
return { 'webhook-id': 'evt_test_' + Date.now(), 'webhook-timestamp': timestamp.toString(), 'webhook-signature': `t=${timestamp},v1=${signature}` };}
// Integration testasync function testWebhookIntegration() { const testSecret = 'test_secret_key'; const testEvent = { type: 'organization.directory.user_created', data: { /* test data */ } };
const headers = createTestSignature(testEvent, testSecret);
// Make request to your webhook endpoint const response = await fetch('http://localhost:3000/webhooks/manage-users', { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(testEvent) });
assert(response.status === 201, 'Expected 201 status'); console.log('Integration test passed');}Monitoring and debugging
Section titled “Monitoring and debugging”Webhook delivery monitoring
Section titled “Webhook delivery monitoring”Track webhook processing metrics to identify issues and optimize performance:
// Track webhook processing metricsasync function trackWebhookMetrics(event, processingTime, success) { await metricsService.record('webhook_processed', { event_type: event.type, processing_time_ms: processingTime, success: success, organization_id: event.organization_id, environment_id: event.environment_id, timestamp: new Date() });
// Alert on processing time anomalies if (processingTime > 5000) { // 5 seconds await alertService.warn({ message: 'Slow webhook processing detected', eventType: event.type, processingTime: processingTime, eventId: event.id }); }
// Alert on failures if (!success) { await alertService.error({ message: 'Webhook processing failed', eventType: event.type, eventId: event.id }); }}
// Dashboard endpoint to view webhook statisticsapp.get('/admin/webhook-stats', async (req, res) => { const stats = await db.query(` SELECT event_type, COUNT(*) as total_events, SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as successful, SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed, AVG(processing_time_ms) as avg_processing_time, MAX(processing_time_ms) as max_processing_time, MIN(processing_time_ms) as min_processing_time FROM processed_webhooks WHERE processed_at > NOW() - INTERVAL 24 HOUR GROUP BY event_type ORDER BY total_events DESC `);
res.json(stats);});
// Real-time webhook monitoringasync function monitorWebhookHealth() { const recentFailures = await db.processed_webhooks.count({ where: { status: 'failed', processed_at: { $gte: new Date(Date.now() - 5 * 60 * 1000) // Last 5 minutes } } });
if (recentFailures > 10) { await alertService.critical({ message: 'High webhook failure rate detected', failureCount: recentFailures, timeWindow: '5 minutes' }); }}
// Run health check every minutesetInterval(monitorWebhookHealth, 60000);Debugging webhook issues
Section titled “Debugging webhook issues”// Detailed webhook loggingasync function logWebhookDetails(event, context) { await db.webhook_logs.create({ event_id: event.id, event_type: event.type, organization_id: event.organization_id, environment_id: event.environment_id, received_at: new Date(), headers: context.headers, payload: event, ip_address: context.ip, user_agent: context.userAgent });}
// Webhook replay for debuggingasync function replayWebhook(eventId) { // Retrieve original webhook from logs const webhookLog = await db.webhook_logs.findOne({ event_id: eventId });
if (!webhookLog) { throw new Error(`Webhook ${eventId} not found`); }
// Replay the webhook console.log(`Replaying webhook ${eventId}`); await processWebhookEvent(webhookLog.payload); console.log(`Webhook ${eventId} replayed successfully`);}
// Dead letter queue processor for failed webhooksasync function processDeadLetterQueue() { const failedWebhooks = await deadLetterQueue.getAll('failed_webhook');
for (const item of failedWebhooks) { try { console.log(`Reprocessing failed webhook: ${item.event.id}`); await processWebhookEvent(item.event);
// Remove from dead letter queue on success await deadLetterQueue.remove('failed_webhook', item.id);
} catch (error) { console.error(`Failed to reprocess webhook ${item.event.id}:`, error);
// Increment retry count item.retries = (item.retries || 0) + 1;
if (item.retries >= 5) { // Move to permanent failure queue await permanentFailureQueue.add(item); await deadLetterQueue.remove('failed_webhook', item.id); } } }}
// Run dead letter queue processor periodicallysetInterval(processDeadLetterQueue, 5 * 60 * 1000); // Every 5 minutesPerformance optimization
Section titled “Performance optimization”// Batch processing for high-volume webhooksclass WebhookBatchProcessor { constructor(options = {}) { this.batchSize = options.batchSize || 100; this.flushInterval = options.flushInterval || 5000; // 5 seconds this.queue = []; this.timer = null; }
add(event) { this.queue.push(event);
if (this.queue.length >= this.batchSize) { this.flush(); } else if (!this.timer) { this.timer = setTimeout(() => this.flush(), this.flushInterval); } }
async flush() { if (this.queue.length === 0) return;
const batch = this.queue.splice(0, this.batchSize); clearTimeout(this.timer); this.timer = null;
try { await this.processBatch(batch); } catch (error) { console.error('Batch processing error:', error); // Re-queue failed items this.queue.unshift(...batch); } }
async processBatch(events) { // Process multiple events efficiently await db.transaction(async (trx) => { // Bulk insert processed events await trx('processed_webhooks').insert( events.map(e => ({ event_id: e.id, event_type: e.type, organization_id: e.organization_id, status: 'processing', received_at: new Date() })) );
// Process events in parallel await Promise.all(events.map(e => this.processEvent(e, trx))); }); }
async processEvent(event, trx) { // Event-specific processing logic // Use transaction for atomicity }}
// Usageconst batchProcessor = new WebhookBatchProcessor({ batchSize: 100, flushInterval: 5000});
app.post('/webhooks/manage-users', async (req, res) => { // Verify signature... const event = req.body;
// Add to batch processor batchProcessor.add(event);
// Respond immediately return res.status(201).json({ received: true });});By following these advanced best practices, you can build a robust, reliable, and performant webhook integration that handles high volumes of events while maintaining data consistency and security.