Receiving and decrypting webhooks

This guide shows you how to receive webhook notifications from Kompliant, decrypt the payloads, and process the events in your application.

📘

Webhook Fundamentals

For details on webhook structure, encryption model, and what you can expect to receive, see Webhook Notifications.
This guide focuses on the implementation details for receiving and decrypting webhooks.

Overview

Kompliant sends webhook notifications to your configured endpoint URL whenever important events occur in your workflows. To set up your infrastructure to receive these webhooks, you need to:

  1. Set up an HTTPS endpoint to receive POST requests
  2. Validate the webhook structure
  3. Decrypt the payload using AES-256-GCM
  4. Process the event data
  5. Respond appropriately to ensure delivery confirmation

This guide provides complete implementation examples for common programming languages.

Whitelist IPs

Webhook notifications are sent from one of the following IP addresses. Partners are encouraged to whitelist all of these IP addresses in their applications.

3.230.141.132
35.166.77.7
52.27.236.245
54.86.192.161

Webhook Structure

For detailed information on webhook structure, fields, and the overall encryption model, see Webhook Notifications.

This section provides a quick reference for implementation purposes:

Quick Example:

{
  "id": "wh_2K9mPxR7N4jL8hS6TdWfY3",
  "event_type": "WORKFLOW_COMPLETED",
  "timestamp": "2025-10-21T15:42:33Z",
  "account_id": "lv_4K8mPxR9N2jL7hS5TdWfY1",
  "schema_version": "2025-10-21",
  "key_id": "whk_20251021_01",
  "data": "AAAAAGTlm...encrypted_base64_payload...==",
  "retry_count": 0
}

Field Descriptions

FieldEncrypted?Description
id❌ NoUnique webhook message identifier for deduplication
event_type❌ NoType of event (e.g., WORKFLOW_COMPLETED, DOCUMENT_UPLOADED)
timestamp❌ NoISO 8601 timestamp when the event occurred
account_id❌ NoYour account identifier
schema_version❌ NoWebhook envelope schema version
key_id❌ NoIdentifier of the encryption key used
data✅ YesBase64-encoded encrypted payload containing event details
retry_count❌ NoNumber of delivery attempts (0 for first attempt)

📘

Understanding Encryption

The metadata fields (id, event_type, etc.) are authenticated but not encrypted. This allows you to route events without decryption. However, they are cryptographically verified during decryption to prevent tampering.

Step 1: Set Up Your Webhook Endpoint

Create an HTTPS endpoint in your application that can receive POST requests from Kompliant.

Requirements

  • HTTPS only: Webhooks will not be sent to HTTP endpoints
  • Public accessibility: The endpoint must be reachable from the internet
  • Fast response: Return HTTP 200 within 30 seconds to avoid retries
  • Idempotency: Handle duplicate deliveries gracefully using the id field

Example Endpoint Setup

// Node.js with Express
const express = require('express');
const app = express();

app.use(express.json());

app.post('/webhooks/kompliant', async (req, res) => {
  try {
    const webhook = req.body;

    // Process webhook (implementation shown in next sections)
    await processWebhook(webhook);

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

  } catch (error) {
    console.error('Webhook processing error:', error);
    // Return 500 to trigger retry
    res.status(500).json({ error: 'Processing failed' });
  }
});

app.listen(443, () => {
  console.log('Webhook endpoint listening on port 443');
});
# Python with Flask
from flask import Flask, request, jsonify
import logging

app = Flask(__name__)
logger = logging.getLogger(__name__)

@app.route('/webhooks/kompliant', methods=['POST'])
def webhook_handler():
    try:
        webhook = request.get_json()

        # Process webhook (implementation shown in next sections)
        process_webhook(webhook)

        # Return 200 immediately to confirm receipt
        return jsonify({'received': True}), 200

    except Exception as e:
        logger.error(f'Webhook processing error: {e}')
        # Return 500 to trigger retry
        return jsonify({'error': 'Processing failed'}), 500

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=443, ssl_context='adhoc')

⚠️

Respond Quickly

Always return HTTP 200 as quickly as possible. If you need to perform time-consuming operations, queue the webhook for asynchronous processing and return the success response immediately.

