Skip to content

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.

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

Skycloak checks the webhook URL when you save the destination, and again on every edit. The URL must:

  • Start with https://. Plain http:// endpoints are refused, because the events being forwarded are authentication and security records.
  • Be complete, with a hostname. https:// on its own, or a bare hostname with no scheme, is refused.
  • Contain no spaces or control characters, and none of the characters <>"{}|\^`. If a value in your query string needs one of them, percent-encode it (%3C for <). Percent-encoded URLs are accepted as they are.
  • Be at most 1000 characters.

A path and a query string are both optional, so https://siem.example.com, https://siem.example.com/events and https://siem.example.com/events?source=skycloak are all fine.

If a URL is rejected, the dashboard shows the reason under the field and the destination is not saved. Destinations you saved before this check keep delivering, and you can still rename or disable them; you will be asked to correct the URL the next time you change it.

Your endpoint should also accept the events directly rather than redirect them. A redirect that sends Skycloak from https:// to a plain http:// address is refused, and the delivery fails, because it would put the events and your credential on the network unencrypted.

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

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.

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)

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

{
  "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": "john.doe@company.com",
        "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 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"
      }
    }
  ]
}
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
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"

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.


Raw Keycloak application log lines.

{
  "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": {}
    }
  ]
}
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=....


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": {}
    }
  ]
}
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

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

Skycloak supports two authentication methods for webhooks:

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

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

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": "test@example.com", "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 }
      }
    ]
  }'
  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

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.

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.

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

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.

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);

Limit payload size to prevent DoS:

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

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...
});

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

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;
}

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

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

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

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

  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
  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
  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
  • ✅ 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
  • 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)

For webhook integration questions: