Webhook Integration for Developers

Webhook Integration for Developers

A comprehensive guide for developers building custom webhook endpoints to receive Keycloak authentication events and logs from Skycloak’s SIEM Integration.

Overview

Webhooks provide a flexible way to receive real-time Keycloak events in your application. This guide covers:

  • Building a webhook endpoint
  • Understanding the payload format
  • Implementing authentication
  • Testing and debugging
  • Production best practices

Quick Start

Minimal Webhook Endpoint

Here’s a minimal Express.js endpoint that receives Skycloak events:

const express = require('express');
const app = express();

app.use(express.json());

app.post('/webhooks/skycloak-events', (req, res) => {
  const { source, type, timestamp, event_count, events } = req.body;

  console.log(`Received ${event_count} ${type} events from ${source}`);

  // Process events
  events.forEach(event => {
    console.log(`Event: ${event.type} at ${event.timestamp}`);
  });

  // Return 200 to acknowledge receipt
  res.status(200).json({ received: true });
});

app.listen(3000, () => {
  console.log('Webhook server running on port 3000');
});

Payload Format

Request Structure

Skycloak sends POST requests with the following envelope structure:

{
  "source": "skycloak",
  "type": "<event_type>",
  "timestamp": "2025-01-15T14:30:00Z",
  "event_count": 1,
  "events": [...]
}

The type field indicates the kind of data: keycloak_events, application_logs, or security_logs. Each type has a different event structure inside the events array.

Envelope Fields

Field Type Description
source string Always "skycloak"
type string Data type: "keycloak_events", "application_logs", or "security_logs"
timestamp string ISO 8601 timestamp when the webhook was sent
event_count number Number of events in the batch
events array Array of event objects (structure depends on type)

Keycloak Events (keycloak_events)

User authentication events (LOGIN, LOGOUT, REGISTER, etc.) and admin events (CREATE, UPDATE, DELETE).

User Event Example

{
  "source": "skycloak",
  "type": "keycloak_events",
  "timestamp": "2025-01-15T14:30:00Z",
  "event_count": 1,
  "events": [
    {
      "type": "LOGIN",
      "timestamp": "2025-01-15T14:29:45Z",
      "realm": {
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "name": "production"
      },
      "auth": {
        "client_id": "web-app",
        "redirect_uri": "https://app.example.com/callback",
        "method": "openid-connect",
        "identity_provider": "google"
      },
      "user": {
        "id": "123e4567-e89b-12d3-a456-426614174000",
        "username": "[email protected]",
        "ip_address": "203.0.113.42"
      },
      "session": {
        "id": "7f3e9c8a-1b4d-4e5f-a6c7-8d9e0f1a2b3c"
      },
      "metadata": {
        "event_type": "keycloak_events",
        "workspace_id": "f1e2d3c4-b5a6-4789-a012-345678901234",
        "cluster_id": "a1b2c3d4-e5f6-4789-a012-345678901234",
        "source": "skycloak"
      },
      "details": {
        "id": "evt-abc123",
        "grant_type": "authorization_code",
        "event_category": "user",
        "is_m2_m": false,
        "geo": {
          "country": "United States",
          "country_code": "US",
          "city": "San Francisco",
          "latitude": 37.7749,
          "longitude": -122.4194
        },
        "severity": "info",
        "priority": 1,
        "source": "skycloak",
        "product": "keycloak",
        "vendor": "keycloak"
      }
    }
  ]
}

Admin Event Example

Admin events (realm configuration changes, user management, etc.) include resource information:

{
  "source": "skycloak",
  "type": "keycloak_events",
  "timestamp": "2025-01-15T14:30:00Z",
  "event_count": 1,
  "events": [
    {
      "type": "CREATE",
      "timestamp": "2025-01-15T14:29:45Z",
      "realm": {
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "name": "production"
      },
      "user": {
        "ip_address": "203.0.113.42"
      },
      "resource": {
        "type": "USER",
        "path": "users/123e4567-e89b-12d3-a456-426614174000"
      },
      "metadata": {
        "event_type": "keycloak_events",
        "workspace_id": "f1e2d3c4-b5a6-4789-a012-345678901234",
        "cluster_id": "a1b2c3d4-e5f6-4789-a012-345678901234",
        "source": "skycloak"
      },
      "details": {
        "id": "evt-def456",
        "event_category": "admin",
        "geo": {
          "country": "United States",
          "country_code": "US",
          "city": "San Francisco",
          "latitude": 37.7749,
          "longitude": -122.4194
        },
        "severity": "info",
        "priority": 1,
        "source": "skycloak",
        "product": "keycloak",
        "vendor": "keycloak"
      }
    }
  ]
}

