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_ | A source connection record is created (OAuth may be unfinished, see is_authenticated) | Track onboarding |
source_ | The OAuth flow completed and the connection is ready | Start the first sync, tell the user |
source_ | 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:
{
"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_ can fire before the user finishes OAuth. Check is_authenticated, or wait for source_ 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:
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:
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:
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:
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:
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 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=trueand use thewhsec_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
disabledon the subscription when deliveries stop. - Call
POST /webhooks/subscriptions/{id}/recover, or passrecover_sincewhen 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 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_idand only move forward:pending,running, then a final state. - Compare
timestampbefore 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
200for anyevent_typeyou don't handle. Airweave retries4xxresponses too. - Read IDs with fallbacks rather than assuming one name.
How Hookdeck Can Help: A transformation 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 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 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 can accept Airweave's events immediately and deliver them to your service when it is ready. Sign up for Hookdeck and point a subscription at a new source.