# 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](https://developer.kompliant.com/docs/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](https://developer.kompliant.com/docs/webhook-notifications). This section provides a quick reference for implementation purposes: **Quick Example:** ```json { "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 `id` field ## Example Endpoint Setup ```javascript // 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 # 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: ```javascript 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`: ```javascript 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: ```javascript 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 ```javascript NodeJs 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 } } ``` ```python 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 ``` ```go 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: ```json { "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`: ```javascript 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: ```javascript 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:** ```javascript // 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: ```json { "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: ```javascript 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](https://developer.kompliant.com/docs/webhook-integration-guide).