Keycloak Event Fields

Field Type Description
type string Event type (LOGIN, LOGOUT, REGISTER, LOGIN_ERROR, CREATE, UPDATE, etc.)
timestamp string When the event occurred (ISO 8601)
error string Error code, present for error events (e.g., LOGIN_ERROR → "user_not_found")
realm object Realm info: id (UUID), name (realm name)
auth object Auth info: client_id, redirect_uri, method, type, identity_provider
user object User info: id, username, email, ip_address
session object Session info: id
resource object Admin events only: type (USER, REALM, CLIENT, etc.), path
metadata object Event metadata (see below)
details object Additional fields including geo, severity, priority, and event-specific data

Metadata Fields

Field Type Description
event_type string Type of data: "keycloak_events"
workspace_id string Skycloak workspace UUID
cluster_id string Skycloak cluster UUID
source string Always "skycloak"

Details Fields

The details object contains SIEM-enriched fields and any additional event-specific data:

Field Type Description
id string Unique event ID
event_category string "user" or "admin"
grant_type string OAuth grant type (e.g., "authorization_code")
is_m2_m boolean Whether this is a machine-to-machine event
geo object GeoIP data: country, country_code, city, latitude, longitude
severity string "info" for normal events, "high" for error events
priority number 1 for normal events, 3 for error events
product string Always "keycloak"
vendor string Always "keycloak"

Note: The geo object is automatically enriched from the user’s IP address. It is absent when geolocation data is unavailable.


Application Logs (application_logs)

Raw Keycloak application log lines from Loki.

{
  "source": "skycloak",
  "type": "application_logs",
  "timestamp": "2025-01-15T14:30:00Z",
  "event_count": 1,
  "events": [
    {
      "type": "",
      "timestamp": "2025-01-15T14:29:45Z",
      "application": {
        "level": "INFO",
        "logger": "org.keycloak.events",
        "thread": "executor-thread-19",
        "message": "type=LOGIN, realmId=production, clientId=web-app, userId=123e4567..."
      },
      "metadata": {
        "event_type": "application_logs",
        "workspace_id": "f1e2d3c4-b5a6-4789-a012-345678901234",
        "cluster_id": "a1b2c3d4-e5f6-4789-a012-345678901234",
        "source": "skycloak"
      },
      "details": {}
    }
  ]
}

Application Log Fields

Field Type Description
timestamp string When the log was emitted (ISO 8601)
application object Log data: level, logger, thread, message
metadata object Event metadata: event_type, workspace_id, cluster_id, source
details object Any additional fields from the log entry

The application.message field contains the raw Keycloak log line, which may include structured data like type=LOGIN, realmId=..., clientId=....


Security Logs (security_logs)

WAF and geo-blocking security events.

{
  "source": "skycloak",
  "type": "security_logs",
  "timestamp": "2025-01-15T14:30:00Z",
  "event_count": 1,
  "events": [
    {
      "type": "",
      "timestamp": "2025-01-15T14:29:45Z",
      "security": {
        "action": "blocked",
        "rule_id": "942100",
        "attack_type": "sql_injection",
        "severity": "critical",
        "anomaly_score": "15",
        "uri": "/auth/realms/production/protocol/openid-connect/token",
        "method": "POST",
        "source_ip": "203.0.113.42"
      },
      "metadata": {
        "event_type": "security_logs",
        "workspace_id": "f1e2d3c4-b5a6-4789-a012-345678901234",
        "cluster_id": "a1b2c3d4-e5f6-4789-a012-345678901234",
        "source": "skycloak"
      },
      "details": {}
    }
  ]
}

Security Log Fields

Field Type Description
timestamp string When the security event occurred (ISO 8601)
security object Security data: action, rule_id, attack_type, severity, anomaly_score, uri, method, source_ip
metadata object Event metadata: event_type, workspace_id, cluster_id, source
details object Any additional fields from the security event

Implementation Examples

Node.js with Express

Complete example with authentication and error handling:

const express = require('express');
const crypto = require('crypto');
const app = express();

// Environment variables
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || 'your-secret-token';
const PORT = process.env.PORT || 3000;

// Middleware
app.use(express.json({ limit: '10mb' }));

