Guide to Exa Webhooks: Features and Best Practices
Exa webhooks let your systems follow a Webset as it runs. Exa, the AI search API, uses Websets to find and enrich lists of entities such as companies or people, and that work can take minutes. Instead of polling, you register a webhook and Exa tells you when a search finishes, when each item is found or enriched, and when the Webset goes idle.
This guide covers how Exa webhooks are created and signed, the events you can subscribe to, the gaps in what Exa documents, and the best practices for running an Exa receiver in production.
What are Exa webhooks?
An Exa webhook is a subscription you create through the Websets API. It has a URL, a list of event types and optional metadata. When one of those events occurs, Exa sends a JSON event to the URL with an Exa-Signature header.
The header uses a familiar format: t=<unix seconds>,v1=<hex signature>. The signature is a hex HMAC-SHA256 of the timestamp, a period and the raw request body, keyed with the webhook's secret. The header can carry more than one v1= value, and a request is valid if any one of them matches.
The secret is returned once, in the response to the call that creates the webhook. Exa says it cannot be retrieved later, so save it before you do anything else.
Exa webhook features
| Feature | Details |
|---|---|
| Configuration | API only: POST https://api.exa.ai/websets/v0/webhooks |
| Subscription model | 1 to 19 event types per webhook, plus optional string metadata |
| Signature header | Exa-Signature: t=<unix seconds>,v1=<hex> |
| Scheme | HMAC-SHA256, lowercase hex digest |
| Signed content | <t>.<raw body> |
| Secret | One per webhook, returned only in the create response, used as a UTF-8 string |
| Replay window | Exa suggests rejecting timestamps older than about 5 minutes |
| Redirects | Not followed. A 3xx response counts as a failed delivery |
| Delivery history | GET /v0/webhooks/{id}/attempts lists attempts, filterable by success |
| Retries and timeout | Not documented |
| Source IPs | Not published |
Common events
The Websets API lists 19 event types. A webhook subscribes to between 1 and 19 of them.
| Area | Events | Fires when |
|---|---|---|
| Websets | webset.created, webset.deleted, webset.paused, webset.idle | A Webset is created, deleted or paused, or has no running operations left |
| Searches | webset.search.created, webset.search.updated, webset.search.completed, webset.search.canceled | A search on a Webset starts, progresses, finishes or is cancelled |
| Items | webset.item.created, webset.item.enriched | An item is added to a Webset, or its enrichments finish |
| Imports | import.created, import.completed | An import is created or completes |
| Exports | webset.export.created, webset.export.completed | An export is created or completes |
| Monitors | monitor.created, monitor.updated, monitor.deleted, monitor.run.created, monitor.run.completed | A monitor is created, updated or deleted, or one of its runs starts or completes |
Every delivery is an event object. Exa's OpenAPI spec defines it as:
{
"id": "<event id>",
"object": "event",
"type": "webset.item.created",
"data": { "...": "the full resource named by type" },
"createdAt": "<ISO 8601 date-time>"
}
object is always event, id identifies the event, and createdAt is an ISO 8601 date-time. data is the full resource that changed: a Webset, a search, an item, an import or a monitor. For webset.item.created and webset.item.enriched, that is a Webset item with fields such as id, websetId, source, properties, evaluations and enrichments.
The spec has an event schema for 17 of the 19 types. The two export events can be subscribed to, but the spec doesn't describe their data, so log what arrives before you rely on it. Exa doesn't publish example deliveries either, which is why there are no sample payloads in Hookdeck's Console for Exa yet.
Setting up Exa webhooks
Exa has no dashboard step for webhooks. Create one with your API key:
curl -X POST "https://api.exa.ai/websets/v0/webhooks" \
-H "x-api-key: $EXA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/exa",
"events": ["webset.search.completed", "webset.item.created", "webset.item.enriched", "webset.idle"]
}'
The response is the webhook object, with id, status (active or inactive), events, url, metadata and secret. Store secret right away, for example as EXA_WEBHOOK_SECRET. Later reads of the webhook don't include it. If you lose it, create a new webhook and delete the old one.
Register the final URL. Exa sends deliveries directly to it and treats a redirect as a failure, so an http:// URL that redirects to https://, or a path that adds a trailing slash, never receives an event.
If you use Hookdeck, create a source with the Exa source type, register the Hookdeck source URL as the webhook url, and paste the returned secret into the source.
Securing Exa webhooks
Split Exa-Signature on commas and read t and every v1. Compute an HMAC-SHA256 of t, a period and the raw request body, with the secret as a UTF-8 string, and encode the result as lowercase hex. Compare it in constant time with each v1 value, and accept the request if any one matches.
Exa suggests rejecting timestamps that are too old, for example more than 5 minutes. Its own sample only logs a warning. The check is optional in the code below and off by default. If you turn it on, remember that a delivery replayed later from Hookdeck keeps its original timestamp.
const crypto = require('crypto');
const express = require('express');
function verifyExaSignature(rawBody, signatureHeader, secret, toleranceSeconds = 0) {
if (!signatureHeader || !secret) return false;
let timestamp;
const signatures = [];
for (const part of signatureHeader.split(',')) {
const index = part.indexOf('=');
if (index === -1) continue;
const key = part.slice(0, index).trim();
const value = part.slice(index + 1).trim();
if (key === 't') timestamp = value;
else if (key === 'v1') signatures.push(value);
}
if (!timestamp || !/^\d+$/.test(timestamp) || signatures.length === 0) return false;
// Optional replay check, off by default.
if (toleranceSeconds > 0 && Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
return false;
}
const expected = Buffer.from(
crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex'),
'utf8'
);
return signatures.some((signature) => {
const received = Buffer.from(signature.toLowerCase(), 'utf8');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
const app = express();
app.post('/webhooks/exa', express.raw({ type: '*/*' }), (req, res) => {
if (!verifyExaSignature(req.body, req.get('exa-signature'), process.env.EXA_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// Deduplicate on event.id, route on event.type, then process async.
res.sendStatus(200);
});
The same check in Python:
import hashlib
import hmac
import os
import time
def verify_exa_signature(raw_body: bytes, signature_header, secret: str, tolerance_seconds: int = 0) -> bool:
if not signature_header or not secret:
return False
timestamp, signatures = None, []
for part in signature_header.split(","):
key, sep, value = part.partition("=")
if not sep:
continue
key, value = key.strip(), value.strip()
if key == "t":
timestamp = value
elif key == "v1":
signatures.append(value)
if not timestamp or not timestamp.isdigit() or not signatures:
return False
# Optional replay check, off by default.
if tolerance_seconds > 0 and abs(time.time() - int(timestamp)) > tolerance_seconds:
return False
signed = timestamp.encode("utf-8") + b"." + raw_body
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest().encode("utf-8")
return any(hmac.compare_digest(sig.lower().encode("utf-8"), expected) for sig in signatures)
# In FastAPI: raw_body = await request.body()
# verify_exa_signature(raw_body, request.headers.get("exa-signature"), os.environ["EXA_WEBHOOK_SECRET"])
To check your verifier, use the secret test_webhook_secret, the timestamp 1234567890 and this exact body, which is the test payload from Exa's docs:
{"type":"webset.created","data":{"id":"ws_test"}}
The Exa-Signature header should be:
t=1234567890,v1=9fdca5e9117e0866580d1ea9b0cd4464e71f59df5fb14a0b5a1544006e14bfad
Make Exa webhooks production-ready. Hookdeck Event Gateway verifies the
Exa-Signatureheader on every delivery, and durably queues Webset, search and item events so a long-running Webset never outpaces your service.
Exa webhook limitations and pain points
The secret is shown once
The Problem: You can't verify deliveries because nobody saved the secret.
Why It Happens: Exa returns secret only in the response to POST /v0/webhooks. Reading the webhook later doesn't include it, and there is no endpoint to reveal it again.
Workarounds:
- Write the secret to your secret store in the same script that creates the webhook.
- If it is lost, create a new webhook, switch your verifier to the new secret, then delete the old webhook.
How Hookdeck Can Help: Create the webhook with the Hookdeck source URL and paste the secret into the Exa source once. Every service behind Hookdeck then receives verified events without needing the secret itself.
Retries and timeouts are not documented
The Problem: You can't tell how long your endpoint has to respond, how often Exa retries, or when it gives up.
Why It Happens: Exa records delivery attempts and lets you list them, which implies retries, but it doesn't publish a response timeout or a retry schedule.
Workarounds:
- Respond with
200as quickly as possible and process asynchronously. - After an outage, list
GET /v0/webhooks/{id}/attemptswithsuccessful=falseto see what failed. - Treat webhooks as notifications and reconcile from the API, for example with
GET /v0/events, when you suspect a gap.
How Hookdeck Can Help: Hookdeck accepts each delivery immediately and stores it, then retries delivery to your service on a schedule you set, so you aren't depending on a retry policy you can't see.
Redirects silently break delivery
The Problem: No events arrive, although the URL works in a browser.
Why It Happens: Exa doesn't follow 3xx responses and counts them as failed deliveries. Common causes are an http:// URL, a missing or extra trailing slash, and a load balancer or framework that redirects.
Workarounds:
- Register the exact final HTTPS URL your handler serves.
- Check the attempts list for
3xxstatus codes.
How Hookdeck Can Help: A Hookdeck source URL answers directly, without redirects, and Hookdeck forwards to your destination wherever it lives.
High volume from item events
The Problem: A large Webset floods your endpoint with webset.item.created and webset.item.enriched deliveries.
Why It Happens: Item events fire once per item, and enrichment events once per enriched item, so a Webset with thousands of items produces thousands of requests in a short time.
Workarounds:
- Subscribe only to the events you act on. If you only need the final list,
webset.idleplus a fetch of the items may be enough. - Queue item events and write them to your database in batches.
How Hookdeck Can Help: Hookdeck can rate-limit delivery to your service and absorb bursts in its queue, so a large Webset doesn't overload your database.
Best practices
Verify against the raw body
The signature covers the exact bytes Exa sent, prefixed with the timestamp. Read the raw body (express.raw(), await request.body()) and parse only after verification. Re-serialised JSON won't match.
Accept any matching v1 value
The header can carry several v1= signatures. Check each one and accept the request if any matches. Code that takes only the first value can reject valid deliveries.
Route on type and handle unknown data defensively
Switch on type, and log and acknowledge types you don't handle. For the two export events, whose shape the spec doesn't define, store the raw payload until you have seen real deliveries.
Deduplicate on the event id
Store each event's id before processing, so a retried delivery doesn't create a duplicate record. See our guide to webhook idempotency.
Use metadata to route between environments
metadata is a set of string key-value pairs you attach when you create the webhook. Use it to label the environment or tenant a webhook belongs to, so you can tell webhooks apart when you list or update them.
Acknowledge fast and process asynchronously
Exa doesn't publish a timeout, so don't let your handler run long. Verify, persist the event, return 200, and do the real work afterwards. See why to process webhooks asynchronously.
Conclusion
Exa signs every Websets webhook with Exa-Signature: t=...,v1=..., a hex HMAC-SHA256 of the timestamp and raw body keyed with a secret you only see once. Save the secret when you create the webhook, register a URL that never redirects, accept any matching v1 value, and acknowledge quickly, because Exa doesn't document how long it waits or how often it retries.
Hookdeck Event Gateway sits in front of your receiver, verifies every Exa signature, and queues each event until your service has processed it, so a busy Webset or an outage on your side doesn't lose events.
Get started with Hookdeck for free and handle Exa webhooks reliably in minutes.