Skip to main content
When you receive a webhook from Exa, you should verify that it came from us to ensure the integrity and authenticity of the data. Exa signs all webhook payloads with a secret key that’s unique to your webhook endpoint.

How Webhook Signatures Work

Exa uses HMAC SHA256 to sign webhook payloads. The signature is included in the Exa-Signature header, which contains:
  • A timestamp (t=) indicating when the webhook was sent
  • One or more signatures (v1=) computed using the timestamp and payload
The signature format looks like this:
Exa-Signature: t=1234567890,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

Verification Process

To verify a webhook signature:
  1. Extract the timestamp and signatures from the Exa-Signature header
  2. Create the signed payload by concatenating the timestamp, a period, and the raw request body
  3. Compute the expected signature using HMAC SHA256 with your webhook secret
  4. Compare your computed signature with the provided signatures
import hmac
import hashlib
import time

def verify_webhook_signature(payload, signature_header, webhook_secret):
    """
    Verify the signature of a webhook payload.

    Args:
        payload (str): The raw request body as a string
        signature_header (str): The Exa-Signature header value
        webhook_secret (str): Your webhook secret

    Returns:
        bool: True if signature is valid, False otherwise
    """
    try:
        # Parse the signature header
        pairs = [pair.split('=', 1) for pair in signature_header.split(',')]
        timestamp = None
        signatures = []

        for key, value in pairs:
            if key == 't':
                timestamp = value
            elif key == 'v1':
                signatures.append(value)

        if not timestamp or not signatures:
            return False

        # Optional: Check if timestamp is recent (within 5 minutes)
        current_time = int(time.time())
        if abs(current_time - int(timestamp)) > 300:
            print("Warning: Webhook timestamp is more than 5 minutes old")

        # Create the signed payload
        signed_payload = f"{timestamp}.{payload}"

        # Compute the expected signature
        expected_signature = hmac.new(
            webhook_secret.encode('utf-8'),
            signed_payload.encode('utf-8'),
            hashlib.sha256
        ).hexdigest()

        # Compare with provided signatures
        return any(hmac.compare_digest(expected_signature, sig) for sig in signatures)

    except Exception as e:
        print(f"Error verifying signature: {e}")
        return False

# Example usage in a Flask webhook endpoint
from flask import Flask, request, jsonify
import os

app = Flask(__name__)

@app.route('/webhook', methods=['POST'])
def handle_webhook():
    # Get the raw payload and signature
    payload = request.get_data(as_text=True)
    signature_header = request.headers.get('Exa-Signature', '')
    webhook_secret = os.environ.get('WEBHOOK_SECRET')

    # Verify the signature
    if not verify_webhook_signature(payload, signature_header, webhook_secret):
        return jsonify({'error': 'Invalid signature'}), 400

    # Process the webhook
    webhook_data = request.get_json()
    print(f"Received {webhook_data['type']} event")

    return jsonify({'status': 'success'}), 200
const crypto = require('crypto');

function verifyWebhookSignature(payload, signatureHeader, webhookSecret) {
    /**
     * Verify the signature of a webhook payload.
     *
     * @param {string} payload - The raw request body as a string
     * @param {string} signatureHeader - The Exa-Signature header value
     * @param {string} webhookSecret - Your webhook secret
     * @returns {boolean} True if signature is valid, false otherwise
     */
    try {
        // Parse the signature header
        const pairs = signatureHeader.split(',').map(pair => pair.split('='));
        const timestamp = pairs.find(([key]) => key === 't')?.[1];
        const signatures = pairs
            .filter(([key]) => key === 'v1')
            .map(([, value]) => value);

        if (!timestamp || signatures.length === 0) {
            return false;
        }

        // Optional: Check if timestamp is recent (within 5 minutes)
        const currentTime = Math.floor(Date.now() / 1000);
        if (Math.abs(currentTime - parseInt(timestamp)) > 300) {
            console.warn('Warning: Webhook timestamp is more than 5 minutes old');
        }

        // Create the signed payload
        const signedPayload = `${timestamp}.${payload}`;

        // Compute the expected signature
        const expectedSignature = crypto
            .createHmac('sha256', webhookSecret)
            .update(signedPayload)
            .digest('hex');

        // Compare with provided signatures using timing-safe comparison
        return signatures.some(sig =>
            crypto.timingSafeEqual(
                Buffer.from(expectedSignature, 'hex'),
                Buffer.from(sig, 'hex')
            )
        );

    } catch (error) {
        console.error('Error verifying signature:', error);
        return false;
    }
}