// Bearer token authentication
const authenticateWebhook = (req, res, next) => {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing or invalid authorization header' });
  }

  const token = authHeader.substring(7);

  if (token !== WEBHOOK_SECRET) {
    return res.status(403).json({ error: 'Invalid token' });
  }

  next();
};

// Webhook endpoint
app.post('/webhooks/skycloak-events', authenticateWebhook, async (req, res) => {
  try {
    const { source, type, timestamp, event_count, events } = req.body;

    // Validate payload
    if (source !== 'skycloak' || type !== 'keycloak_events') {
      return res.status(400).json({ error: 'Invalid payload source or type' });
    }

    if (!Array.isArray(events) || events.length === 0) {
      return res.status(400).json({ error: 'Events array is required' });
    }

    console.log(`[${timestamp}] Received ${event_count} events`);

    // Process events asynchronously
    const results = await Promise.allSettled(
      events.map(event => processEvent(event))
    );

    // Log any failures
    const failures = results.filter(r => r.status === 'rejected');
    if (failures.length > 0) {
      console.error(`Failed to process ${failures.length} events:`, failures);
    }

    // Always return 200 to acknowledge receipt
    // Even if some events failed processing, we received them
    res.status(200).json({
      received: true,
      processed: results.filter(r => r.status === 'fulfilled').length,
      failed: failures.length
    });

  } catch (error) {
    console.error('Webhook error:', error);
    // Return 200 even on error to avoid retries
    res.status(200).json({ received: true, error: error.message });
  }
});

async function processEvent(event) {
  const eventType = event.type;
  const username = event.user?.username;
  const ipAddress = event.user?.ip_address;
  const realmName = event.realm?.name;

  console.log(`Processing ${eventType}: user=${username}, ip=${ipAddress}, realm=${realmName}`);

  // Example: Store in database
  // await db.events.create({
  //   type: eventType,
  //   timestamp: new Date(event.timestamp),
  //   user: username,
  //   data: event
  // });

  // Example: Send to analytics
  // if (eventType === 'LOGIN' || eventType === 'LOGIN_ERROR') {
  //   await analytics.track({
  //     event: eventType,
  //     userId: event.user?.id,
  //     properties: { realm: realmName, ip_address: ipAddress }
  //   });
  // }

  // Example: Trigger alerts
  // if (eventType === 'LOGIN_ERROR' && event.error === 'invalid_user_credentials') {
  //   await alerting.checkBruteForce(username, ipAddress);
  // }
}

app.listen(PORT, () => {
  console.log(`Webhook server listening on port ${PORT}`);
});

Python with Flask

from flask import Flask, request, jsonify
import os
from datetime import datetime
import logging

app = Flask(__name__)
logging.basicConfig(level=logging.INFO)

WEBHOOK_SECRET = os.getenv('WEBHOOK_SECRET', 'your-secret-token')

def authenticate_webhook():
    """Verify Bearer token"""
    auth_header = request.headers.get('Authorization', '')

    if not auth_header.startswith('Bearer '):
        return False

    token = auth_header[7:]
    return token == WEBHOOK_SECRET

@app.route('/webhooks/skycloak-events', methods=['POST'])
def handle_webhook():
    # Authenticate
    if not authenticate_webhook():
        return jsonify({'error': 'Unauthorized'}), 401

    try:
        data = request.get_json()

        # Validate payload
        if data.get('source') != 'skycloak' or data.get('type') != 'keycloak_events':
            return jsonify({'error': 'Invalid payload'}), 400

        events = data.get('events', [])
        logging.info(f"Received {len(events)} events at {data.get('timestamp')}")

        # Process events
        for event in events:
            process_event(event)

        return jsonify({'received': True, 'processed': len(events)}), 200

    except Exception as e:
        logging.error(f"Webhook error: {e}")
        # Return 200 to avoid retries
        return jsonify({'received': True, 'error': str(e)}), 200

def process_event(event):
    """Process individual event"""
    event_type = event.get('type')
    user = event.get('user', {})
    username = user.get('username')
    ip_address = user.get('ip_address')
    realm_name = event.get('realm', {}).get('name')

    logging.info(f"Processing {event_type}: user={username}, ip={ip_address}, realm={realm_name}")

    # Example: Store in database
    # db.session.add(Event(
    #     type=event_type,
    #     timestamp=datetime.fromisoformat(event['timestamp']),
    #     user=username,
    #     data=event
    # ))
    # db.session.commit()

    # Example: Check for suspicious activity
    if event_type == 'LOGIN_ERROR':
        check_failed_login_attempts(username, ip_address)

