# 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:

```json
{
  "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:

```bash
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.

```javascript
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:

```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](/event-gateway) verifies the `Exa-Signature` header 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 `200` as quickly as possible and process asynchronously.
* After an outage, list `GET /v0/webhooks/{id}/attempts` with `successful=false` to 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 `3xx` status 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.idle` plus 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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Exa webhooks reliably in minutes.