// Example usage in an Express.js webhook endpoint
const express = require('express');
const app = express();

// Important: Use raw body parser for webhook verification
app.use('/webhook', express.raw({ type: 'application/json' }));

app.post('/webhook', (req, res) => {
    const payload = req.body.toString();
    const signatureHeader = req.headers['exa-signature'] || '';
    const webhookSecret = process.env.WEBHOOK_SECRET;

    // Verify the signature
    if (!verifyWebhookSignature(payload, signatureHeader, webhookSecret)) {
        return res.status(400).json({ error: 'Invalid signature' });
    }

    // Process the webhook
    const webhookData = JSON.parse(payload);
    console.log(`Received ${webhookData.type} event`);

    res.json({ status: 'success' });
});
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;

public class WebhookTest {

    /**
    * Verify the signature of a webhook payload.
    *
    * @param payload The raw request body as a string
    * @param signatureHeader The Exa-Signature header value
    * @param webhookSecret Your webhook secret
    * @return true if signature is valid, false otherwise
    */
    public static boolean verifyWebhookSignature(String payload, String signatureHeader, String webhookSecret) {
        try {
            // Parse the signature header
            String[] pairs = signatureHeader.split(",");
            String timestamp = null;
            List<String> signatures = new ArrayList<>();

            for (String pair : pairs) {
                String[] keyValue = pair.split("=", 2);
                if (keyValue.length == 2) {
                    String key = keyValue[0];
                    String value = keyValue[1];

                    if ("t".equals(key)) {
                        timestamp = value;
                    } else if ("v1".equals(key)) {
                        signatures.add(value);
                    }
                }
            }

            if (timestamp == null || signatures.isEmpty()) {
                return false;
            }

            // Optional: Check if timestamp is recent (within 5 minutes)
            long currentTime = Instant.now().getEpochSecond();
            long webhookTime = Long.parseLong(timestamp);
            if (Math.abs(currentTime - webhookTime) > 300) {
                System.out.println("Warning: Webhook timestamp is more than 5 minutes old");
            }

            // Create the signed payload
            String signedPayload = timestamp + "." + payload;

            // Compute the expected signature
            String expectedSignature = computeHmacSha256(signedPayload, webhookSecret);

            // Compare with provided signatures using timing-safe comparison
            return signatures.stream().anyMatch(sig -> timingSafeEquals(expectedSignature, sig));

        } catch (Exception e) {
            System.err.println("Error verifying signature: " + e.getMessage());
            return false;
        }
    }

    /**
    * Compute HMAC SHA256 signature.
    */
    private static String computeHmacSha256(String data, String key)
            throws NoSuchAlgorithmException, InvalidKeyException {
        Mac mac = Mac.getInstance("HmacSHA256");
        SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
        mac.init(secretKeySpec);
        byte[] hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
        return bytesToHex(hash);
    }

    /**
    * Convert byte array to hexadecimal string.
    */
    private static String bytesToHex(byte[] bytes) {
        StringBuilder result = new StringBuilder();
        for (byte b : bytes) {
            result.append(String.format("%02x", b));
        }
        return result.toString();
    }

    /**
    * Timing-safe string comparison to prevent timing attacks.
    */
    private static boolean timingSafeEquals(String a, String b) {
        if (a.length() != b.length()) {
            return false;
        }

        int result = 0;
        for (int i = 0; i < a.length(); i++) {
            result |= a.charAt(i) ^ b.charAt(i);
        }
        return result == 0;
    }

