# Guide to Airweave Webhooks: Features and Best Practices

Airweave connects SaaS apps, databases and file stores to AI agents. It syncs each source into a searchable collection, and its webhooks tell you how those syncs are going: when a job is queued, starts, finishes with entity counts, fails or is cancelled, and when source connections and collections are created, authenticated or removed. Teams use them to refresh search indexes after a sync, alert on failures, and show sync status in their own product without polling the API. The webhooks are in beta.

## What are Airweave webhooks?

An Airweave webhook is a subscription created through the API: a URL plus a list of event types. Delivery runs on Svix, which signs each request with a per-subscription secret and adds `svix-id`, `svix-timestamp` and `svix-signature` headers. The signature is Standard Webhooks HMAC-SHA256.

Because Svix handles delivery, Airweave inherits its retry and recovery tools. A failed message is retried five times over about two and a half hours, and anything still undelivered can be resent through Airweave's recover endpoint once your receiver is healthy again.

## Airweave webhook features

| Feature | Details |
| --- | --- |
| Status | Beta |
| Configuration | Subscriptions API (`/webhooks/subscriptions`), admin or owner role with Bearer auth |
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | `svix-signature`, with `svix-id` and `svix-timestamp` |
| Secret | Per subscription, `whsec_...`, read with `include_secret=true` |
| Body | JSON, `Content-Type: application/json` |
| Event routing | Body field `event_type` |
| Success | Any `2xx`. `4xx`, `5xx` and a 30-second timeout are retried |
| Retries | 5 attempts: immediate, 5 seconds, 5 minutes, 30 minutes, 2 hours. Then the message is marked failed |
| Recovery | `POST /webhooks/subscriptions/{id}/recover`, or `recover_since` when re-enabling a subscription |
| Delivery | At least once, not ordered. Deduplicate on the `svix-id` header |
| Source IPs | Not published |

## Events and payloads

| Event | Triggered When | Common Use Cases |
| --- | --- | --- |
| `sync.pending` | A sync job is created and queued | Show "queued" state |
| `sync.running` | The sync job starts processing | Progress display |
| `sync.completed` | The sync job finishes successfully; carries entity and chunk counts | Trigger downstream workflows, refresh search |
| `sync.failed` | The sync job hit an error; `error` has the details | Alert, re-authenticate the source |
| `sync.cancelled` | The sync job was cancelled manually | Clear "running" state |
| `source_connection.created` | A source connection record is created (OAuth may be unfinished, see `is_authenticated`) | Track onboarding |
| `source_connection.auth_completed` | The OAuth flow completed and the connection is ready | Start the first sync, tell the user |
| `source_connection.deleted` | A source connection and its data were removed | Clean up references |
| `collection.created` | A collection was created | Register it in your app |
| `collection.updated` | Collection properties changed | Refresh cached names |
| `collection.deleted` | A collection and its data were removed | Remove references |

Each domain has its own flat body. They share `event_type` and `timestamp` and add the IDs for that domain:

| Domain | Fields besides `event_type` and `timestamp` |
| --- | --- |
| Sync | `sync_id`, `sync_job_id`, `collection_id`, `collection_readable_id`, `collection_name`, `source_connection_id`, `source_type`. `sync.completed` adds `entities_inserted`, `entities_updated`, `entities_deleted`, `entities_skipped`, `chunks_written`; `sync.failed` adds `error` |
| Source connection | `source_connection_id`, `collection_readable_id`, `source_type`, `is_authenticated` |
| Collection | `collection_id`, `collection_name`, `collection_readable_id` |

This is the `collection.created` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/airweave/latest/collection.created.json):

```json
{
  "event_type": "collection.created",
  "collection_id": "770e8400-e29b-41d4-a716-446655440002",
  "collection_name": "Finance Data",
  "collection_readable_id": "finance-data-ab123",
  "timestamp": "2025-01-15T13:50:00Z"
}

```

The delivered body has no event ID. Airweave's message-history API shows an `event_id`, but it isn't in the body, so deduplicate on `svix-id`, which stays the same across retries. The same history example shows `job_id` and `status` in a sync payload where the event pages use `sync_job_id`, so read IDs defensively.