Step 2: Validate the Webhook

Before decrypting, perform basic validation:

function validateWebhook(webhook) {
  // Check required fields
  const requiredFields = ['id', 'event_type', 'timestamp', 'account_id',
                          'schema_version', 'key_id', 'data'];

  for (const field of requiredFields) {
    if (!webhook[field]) {
      throw new Error(`Missing required field: ${field}`);
    }
  }

  // Validate account_id matches your account
  if (webhook.account_id !== process.env.KOMPLIANT_ACCOUNT_ID) {
    throw new Error(`Invalid account_id: ${webhook.account_id}`);
  }

  // Check for duplicate webhooks using id
  if (isDuplicate(webhook.id)) {
    console.log(`Duplicate webhook ${webhook.id}, skipping`);
    return false; // Skip processing but return 200
  }

  return true;
}

Step 3: Decrypt the Payload

The data field contains the encrypted event payload. You must decrypt it using AES-256-GCM with the shared secret for the specified key_id.

Understanding AES-256-GCM with AAD

Kompliant uses AES-256-GCM (Galois/Counter Mode) encryption with Additional Authenticated Data (AAD). This provides:

  • Encryption: The payload data is encrypted
  • Authentication: Both the encrypted data AND the metadata are authenticated
  • Tamper Detection: Any modification to encrypted data or metadata causes decryption to fail

What is AAD?

AAD (Additional Authenticated Data) is data that is authenticated but not encrypted. In Kompliant webhooks:

  • The metadata fields are concatenated and used as AAD
  • During decryption, GCM verifies both the encrypted payload AND the AAD
  • If anyone tampers with event_type, timestamp, or other metadata, decryption fails

Decryption Process

Step 1: Retrieve the Shared Secret

Look up the shared secret for the given key_id:

function getSharedSecret(keyId) {
  const secrets = {
    'whk_20251021_01': process.env.WEBHOOK_KEY_20251021_01,
    'whk_20251021_02': process.env.WEBHOOK_KEY_20251021_02
  };

  const secret = secrets[keyId];
  if (!secret) {
    throw new Error(`Unknown key_id: ${keyId}`);
  }

  // Shared secret is base64-encoded, decode to binary
  return Buffer.from(secret, 'base64');
}

Step 2: Construct the AAD

The AAD is formed by concatenating metadata fields with newlines:

function constructAAD(webhook) {
  // Order matters! Must match Kompliant's construction
  const aadString = [
    webhook.id,
    webhook.event_type,
    webhook.timestamp,
    webhook.account_id,
    webhook.schema_version,
    webhook.key_id
  ].join('\n');

  return Buffer.from(aadString, 'utf8');
}

Step 3: Decrypt Using AES-256-GCM

The encrypted data field is base64-encoded and contains:

  • IV/Nonce (first 12 bytes): Initialization vector
  • Ciphertext + Auth Tag (remaining bytes): Encrypted payload with GCM authentication tag

Complete Decryption Examples

const crypto = require('crypto');

function decryptWebhookData(webhook) {
  // 1. Get the shared secret for this key_id
  const sharedSecret = getSharedSecret(webhook.key_id);

  // 2. Decode the base64 encrypted data
  const encryptedData = Buffer.from(webhook.data, 'base64');

  // 3. Extract IV (first 12 bytes) and ciphertext (remaining bytes)
  const iv = encryptedData.slice(0, 12);
  const ciphertext = encryptedData.slice(12);

  // 4. Construct AAD from metadata
  const aad = constructAAD(webhook);

  // 5. Create decipher
  const decipher = crypto.createDecipheriv('aes-256-gcm', sharedSecret, iv);

  // 6. Set AAD for authentication
  decipher.setAAD(aad);

  // 7. Set auth tag (last 16 bytes of ciphertext)
  const authTag = ciphertext.slice(-16);
  decipher.setAuthTag(authTag);

  // 8. Decrypt (will throw if authentication fails)
  const actualCiphertext = ciphertext.slice(0, -16);
  let decrypted = decipher.update(actualCiphertext);
  decrypted = Buffer.concat([decrypted, decipher.final()]);

  // 9. Parse JSON payload
  return JSON.parse(decrypted.toString('utf8'));
}