def check_failed_login_attempts(username, ip_address):
    """Example: Alert on multiple failed logins"""
    # Implement your logic here
    pass

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=3000)

Go with net/http

package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"
	"strings"
	"time"
)

type WebhookPayload struct {
	Source     string                   `json:"source"`
	Type       string                   `json:"type"`
	Timestamp  string                   `json:"timestamp"`
	EventCount int                      `json:"event_count"`
	Events     []map[string]interface{} `json:"events"`
}

type WebhookResponse struct {
	Received  bool   `json:"received"`
	Processed int    `json:"processed,omitempty"`
	Error     string `json:"error,omitempty"`
}

var webhookSecret = os.Getenv("WEBHOOK_SECRET")

func main() {
	if webhookSecret == "" {
		webhookSecret = "your-secret-token"
	}

	http.HandleFunc("/webhooks/skycloak-events", handleWebhook)

	port := os.Getenv("PORT")
	if port == "" {
		port = "3000"
	}

	log.Printf("Webhook server listening on port %s", port)
	log.Fatal(http.ListenAndServe(":"+port, nil))
}

func handleWebhook(w http.ResponseWriter, r *http.Request) {
	// Check method
	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	// Authenticate
	authHeader := r.Header.Get("Authorization")
	if !strings.HasPrefix(authHeader, "Bearer ") {
		http.Error(w, "Missing authorization header", http.StatusUnauthorized)
		return
	}

	token := strings.TrimPrefix(authHeader, "Bearer ")
	if token != webhookSecret {
		http.Error(w, "Invalid token", http.StatusForbidden)
		return
	}

	// Parse payload
	var payload WebhookPayload
	if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
		log.Printf("Failed to parse payload: %v", err)
		respondJSON(w, WebhookResponse{Received: true, Error: err.Error()}, http.StatusOK)
		return
	}

	// Validate
	if payload.Source != "skycloak" || payload.Type != "keycloak_events" {
		http.Error(w, "Invalid payload", http.StatusBadRequest)
		return
	}

	log.Printf("[%s] Received %d events", payload.Timestamp, payload.EventCount)

	// Process events
	processed := 0
	for _, event := range payload.Events {
		if err := processEvent(event); err != nil {
			log.Printf("Failed to process event: %v", err)
		} else {
			processed++
		}
	}

	respondJSON(w, WebhookResponse{Received: true, Processed: processed}, http.StatusOK)
}

func processEvent(event map[string]interface{}) error {
	eventType, _ := event["type"].(string)

	var username, ipAddress string
	if user, ok := event["user"].(map[string]interface{}); ok {
		username, _ = user["username"].(string)
		ipAddress, _ = user["ip_address"].(string)
	}

	log.Printf("Processing %s: user=%s, ip=%s", eventType, username, ipAddress)

	// Example: Store in database
	// if err := db.InsertEvent(event); err != nil {
	//     return fmt.Errorf("database error: %w", err)
	// }

	// Example: Send alert
	// if eventType == "LOGIN_ERROR" {
	//     checkBruteForce(username, ipAddress)
	// }

	return nil
}

func respondJSON(w http.ResponseWriter, data interface{}, status int) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(data)
}

Authentication

Skycloak supports two authentication methods for webhooks:

Bearer Token (Recommended)

Simple and secure for most use cases:

// Verify Bearer token
const authHeader = req.headers.authorization;
const token = authHeader.substring(7); // Remove "Bearer "

if (token !== WEBHOOK_SECRET) {
  return res.status(403).json({ error: 'Invalid token' });
}

Configuration in Skycloak:

  • Authentication Type: Bearer Token
  • Auth Token: Your secure random token (e.g., sk_live_abc123...)

Basic Authentication

Username and password encoded in Base64:

// Verify Basic auth
const authHeader = req.headers.authorization;
const base64Credentials = authHeader.substring(6); // Remove "Basic "
const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii');
const [username, password] = credentials.split(':');

if (username !== EXPECTED_USERNAME || password !== EXPECTED_PASSWORD) {
  return res.status(403).json({ error: 'Invalid credentials' });
}

Configuration in Skycloak:

  • Authentication Type: Basic Auth
  • Auth Token: username:password (Skycloak will Base64 encode it)