`source_connection.created` can fire before the user finishes OAuth. Check `is_authenticated`, or wait for `source_connection.auth_completed` before you start anything that needs the connection.

## Setting up Airweave webhooks

Creating, updating, deleting and recovering subscriptions needs the admin or owner role and Bearer token authentication. An API key is enough for read-only calls such as listing subscriptions, messages and delivery attempts. The URL must be HTTPS, except for local development.

Create the subscription with the events you want:

```bash
curl -X POST 'https://api.airweave.ai/webhooks/subscriptions' \
  -H 'Authorization: Bearer <token>' \
  -H 'Content-Type: application/json' \
  -d '{
    "url": "https://your-server.com/webhooks/airweave",
    "event_types": ["sync.completed", "sync.failed", "source_connection.created", "collection.deleted"]
  }'

```

The response holds the subscription `id` (`ep_...`), `url`, `filter_types` and `disabled: false`. Then read its signing secret:

```bash
curl -X GET 'https://api.airweave.ai/webhooks/subscriptions/{subscription_id}?include_secret=true' \
  -H 'x-api-key: YOUR_API_KEY'

```

The `secret` field holds a `whsec_...` value. Store all of it, prefix included, as `AIRWEAVE_WEBHOOK_SECRET`.

You can pass your own `secret` (minimum 24 characters) when you create the subscription. Even then, use the `whsec_` secret Airweave returns for the subscription, not the value you sent, both in your code and in a Hookdeck Airweave source.

To put Hookdeck in front, create a source with the **Airweave** type, paste that `whsec_` value as its verification secret, and use the source URL as the subscription `url`. For local testing, forward the source to your machine with the [Hookdeck CLI](/docs/cli):

```bash
npx hookdeck-cli listen 3000 airweave --path /webhooks/airweave

```

## Securing Airweave webhooks

The signed content is `<svix-id>.<svix-timestamp>.<raw body>`. The HMAC key is the base64-decoded part of the secret after `whsec_`, and `svix-signature` can hold several space-separated `v1,<base64>` entries, any one of which may match.

Airweave's manual-verification snippets split the header on `,` and take the last space-separated token. That works while there is one signature, but the header format allows several entries, and the snippet would then check only the last. The verifier below checks every `v1` entry and falls back to `webhook-*` headers if `svix-id` is missing, as the Hookdeck source does:

```javascript
const crypto = require('node:crypto');

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
    ? bytes : null;
}

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const prefix = headers['svix-id'] ? 'svix' : 'webhook';
  const id = headers[`${prefix}-id`];
  const timestamp = headers[`${prefix}-timestamp`];
  const signature = headers[`${prefix}-signature`];
  if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
      !/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
  const key = decodeBase64(secret.replace(/^whsec_/, ''));
  if (!key) return false;
  const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    const received = decodeBase64(candidate);
    if (!received) return false;
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

Airweave recommends the `svix` library for production. It parses the header correctly but rejects timestamps more than five minutes old, and a request that Hookdeck retries or replays still carries its original `svix-timestamp`. Hookdeck itself applies no timestamp tolerance, so behind Hookdeck use a verifier like this one, or widen the library's window.

The receiver below refuses a secret without the `whsec_` prefix, since a custom value would verify against the wrong key, and treats sync outcomes as the events worth storing:

```javascript
const express = require('express');

// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
  try {
    const value = JSON.parse(rawBody.toString('utf8'));
    return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
  } catch {
    return null;
  }
}

// storeEvent(svixId, event) persists the event and resolves once it is durable.
function createAirweaveReceiver(storeEvent) {
  const app = express();
  app.post('/webhooks/airweave', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.AIRWEAVE_WEBHOOK_SECRET;
    if (!secret || !secret.startsWith('whsec_')) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

    const event = parseJsonObject(req.body);
    if (!event) return res.sendStatus(400); // signed, but not a JSON object
    const deliveryId = req.headers['svix-id'] || req.headers['webhook-id'];
    try {
      await storeEvent(deliveryId, event);
    } catch {
      return res.sendStatus(503); // retried on Airweave's 5-attempt schedule
    }
    // Handle sync.completed and sync.failed in a worker; acknowledge everything else too.
    return res.sendStatus(200);
  });
  return app;
}

