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:
- Extract the timestamp and signatures from the
Exa-Signature
header
- Create the signed payload by concatenating the timestamp, a period, and the raw request body
- Compute the expected signature using HMAC SHA256 with your webhook secret
- 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
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' });
});
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.
-
Use HTTPS - Always use HTTPS endpoints for your webhooks to ensure the data is encrypted in transit.
Troubleshooting
Invalid Signature Errors
If you’re getting signature verification failures:
- Check the raw payload: Make sure you’re using the raw request body, not a parsed JSON object
- Verify the secret: Ensure you’re using the correct webhook secret from when the webhook was created
- Check header parsing: Make sure you’re correctly extracting the timestamp and signatures from the header
- 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:
# 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?