Signature Verification

All webhook requests are signed using HMAC-SHA256. Always verify the signature to ensure the request is from Esca.

Signature Format

The signature is included in the X-Esca-Webhook-Signature header:

X-Esca-Webhook-Signature: t=1705574400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Component Description
t Unix timestamp when the webhook was sent
v1 HMAC-SHA256 signature

Verification Steps

  1. Extract the timestamp (t) and signature (v1) from the header
  2. Construct the signed payload: {timestamp}.{JSON payload}
  3. Compute the expected signature using HMAC-SHA256 with your webhook secret
  4. Compare signatures using a timing-safe comparison
  5. Optionally, reject requests with timestamps older than 5 minutes (replay attack protection)

Node.js Example

const crypto = require('crypto');

function verifyWebhookSignature(payload, signature, secret, toleranceSeconds = 300) {
  // Parse the signature header
  const parts = signature.split(',').reduce((acc, part) => {
    const [key, value] = part.split('=');
    acc[key] = value;
    return acc;
  }, {});

  const timestamp = parseInt(parts.t, 10);
  const receivedSignature = parts.v1;

  // Check timestamp tolerance (prevent replay attacks)
  const currentTime = Math.floor(Date.now() / 1000);
  if (Math.abs(currentTime - timestamp) > toleranceSeconds) {
    throw new Error('Webhook timestamp too old');
  }

  // Calculate expected signature
  const signedPayload = `${timestamp}.${JSON.stringify(payload)}`;
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  // Timing-safe comparison
  const sigBuffer = Buffer.from(receivedSignature, 'hex');
  const expectedBuffer = Buffer.from(expectedSignature, 'hex');

  if (sigBuffer.length !== expectedBuffer.length) {
    throw new Error('Invalid webhook signature');
  }

  if (!crypto.timingSafeEqual(sigBuffer, expectedBuffer)) {
    throw new Error('Invalid webhook signature');
  }

  return true;
}

// Express.js endpoint example
app.post('/webhooks/esca', express.json(), (req, res) => {
  const signature = req.headers['x-esca-webhook-signature'];
  const secret = process.env.ESCA_WEBHOOK_SECRET;

  try {
    verifyWebhookSignature(req.body, signature, secret);

    // Process the webhook
    const event = req.body;
    console.log(`Received ${event.type} event:`, event.data);

    // Handle different event types
    switch (event.type) {
      case 'virtual_account.credited':
        // Handle deposit
        break;
      case 'transfer.completed':
        // Handle successful transfer
        break;
      case 'transfer.failed':
        // Handle failed transfer
        break;
    }

    res.status(200).json({ received: true });
  } catch (err) {
    console.error('Webhook verification failed:', err.message);
    res.status(400).json({ error: 'Invalid signature' });
  }
});

Python Example

import hmac
import hashlib
import time
import json

def verify_webhook_signature(payload, signature, secret, tolerance_seconds=300):
    # Parse the signature header
    parts = dict(part.split('=') for part in signature.split(','))
    timestamp = int(parts['t'])
    received_signature = parts['v1']

    # Check timestamp tolerance
    current_time = int(time.time())
    if abs(current_time - timestamp) > tolerance_seconds:
        raise ValueError('Webhook timestamp too old')

    # Calculate expected signature
    signed_payload = f"{timestamp}.{json.dumps(payload, separators=(',', ':'))}"
    expected_signature = hmac.new(
        secret.encode('utf-8'),
        signed_payload.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()

    # Timing-safe comparison
    if not hmac.compare_digest(received_signature, expected_signature):
        raise ValueError('Invalid webhook signature')

    return True

# Flask endpoint example
@app.route('/webhooks/esca', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-Esca-Webhook-Signature')
    secret = os.environ.get('ESCA_WEBHOOK_SECRET')

    try:
        verify_webhook_signature(request.json, signature, secret)

        event = request.json
        print(f"Received {event['type']} event:", event['data'])

        return jsonify({'received': True}), 200
    except ValueError as e:
        return jsonify({'error': str(e)}), 400

Common Verification Errors

Error Cause Solution
Timestamp too old Request took too long or replay attack Check server time sync, increase tolerance if needed
Invalid signature Wrong secret or payload modified Verify you're using the correct webhook secret
Buffer length mismatch Corrupted signature Ensure header is not truncated