```

A Hookdeck Airweave source answers every request with `200`, including ones whose signature fails, and marks the failures in its request log instead of forwarding them.

> **Make Airweave webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies each Svix-signed sync and collection event, keeps the full delivery history, and lets you retry or replay events to your indexer after an outage.

## Airweave webhook limitations and pain points

### A custom secret isn't the key

**The Problem:** You set your own `secret` when you created the subscription, used the same value in your verifier, and every delivery fails.

**Why It Happens:** Svix stores the custom value and reports the subscription's secret back in `whsec_` form. That returned value is what signs requests.

**Workarounds:**

* Always fetch the subscription with `include_secret=true` and use the `whsec_` value it returns.

**How Hookdeck Can Help:** Hookdeck's configuration alert for the Airweave source gives the same instruction, and its request log shows whether verification passed for each delivery as soon as you send a test sync.

### Five attempts, then nothing until you recover

**The Problem:** After a few hours of downtime, sync events stop arriving and don't come back on their own.

**Why It Happens:** The fifth attempt comes two hours after the fourth. After that the message is marked failed and Airweave stops trying it. A subscription that has been disabled, by you or through the API, sends nothing at all.

**Workarounds:**

* Check `disabled` on the subscription when deliveries stop.
* Call `POST /webhooks/subscriptions/{id}/recover`, or pass `recover_since` when you re-enable it, to resend failed messages.

**How Hookdeck Can Help:** Hookdeck accepts each event as it arrives, so your outage doesn't use up Airweave's five attempts. It then [retries](/docs/retries) delivery to your service on a schedule you set.

### Events are unordered

**The Problem:** A late `sync.running` overwrites the `sync.completed` you already stored, and the sync looks stuck.

**Why It Happens:** Delivery is at least once and Airweave doesn't guarantee order, so a retried earlier event can arrive after a later one.

**Workarounds:**

* Track state per `sync_job_id` and only move forward: `pending`, `running`, then a final state.
* Compare `timestamp` before overwriting.

**How Hookdeck Can Help:** Every delivery is logged with its arrival time and headers, which makes out-of-order cases easy to find when a job's status looks wrong.

### Beta event surface

**The Problem:** A new event type or a renamed field breaks a strict handler.

**Why It Happens:** Airweave labels webhooks as beta, and its docs already disagree on some field names (`job_id` against `sync_job_id`).

**Workarounds:**

* Return `200` for any `event_type` you don't handle. Airweave retries `4xx` responses too.
* Read IDs with fallbacks rather than assuming one name.

**How Hookdeck Can Help:** A [transformation](/docs/transformations) can normalise field names in one place before events reach your handlers.

## Best practices

### Act on `sync.completed`, not `sync.running`

`sync.completed` carries `entities_inserted`, `entities_updated`, `entities_deleted`, `entities_skipped` and `chunks_written`. Use those counts to decide whether a search cache needs refreshing, and skip the work when everything was skipped.

### Treat `sync.failed` as an action item

`error` explains what went wrong. If the error comes from the source's credentials, the user has to re-authenticate the connection, so route those failures to someone who can.

### Deduplicate on `svix-id`

With no event ID in the body, the `svix-id` header is the stable key across retries. Store it before processing. [Implementing webhook idempotency](/webhooks/guides/implement-webhook-idempotency) walks through the storage side.

### Answer inside 30 seconds

A slow handler times out and is retried. Store the event, return `200`, and run re-indexing or notifications afterwards. [Processing webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks) explains why.

## Conclusion

Airweave reports the lifecycle of syncs, source connections and collections through Svix, with a recover endpoint behind it. The events are still in beta and arrive unordered, so write handlers that tolerate both.

If you would rather not depend on five retries over two and a half hours, [Hookdeck Event Gateway](https://hookdeck.com) can accept Airweave's events immediately and deliver them to your service when it is ready. [Sign up for Hookdeck](https://dashboard.hookdeck.com/signup) and point a subscription at a new source.