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:
- Set up an HTTPS endpoint to receive POST requests
- Validate the webhook structure
- Decrypt the payload using AES-256-GCM
- Process the event data
- 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
| Field | Encrypted? | Description |
|---|---|---|
id | ❌ No | Unique webhook message identifier for deduplication |
event_type | ❌ No | Type of event (e.g., WORKFLOW_COMPLETED, DOCUMENT_UPLOADED) |
timestamp | ❌ No | ISO 8601 timestamp when the event occurred |
account_id | ❌ No | Your account identifier |
schema_version | ❌ No | Webhook envelope schema version |
key_id | ❌ No | Identifier of the encryption key used |
data | ✅ Yes | Base64-encoded encrypted payload containing event details |
retry_count | ❌ No | Number 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
idfield
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_countfield 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
- Always use HTTPS for your webhook endpoint
- Validate the account_id matches your expected account
- Verify decryption succeeds before trusting the payload
- Implement deduplication using the webhook
id - Store shared secrets securely in a secrets manager
- Rotate keys regularly (every 30-90 days)
- Log webhook IDs for debugging, not payloads
- Return 200 quickly and process asynchronously if needed
❌ Avoid This
- Don't skip decryption - always decrypt and verify
- Don't log decrypted payloads - they contain sensitive data
- Don't trust metadata without decryption - it's authenticated only after decryption
- Don't expose webhook endpoints without authentication (HTTPS is required)
- Don't store shared secrets in your codebase or version control
- 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:
- Deploy your webhook handler to a production environment
- Configure webhook URLs with Kompliant (contact support)
- Monitor webhook deliveries and processing success rates
- Implement key rotation for enhanced security
For information on managing webhook encryption keys, see Webhook Integration Guide.
Updated 28 days ago