// Helper function to construct AAD
function constructAAD(webhook) {
  const aadString = [
    webhook.id,
    webhook.event_type,
    webhook.timestamp,
    webhook.account_id,
    webhook.schema_version,
    webhook.key_id
  ].join('\n');

  return Buffer.from(aadString, 'utf8');
}

// Helper function to get shared secret
function getSharedSecret(keyId) {
  const secrets = {
    'whk_20251021_01': process.env.WEBHOOK_KEY_20251021_01
  };

  if (!secrets[keyId]) {
    throw new Error(`Unknown key_id: ${keyId}`);
  }

  return Buffer.from(secrets[keyId], 'base64');
}

// Complete example
function processWebhook(webhook) {
  try {
    // Validate
    if (!validateWebhook(webhook)) {
      return; // Duplicate or invalid
    }

    // Decrypt
    const payload = decryptWebhookData(webhook);

    // Process based on event_type
    handleEvent(webhook.event_type, payload);

  } catch (error) {
    console.error('Failed to process webhook:', error);
    throw error; // Will trigger retry
  }
}
import base64
import json
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.exceptions import InvalidTag

def decrypt_webhook_data(webhook):
    """Decrypt webhook payload using AES-256-GCM with AAD"""

    # 1. Get the shared secret for this key_id
    shared_secret = get_shared_secret(webhook['key_id'])

    # 2. Decode the base64 encrypted data
    encrypted_data = base64.b64decode(webhook['data'])

    # 3. Extract IV (first 12 bytes) and ciphertext with tag (remaining)
    iv = encrypted_data[:12]
    ciphertext_with_tag = encrypted_data[12:]

    # 4. Construct AAD from metadata
    aad = construct_aad(webhook)

    # 5. Create AESGCM instance
    aesgcm = AESGCM(shared_secret)

    # 6. Decrypt and verify (will raise InvalidTag if authentication fails)
    try:
        decrypted = aesgcm.decrypt(iv, ciphertext_with_tag, aad)
    except InvalidTag:
        raise ValueError("Webhook authentication failed - data may be tampered")

    # 7. Parse JSON payload
    return json.loads(decrypted.decode('utf-8'))

def construct_aad(webhook):
    """Construct Additional Authenticated Data from webhook metadata"""
    aad_string = '\n'.join([
        webhook['id'],
        webhook['event_type'],
        webhook['timestamp'],
        webhook['account_id'],
        webhook['schema_version'],
        webhook['key_id']
    ])
    return aad_string.encode('utf-8')

def get_shared_secret(key_id):
    """Retrieve shared secret from environment or secrets manager"""
    import os

    # Example: Get from environment variable
    secret_key = f'WEBHOOK_KEY_{key_id.replace("-", "_").upper()}'
    secret_base64 = os.getenv(secret_key)

    if not secret_base64:
        raise ValueError(f'Unknown key_id: {key_id}')

    return base64.b64decode(secret_base64)

# Complete example
def process_webhook(webhook):
    try:
        # Validate
        if not validate_webhook(webhook):
            return  # Duplicate or invalid

        # Decrypt
        payload = decrypt_webhook_data(webhook)

        # Process based on event_type
        handle_event(webhook['event_type'], payload)

    except Exception as e:
        print(f'Failed to process webhook: {e}')
        raise  # Will trigger retry
package main

import (
  "crypto/aes"
  "crypto/cipher"
  "encoding/base64"
  "encoding/json"
  "errors"
  "fmt"
  "strings"
)

type Webhook struct {
  ID            string `json:"id"`
  EventType     string `json:"event_type"`
  Timestamp     string `json:"timestamp"`
  AccountID     string `json:"account_id"`
  SchemaVersion string `json:"schema_version"`
  KeyID         string `json:"key_id"`
  Data          string `json:"data"`
  RetryCount    int    `json:"retry_count,omitempty"`
}

