Skip to main content
Always verify webhook signatures to ensure requests come from Kapso.

Signature verification

Kapso signs all webhooks with HMAC SHA256 using your webhook secret key. The signature is included in the X-Webhook-Signature header.

How it works

  1. Kapso creates a signature by hashing the raw JSON payload with your secret key
  2. The signature is sent in the X-Webhook-Signature header
  3. Your endpoint recreates the signature using the same method
  4. Compare signatures using a timing-safe comparison

Node.js example

const crypto = require('crypto');

function verifyWebhook(payload, signature, secret) {
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(JSON.stringify(payload))
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

app.post('/webhooks/whatsapp', (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  const isValid = verifyWebhook(
    req.body,
    signature,
    process.env.WEBHOOK_SECRET
  );

  if (!isValid) {
    return res.status(401).send('Invalid signature');
  }

  // Process webhook
  console.log('Event:', req.body.event);
  res.status(200).send('OK');
});

Python example

import hmac
import hashlib
import json
from flask import Flask, request

app = Flask(__name__)

def verify_webhook(payload, signature, secret):
    expected_signature = hmac.new(
        secret.encode('utf-8'),
        json.dumps(payload).encode('utf-8'),
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(signature, expected_signature)

@app.route('/webhooks/whatsapp', methods=['POST'])
def webhook():
    signature = request.headers.get('X-Webhook-Signature')
    is_valid = verify_webhook(
        request.json,
        signature,
        os.environ['WEBHOOK_SECRET']
    )

    if not is_valid:
        return 'Invalid signature', 401

    # Process webhook
    print('Event:', request.json['event'])
    return 'OK', 200

Ruby example

require 'sinatra'
require 'json'
require 'openssl'

def verify_webhook(payload, signature, secret)
  expected_signature = OpenSSL::HMAC.hexdigest(
    'SHA256',
    secret,
    payload.to_json
  )

  Rack::Utils.secure_compare(signature, expected_signature)
end

post '/webhooks/whatsapp' do
  request.body.rewind
  payload = JSON.parse(request.body.read)
  signature = request.env['HTTP_X_WEBHOOK_SIGNATURE']

  unless verify_webhook(payload, signature, ENV['WEBHOOK_SECRET'])
    halt 401, 'Invalid signature'
  end

  # Process webhook
  puts "Event: #{payload['event']}"
  status 200
end

Important notes

Use the raw payload

Always verify against the raw JSON payload, not a parsed object:
// ❌ Wrong - verifying parsed object
verifyWebhook(req.body, signature, secret)

// ✅ Correct - verifying raw string
const rawBody = JSON.stringify(req.body);
verifyWebhook(rawBody, signature, secret)

Use timing-safe comparison

Never use === or == to compare signatures. Use timing-safe comparison to prevent timing attacks:
// ❌ Wrong - vulnerable to timing attacks
if (signature === expectedSignature) { ... }

// ✅ Correct - timing-safe comparison
crypto.timingSafeEqual(
  Buffer.from(signature),
  Buffer.from(expectedSignature)
)

Store secrets securely

  • Never hardcode webhook secrets in your code
  • Use environment variables or secret management services
  • Rotate secrets periodically
  • Use different secrets for development and production

Idempotency

Webhooks may be delivered more than once. Use the X-Idempotency-Key header to track processed events.

Simple in-memory tracking

const processedKeys = new Set();

app.post('/webhooks', (req, res) => {
  const idempotencyKey = req.headers['x-idempotency-key'];

  if (processedKeys.has(idempotencyKey)) {
    return res.status(200).send('Already processed');
  }

  // Process event
  processEvent(req.body);

  processedKeys.add(idempotencyKey);
  res.status(200).send('OK');
});

Database-backed tracking

app.post('/webhooks', async (req, res) => {
  const idempotencyKey = req.headers['x-idempotency-key'];

  // Check if already processed
  const existing = await db.webhookEvents.findOne({
    idempotency_key: idempotencyKey
  });

  if (existing) {
    return res.status(200).send('Already processed');
  }

  // Process event
  await processEvent(req.body);

  // Store idempotency key
  await db.webhookEvents.create({
    idempotency_key: idempotencyKey,
    event: req.body.event,
    processed_at: new Date()
  });

  res.status(200).send('OK');
});

Best practices

  1. Verify signatures first - Before processing any webhook data
  2. Return 200 quickly - Respond within 10 seconds to avoid retries
  3. Process asynchronously - Use background jobs for heavy processing
  4. Handle duplicates - Implement idempotency using X-Idempotency-Key
  5. Monitor failures - Set up alerts for signature verification failures
  6. Use HTTPS only - Never accept webhooks over HTTP
  7. Rotate secrets - Change webhook secrets periodically
  8. Log everything - Keep audit logs of webhook deliveries and failures