> **Building with AI coding agents?** Install the authstack plugin with one command. This equips your agent with accurate Scalekit implementation patterns.
>
> **Recommended** (the `-y` flags skip prompts, so the command runs without an interactive terminal):
> ```bash
> npx -y @scalekit-inc/cli setup -y
> ```
>
> Global:
> ```bash
> npm install -g @scalekit-inc/cli
> scalekit setup -y
> ```
>
> Supports Claude Code, Cursor, GitHub Copilot, Codex + skills for other Agent Skills-compatible agents in `~/.agents/skills`.
> Skills: integrate-agentkit, implement-saaskit, add-mcp-oauth, implement-sso, implement-scim.
> [Full setup guide](https://docs.scalekit.com/dev-kit/build-with-ai/)

---

# 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

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

Scalekit sends POST requests to your registered webhook endpoint. Verify each request with the signing secret from **Dashboard > Webhooks > Any Endpoint** (`whsec_…`).

Pass the raw body and these headers to the SDK: `webhook-id`, `webhook-timestamp`, and `webhook-signature`.

**Node.js**

```javascript
app.post(
  '/webhook',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const secret = process.env.SCALEKIT_WEBHOOK_SECRET
    const payload = req.body.toString()
    const headers = {
      'webhook-id': req.headers['webhook-id'],
      'webhook-timestamp': req.headers['webhook-timestamp'],
      'webhook-signature': req.headers['webhook-signature'],
    }

    try {
      scalekit.verifyWebhookPayload(secret, headers, payload)
    } catch (error) {
      return res.status(401).json({ error: 'Invalid signature' })
    }

    const event = JSON.parse(payload)
    await processWebhookEvent(event)
    return res.status(201).json({ received: true })
  }
)
```

**Python**

```python
import os

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

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')
    }

    try:
        scalekit_client.verify_webhook_payload(
            secret=os.environ["SCALEKIT_WEBHOOK_SECRET"],
            headers=headers,
            payload=body,
        )
    except Exception:
        return JSONResponse(status_code=401, content={"error": "Invalid signature"})

    return JSONResponse(status_code=201, content={"status": "received"})
```

**Go**

```go
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"),
    }

    valid, err := scalekitClient.VerifyWebhookPayload(
        webhookSecret,
        headers,
        bodyBytes,
    )
    if err != nil || !valid {
        http.Error(w, "Invalid signature", http.StatusUnauthorized)
        return
    }
})
```

**Java**

```java
@PostMapping("/webhook")
public ResponseEntity<String> webhook(
  @RequestBody String body,
  @RequestHeader Map<String, String> requestHeaders
) {
  // Spring may keep title-case keys. The Java SDK reads lowercase names only.
  Map<String, String> headers = new HashMap<>();
  headers.put("webhook-id", requestHeaders.get("webhook-id"));
  headers.put("webhook-timestamp", requestHeaders.get("webhook-timestamp"));
  headers.put("webhook-signature", requestHeaders.get("webhook-signature"));

  String secret = System.getenv("SCALEKIT_WEBHOOK_SECRET");

  try {
    scalekitClient.webhook().verifyWebhookPayload(
      secret,
      headers,
      body.getBytes(java.nio.charset.StandardCharsets.UTF_8)
    );
  } catch (Exception error) {
    return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid signature");
  }

  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 ResponseEntity.badRequest().body("error");
  }

  return ResponseEntity.ok("ok");
}
```

## 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.

**Node.js**

```javascript
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
}
```

**Python**

```python
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'}
    )
```

**Go**

```go
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)
})
```

**Java**

```java
@PostMapping("/webhook")
public ResponseEntity<String> webhook(
  @RequestBody String body,
  @RequestHeader Map<String, String> requestHeaders
) {
  Map<String, String> headers = new HashMap<>();
  headers.put("webhook-id", requestHeaders.get("webhook-id"));
  headers.put("webhook-timestamp", requestHeaders.get("webhook-timestamp"));
  headers.put("webhook-signature", requestHeaders.get("webhook-signature"));

  String secret = System.getenv("SCALEKIT_WEBHOOK_SECRET");
  try {
    scalekitClient.webhook().verifyWebhookPayload(
      secret,
      headers,
      body.getBytes(java.nio.charset.StandardCharsets.UTF_8)
    );
  } catch (Exception error) {
    return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid signature");
  }

  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 ResponseEntity.badRequest().body("error");
  }

  return ResponseEntity.ok("ok");
}
```

## Avoid webhook timeouts

To avoid unnecessary timeouts, respond to the webhook trigger with a response code of 201 and process the event asynchronously.

If you process events from a queue, verify the signature when the request arrives, before you queue the event. The SDK rejects any request whose `webhook-timestamp` is more than 5 minutes old. An event that waits in your queue longer than that fails verification in the worker. Queue the verified event with its `webhook-id`, and have the worker skip IDs it has already processed. The payload's top-level `id` works for this too. Don't use `data.id`, which identifies the user or organization the event is about.

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

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.

## Manual verification without an SDK

Use the SDK when your language has one. If it does not, HMAC-SHA256 the string `{webhook-id}.{webhook-timestamp}.{rawBody}`.

The key is the base64-decoded text after the first `_` in the `whsec_…` secret. The `webhook-signature` header is a space-separated list of `v1,<base64>` values. Reject timestamps more than 5 minutes in the past or future. Compare digests with a timing-safe function.

Do not use Stripe-style `t=<timestamp>,v1=<hex>` headers, a `scalekit-signature` header, or a `sha256=` hex digest. Those are not Scalekit webhook signatures.

See [Handle webhook events](/authenticate/implement-workflows/implement-webhooks/#verify-with-the-three-webhook-headers) for the SDK call.

## Advanced error handling and reliability

Implement comprehensive error handling to ensure reliable webhook processing across various failure scenarios.

### Retry logic with exponential backoff

```javascript title="Retry 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

Prevent cascading failures by implementing a circuit breaker:

```javascript title="Circuit breaker for webhook processing"
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;
    }
  }
}

// Usage
const 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

### Webhook testing utilities

Create comprehensive testing utilities for your webhook handlers:

```javascript title="Webhook testing utilities"
// Test webhook handler with sample events
async 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');
}
```

## Monitoring and debugging

### Webhook delivery monitoring

Track webhook processing metrics to identify issues and optimize performance:

```javascript title="Webhook monitoring"
// Track webhook processing metrics
async 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 statistics
app.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 monitoring
async 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 minute
setInterval(monitorWebhookHealth, 60000);
```

### Debugging webhook issues

```javascript title="Webhook debugging utilities"
// Detailed webhook logging
async 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 debugging
async 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 webhooks
async 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 periodically
setInterval(processDeadLetterQueue, 5 * 60 * 1000); // Every 5 minutes
```

### Performance optimization

```javascript title="Webhook performance optimization"
// Batch processing for high-volume webhooks
class 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
  }
}

// Usage
const 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.


---

## More Scalekit documentation

| Resource | What it contains | When to use it |
|----------|-----------------|----------------|
| [/llms.txt](/llms.txt) | Structured index with routing hints per product area | Start here — find which documentation set covers your topic before loading full content |
| [/llms-full.txt](/llms-full.txt) | Complete documentation for all Scalekit products in one file | Use when you need exhaustive context across multiple products or when the topic spans several areas |
| [sitemap-0.xml](https://docs.scalekit.com/sitemap-0.xml) | Full URL list of every documentation page | Use to discover specific page URLs you can fetch for targeted, page-level answers |