func decryptWebhookData(webhook *Webhook) (map[string]interface{}, error) {
  // 1. Get the shared secret
  sharedSecret, err := getSharedSecret(webhook.KeyID)
  if err != nil {
    return nil, err
  }

  // 2. Decode base64 encrypted data
  encryptedData, err := base64.StdEncoding.DecodeString(webhook.Data)
  if err != nil {
    return nil, fmt.Errorf("failed to decode data: %w", err)
  }

  // 3. Extract IV (first 12 bytes) and ciphertext
  if len(encryptedData) < 12 {
    return nil, errors.New("encrypted data too short")
  }
  iv := encryptedData[:12]
  ciphertext := encryptedData[12:]

  // 4. Construct AAD
  aad := constructAAD(webhook)

  // 5. Create cipher
  block, err := aes.NewCipher(sharedSecret)
  if err != nil {
    return nil, fmt.Errorf("failed to create cipher: %w", err)
  }

  aesgcm, err := cipher.NewGCM(block)
  if err != nil {
    return nil, fmt.Errorf("failed to create GCM: %w", err)
  }

  // 6. Decrypt and verify
  decrypted, err := aesgcm.Open(nil, iv, ciphertext, aad)
  if err != nil {
    return nil, fmt.Errorf("decryption failed (authentication error): %w", err)
  }

  // 7. Parse JSON
  var payload map[string]interface{}
  if err := json.Unmarshal(decrypted, &payload); err != nil {
    return nil, fmt.Errorf("failed to parse payload: %w", err)
  }

  return payload, nil
}

func constructAAD(webhook *Webhook) []byte {
  aadString := strings.Join([]string{
    webhook.ID,
    webhook.EventType,
    webhook.Timestamp,
    webhook.AccountID,
    webhook.SchemaVersion,
    webhook.KeyID,
  }, "\n")
  return []byte(aadString)
}

func getSharedSecret(keyID string) ([]byte, error) {
  // Retrieve from environment or secrets manager
  secretBase64 := os.Getenv("WEBHOOK_KEY_" + strings.ToUpper(strings.ReplaceAll(keyID, "-", "_")))
  if secretBase64 == "" {
    return nil, fmt.Errorf("unknown key_id: %s", keyID)
  }
  return base64.StdEncoding.DecodeString(secretBase64)
}

Step 4: Process the Event

Once decrypted, the payload contains event-specific data:

{
  "version": "1.0",
  "workflow_id": "w_4EiT4WbJdcLPz3buiZNcO8",
  "subject_record_id": "sr_6N48sDzY7ysrBFJIS4TtuD",
  "status": "COMPLETED",
  "business": {
    "business_id": "b_2I1ISsWEQia02V9tfolNJB",
    "legal_name": "Example Business LLC"
  }
}

Process the event based on event_type:

function handleEvent(eventType, payload) {
  switch (eventType) {
    case 'WORKFLOW_COMPLETED':
      handleWorkflowCompleted(payload);
      break;

    case 'WORKFLOW_FAILED':
      handleWorkflowFailed(payload);
      break;

    case 'DOCUMENT_UPLOADED':
      handleDocumentUploaded(payload);
      break;

    default:
      console.log(`Unknown event type: ${eventType}`);
  }
}

function handleWorkflowCompleted(payload) {
  console.log(`Workflow ${payload.workflow_id} completed`);

  // Update your database
  updateWorkflowStatus(payload.workflow_id, 'COMPLETED');

  // Notify relevant parties
  sendNotification(payload.business.legal_name, 'Application approved');
}

Step 5: Implement Deduplication

Webhooks may be delivered multiple times. Use the id field to detect and skip duplicates:

const processedWebhooks = new Set();

function isDuplicate(webhookId) {
  if (processedWebhooks.has(webhookId)) {
    return true;
  }

  processedWebhooks.add(webhookId);
  return false;
}

// For production, use a database or cache with TTL
const redis = require('redis');
const client = redis.createClient();

async function isDuplicateRedis(webhookId) {
  const key = `webhook:${webhookId}`;
  const exists = await client.exists(key);

  if (exists) {
    return true;
  }

  // Store for 7 days
  await client.setex(key, 7 * 24 * 60 * 60, '1');
  return false;
}

Retry Behavior and Error Handling

When to Return HTTP 200