    // Example usage and test
    public static void main(String[] args) {
        System.out.println("🚀 === Exa Webhook Signature Verification Test ===\n");

        // Test with a known payload and signature
        String testPayload = "{\"type\":\"webset.created\",\"data\":{\"id\":\"ws_test\"}}";
        String testSecret = "test_webhook_secret";
        String testTimestamp = String.valueOf(Instant.now().getEpochSecond());

        try {
            // Create test signature
            String signedPayload = testTimestamp + "." + testPayload;
            String testSignature = computeHmacSha256(signedPayload, testSecret);
            String testHeader = "t=" + testTimestamp + ",v1=" + testSignature;

            System.out.println("📋 Test Data:");
            System.out.println("   • Payload: " + testPayload);
            System.out.println("   • Secret: " + testSecret);
            System.out.println("   • Timestamp: " + testTimestamp);
            System.out.println("   • Generated Signature: " + testSignature);
            System.out.println("   • Header: " + testHeader);
            System.out.println();

            System.out.println("🧪 Running Tests...");

            // Test verification
            boolean isValid = verifyWebhookSignature(testPayload, testHeader, testSecret);
            System.out.println("   ✓ Valid signature verification: " + (isValid ? "✅ PASSED" : "❌ FAILED"));

            // Test with invalid signature
            String invalidHeader = "t=" + testTimestamp + ",v1=invalid_signature";
            boolean isInvalid = verifyWebhookSignature(testPayload, invalidHeader, testSecret);
            System.out.println("   ✓ Invalid signature rejection: " + (!isInvalid ? "✅ PASSED" : "❌ FAILED"));

            // Test with missing timestamp
            String noTimestampHeader = "v1=" + testSignature;
            boolean noTimestamp = verifyWebhookSignature(testPayload, noTimestampHeader, testSecret);
            System.out.println("   ✓ Missing timestamp rejection: " + (!noTimestamp ? "✅ PASSED" : "❌ FAILED"));

            // Test with empty header
            boolean emptyHeader = verifyWebhookSignature(testPayload, "", testSecret);
            System.out.println("   ✓ Empty header rejection: " + (!emptyHeader ? "✅ PASSED" : "❌ FAILED"));

            // Test with malformed header
            boolean malformedHeader = verifyWebhookSignature(testPayload, "invalid-header-format", testSecret);
            System.out.println("   ✓ Malformed header rejection: " + (!malformedHeader ? "✅ PASSED" : "❌ FAILED"));

            System.out.println();

            // Example webhook processing
            if (isValid) {
                System.out.println("🎉 === Processing Valid Webhook ===");
                System.out.println("   Processing webhook payload: " + testPayload);
                // Here you would parse the JSON and handle the webhook event
                System.out.println("   Webhook processed successfully!");
                System.out.println();
                System.out.println("🔒 Security verification complete! Your webhook signature verification is working correctly.");
            }

        } catch (Exception e) {
            System.err.println("❌ Test failed with error: " + e.getMessage());
            e.printStackTrace();
        }
    }
}


Security Best Practices

Following these practices will help ensure your webhook implementation is secure and robust:
  • Always Verify Signatures - Never process webhook data without first verifying the signature. This prevents attackers from sending fake webhooks to your endpoint.
  • Use Timing-Safe Comparison - When comparing signatures, use functions like hmac.compare_digest() in Python or crypto.timingSafeEqual() in Node.js to prevent timing attacks.
  • Check Timestamp Freshness - Consider rejecting webhooks with timestamps that are too old (e.g., older than 5 minutes) to prevent replay attacks.
  • Store Secrets Securely - Store your webhook secrets in environment variables or a secure secret management system. Never hardcode them in your application. Important: The webhook secret is only returned when you create a webhook - make sure to save it securely as it cannot be retrieved later.
  • Use HTTPS - Always use HTTPS endpoints for your webhooks to ensure the data is encrypted in transit.
  • Register the Final URL - Webhook deliveries do not follow HTTP redirects (3xx responses). If your endpoint redirects, the delivery will be treated as a failure. Always register the URL that directly handles the payload.


Troubleshooting

Invalid Signature Errors

If you’re getting signature verification failures:
  1. Check the raw payload: Make sure you’re using the raw request body, not a parsed JSON object
  2. Verify the secret: Ensure you’re using the correct webhook secret from when the webhook was created
  3. Check header parsing: Make sure you’re correctly extracting the timestamp and signatures from the header
  4. Encoding issues: Ensure consistent UTF-8 encoding throughout the verification process

Testing Signatures Locally

You can test your signature verification logic using the webhook secret and a sample payload:
Python
# Test with a known payload and signature
test_payload = '{"type":"webset.created","data":{"id":"ws_test"}}'
test_timestamp = "1234567890"
test_secret = "your_webhook_secret"

# Create test signature
import hmac
import hashlib

signed_payload = f"{test_timestamp}.{test_payload}"
test_signature = hmac.new(
    test_secret.encode('utf-8'),
    signed_payload.encode('utf-8'),
    hashlib.sha256
).hexdigest()

test_header = f"t={test_timestamp},v1={test_signature}"

# Verify it works
is_valid = verify_webhook_signature(test_payload, test_header, test_secret)
print(f"Test signature valid: {is_valid}")  # Should print True


What’s Next?