Documentation Index
Fetch the complete documentation index at: https://exa.ai/docs/llms.txt
Use this file to discover all available pages before exploring further.
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
Python
JavaScript/Node.js
Java
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.
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?