Testing Your Webhook

Local Development with ngrok

  1. Start your webhook server locally:
node webhook-server.js
# Server running on http://localhost:3000
  1. Expose it with ngrok:
ngrok http 3000
# Forwarding https://abc123.ngrok.io -> http://localhost:3000
  1. Use the ngrok HTTPS URL in Skycloak:
https://abc123.ngrok.io/webhooks/skycloak-events

Testing with curl

Send a test payload to your endpoint:

curl -X POST https://your-domain.com/webhooks/skycloak-events \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your-secret-token" \
  -d '{
    "source": "skycloak",
    "type": "keycloak_events",
    "timestamp": "2025-01-15T14:30:00Z",
    "event_count": 1,
    "events": [
      {
        "type": "LOGIN",
        "timestamp": "2025-01-15T14:29:45Z",
        "realm": { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "production" },
        "auth": { "client_id": "web-app" },
        "user": { "id": "user-uuid", "username": "[email protected]", "ip_address": "203.0.113.42" },
        "metadata": { "event_type": "keycloak_events", "workspace_id": "ws-uuid", "cluster_id": "cl-uuid", "source": "skycloak" },
        "details": { "event_category": "user", "severity": "info", "priority": 1 }
      }
    ]
  }'

Testing with Postman

  1. Create new POST request
  2. URL: https://your-domain.com/webhooks/skycloak-events
  3. Headers:
    • Content-Type: application/json
    • Authorization: Bearer your-secret-token
  4. Body (raw JSON): Copy the test payload from above
  5. Send and verify 200 response

Response Codes

Your webhook endpoint should return appropriate HTTP status codes:

Code Meaning Skycloak Behavior
200-299 Success Event marked as delivered
400-499 Client error Event marked as failed (no retry)
500-599 Server error Will retry with exponential backoff
Timeout No response after 30s Will retry

Important: Always return 200 to acknowledge receipt, even if processing fails. Handle errors internally and retry later if needed.

Error Handling

Idempotency

Skycloak may send the same event multiple times due to retries. Implement idempotency:

const processedEvents = new Set();

async function processEvent(event) {
  // Create unique ID from event
  const eventId = event.details?.id || `${event.type}-${event.timestamp}-${event.user?.id}`;

  // Skip if already processed
  if (processedEvents.has(eventId)) {
    console.log(`Skipping duplicate event: ${eventId}`);
    return;
  }

  // Process event
  await saveToDatabase(event);

  // Mark as processed
  processedEvents.add(eventId);
}

For production, use a database or cache (Redis) instead of in-memory Set.

Retry Logic

If processing fails, queue for retry:

const queue = require('bull'); // Or your preferred queue
const eventQueue = queue('events');

app.post('/webhooks/skycloak-events', async (req, res) => {
  const { events } = req.body;

  // Acknowledge immediately
  res.status(200).json({ received: true });

  // Queue for processing
  for (const event of events) {
    await eventQueue.add(event, {
      attempts: 3,
      backoff: {
        type: 'exponential',
        delay: 2000
      }
    });
  }
});

// Process events asynchronously
eventQueue.process(async (job) => {
  await processEvent(job.data);
});

Production Best Practices

1. Use HTTPS

Always use HTTPS in production:

const https = require('https');
const fs = require('fs');

const options = {
  key: fs.readFileSync('private-key.pem'),
  cert: fs.readFileSync('certificate.pem')
};

https.createServer(options, app).listen(443);

Or use a reverse proxy (nginx, Cloudflare) to handle SSL.

2. Rate Limiting

Protect your endpoint from abuse:

const rateLimit = require('express-rate-limit');

const limiter = rateLimit({
  windowMs: 1 * 60 * 1000, // 1 minute
  max: 100, // 100 requests per minute
  message: 'Too many requests'
});

app.use('/webhooks', limiter);

3. Request Size Limits

Limit payload size to prevent DoS:

app.use(express.json({ limit: '10mb' }));

4. Logging

Log all webhook requests for debugging:

const winston = require('winston');

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    new winston.transports.File({ filename: 'webhooks.log' })
  ]
});

app.post('/webhooks/skycloak-events', (req, res) => {
  logger.info('Webhook received', {
    timestamp: new Date().toISOString(),
    event_count: req.body.event_count,
    source_ip: req.ip
  });

  // Process...
});

5. Monitoring

Monitor webhook health:

const prometheus = require('prom-client');