Return 200 OK when:

  • ✅ Webhook was successfully decrypted and processed
  • ✅ Webhook is a duplicate (already processed)
  • ✅ Event type is unknown but webhook is valid

When to Return HTTP 500

Return 500 Internal Server Error when:

  • ❌ Decryption failed
  • ❌ Database connection failed
  • ❌ Required service is unavailable
  • ❌ Unexpected errors occurred

📘

Retry Logic

If you return a non-200 status code, Kompliant will retry delivery with exponential backoff:

  • Retry 1: 2 seconds
  • Retry 2: 4 seconds
  • Retry 3: 8 seconds
  • ...up to 95 retries over several hours

Check the retry_count field to know which attempt this is.

Handling Failed Webhooks

After 95 failed delivery attempts, webhooks are stored for manual recovery. Contact Kompliant support if you need to retrieve failed webhooks.

Security Best Practices

✅ Do This

  1. Always use HTTPS for your webhook endpoint
  2. Validate the account_id matches your expected account
  3. Verify decryption succeeds before trusting the payload
  4. Implement deduplication using the webhook id
  5. Store shared secrets securely in a secrets manager
  6. Rotate keys regularly (every 30-90 days)
  7. Log webhook IDs for debugging, not payloads
  8. Return 200 quickly and process asynchronously if needed

❌ Avoid This

  1. Don't skip decryption - always decrypt and verify
  2. Don't log decrypted payloads - they contain sensitive data
  3. Don't trust metadata without decryption - it's authenticated only after decryption
  4. Don't expose webhook endpoints without authentication (HTTPS is required)
  5. Don't store shared secrets in your codebase or version control
  6. Don't ignore the key_id - always use the correct key for decryption

Troubleshooting

Decryption Fails with Authentication Error

Possible Causes:

  • Using wrong shared secret for the key_id
  • AAD constructed incorrectly (wrong field order)
  • Shared secret not base64-decoded before use
  • Data was tampered with in transit

Solution:

// Double-check your AAD construction
const aad = [
  webhook.id,
  webhook.event_type,
  webhook.timestamp,
  webhook.account_id,
  webhook.schema_version,
  webhook.key_id
].join('\n'); // Must use '\n', not ',' or other separator

Unknown key_id Error

Problem: Webhook contains a key_id you don't recognize.

Solution:

  • Check if you recently rotated keys
  • Ensure your key mapping is up to date
  • Verify the shared secret for that key is loaded in your environment

Receiving Duplicate Webhooks

Problem: Same webhook id delivered multiple times.

Solution: This is expected behavior. Implement deduplication as shown in Step 5.

Webhooks Not Arriving

Possible Causes:

  • Webhook endpoint is not publicly accessible
  • Endpoint is not using HTTPS
  • Firewall blocking Kompliant's IP ranges
  • Endpoint returning errors (check your logs)

Solution:

  • Test endpoint accessibility from external network
  • Verify SSL certificate is valid
  • Check application logs for errors
  • Contact Kompliant support for delivery logs

Testing Your Integration

Test Webhook Example

Use this example to test your decryption implementation:

{
  "id": "wh_test_123",
  "event_type": "TEST_EVENT",
  "timestamp": "2025-10-21T15:42:33Z",
  "account_id": "lv_test_account",
  "schema_version": "2025-10-21",
  "key_id": "whk_20251021_01",
  "data": "[base64_encrypted_test_payload]",
  "retry_count": 0
}

Create a test endpoint that logs successful decryption:

app.post('/webhooks/test', async (req, res) => {
  try {
    const payload = decryptWebhookData(req.body);
    console.log('✅ Decryption successful!');
    console.log('Payload:', JSON.stringify(payload, null, 2));
    res.status(200).json({ success: true });
  } catch (error) {
    console.error('❌ Decryption failed:', error.message);
    res.status(500).json({ error: error.message });
  }
});

Next Steps

You're now ready to:

  1. Deploy your webhook handler to a production environment
  2. Configure webhook URLs with Kompliant (contact support)
  3. Monitor webhook deliveries and processing success rates
  4. Implement key rotation for enhanced security

For information on managing webhook encryption keys, see Webhook Integration Guide.