const webhookCounter = new prometheus.Counter({
  name: 'webhook_requests_total',
  help: 'Total webhook requests'
});

const webhookDuration = new prometheus.Histogram({
  name: 'webhook_duration_seconds',
  help: 'Webhook processing duration'
});

app.post('/webhooks/skycloak-events', async (req, res) => {
  const start = Date.now();
  webhookCounter.inc();

  // Process...

  webhookDuration.observe((Date.now() - start) / 1000);
  res.status(200).json({ received: true });
});

6. Secrets Management

Never hardcode secrets:

// Bad
const WEBHOOK_SECRET = 'my-secret-token';

// Good - Use environment variables
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;

// Better - Use secrets manager
const AWS = require('aws-sdk');
const secrets = new AWS.SecretsManager();

async function getSecret() {
  const data = await secrets.getSecretValue({
    SecretId: 'skycloak/webhook-secret'
  }).promise();

  return data.SecretString;
}

Use Cases

1. Security Monitoring

Alert on suspicious login patterns:

async function processEvent(event) {
  if (event.type === 'LOGIN_ERROR') {
    const username = event.user?.username;
    const ipAddress = event.user?.ip_address;

    const recentFailures = await countRecentFailedLogins(username, ipAddress);

    if (recentFailures >= 5) {
      await sendAlert({
        type: 'brute_force_detected',
        username,
        ip: ipAddress,
        count: recentFailures
      });
    }
  }
}

2. User Analytics

Track authentication metrics:

async function processEvent(event) {
  if (event.type === 'LOGIN') {
    await analytics.track({
      userId: event.user?.id,
      event: 'User Login',
      properties: {
        realm: event.realm?.name,
        client: event.auth?.client_id,
        ip: event.user?.ip_address,
        timestamp: event.timestamp
      }
    });
  }
}

3. Compliance Logging

Store events for audit trail:

async function processEvent(event) {
  await db.auditLog.create({
    timestamp: new Date(event.timestamp),
    event_type: event.type,
    user_id: event.user?.id,
    username: event.user?.username,
    ip_address: event.user?.ip_address,
    realm: event.realm?.name,
    details: event.details,
    retention_until: new Date(Date.now() + 7 * 365 * 24 * 60 * 60 * 1000) // 7 years
  });
}

4. Real-time Notifications

Notify users of account activity:

async function processEvent(event) {
  if (event.type === 'LOGIN' && isUnusualLocation(event.user?.ip_address)) {
    await sendEmail({
      to: event.user?.username,
      subject: 'New login from unusual location',
      body: `Someone logged into your account from ${event.user?.ip_address}`
    });
  }
}

Troubleshooting

Webhook Not Receiving Events

  1. Check URL is publicly accessible:
curl -I https://your-domain.com/webhooks/skycloak-events
  1. Verify SSL certificate (must be valid):
curl -v https://your-domain.com/webhooks/skycloak-events
  1. Check firewall rules - Allow inbound HTTPS (port 443)

  2. Review Skycloak destination status - Check error message in UI

Authentication Failures

  1. Verify token matches - Check environment variables
  2. Check header format - Should be Authorization: Bearer <token>
  3. Test with curl - Verify manually with correct headers

Events Not Processing

  1. Check logs - Look for errors in application logs
  2. Verify payload structure - Log req.body to debug
  3. Test idempotency - Ensure duplicate events aren’t causing issues

Performance Issues

  1. Process events asynchronously - Don’t block webhook response
  2. Use queue for heavy processing - Bull, RabbitMQ, SQS
  3. Scale horizontally - Run multiple webhook servers behind load balancer
  4. Optimize database queries - Use indexes, batch inserts

Security Checklist

  • ✅ HTTPS only (no HTTP in production)
  • ✅ Bearer token or Basic auth enabled
  • ✅ Secrets stored in environment variables or secrets manager
  • ✅ Request size limits enforced
  • ✅ Rate limiting configured
  • ✅ Input validation on all fields
  • ✅ Logging enabled (but not logging secrets)
  • ✅ Monitoring and alerting configured
  • ✅ Error handling prevents sensitive data exposure
  • ✅ Webhook endpoint not exposed in public documentation

Next Steps

  • Set up your webhook endpoint
  • Configure in Skycloak SIEM Integration
  • Test with sample events
  • Monitor webhook health
  • Implement your use case (security alerts, analytics, compliance)

Support

For webhook integration questions:

Last updated on