Webhooks
Receive signed event notifications when documents are indexed, deliveries fail, or endpoints change state. Verification, retries, and testing.
Webhooks push event notifications to your systems as HTTPS POST requests, so
you can react to changes without polling. Register an endpoint from your
workspace settings: the URL must use HTTPS, and each workspace can register
up to 10 endpoints. At creation you receive a signing secret (prefixed
whsec_, the Standard Webhooks format) exactly once; it is never retrievable afterwards, so copy it into your
secret store straight away.
Every delivery is a JSON body with the same envelope:
{
"event": "documents.indexed",
"timestamp": "2026-07-23T09:14:07.000Z",
"tenantId": "0198f2a4-6c1e-7d30-b6a1-2f9d54c08a11",
"sequence": 42,
"data": { "count": 12 }
}Payloads are deliberately thin: ids, counts, and timestamps only. Treat a webhook as a signal that something changed, then fetch the current state through the authenticated API. Document names and content never appear in a webhook payload.
sequence is a per-endpoint counter, dense for this endpoint's own
stream: consecutive events delivered to this endpoint carry consecutive
numbers, so a missing number means a delivery your service never received.
Delivery ORDER is not guaranteed, so use the sequence (not arrival order) to
order events. Two caveats: replays arrive out of sequence order by design
(a caught-up event keeps its original, older sequence), so dedupe on
webhook-id and never discard a delivery just because its sequence is below
your high-water mark; and a gap can also mean the missing event is still
retrying or waiting in a replayable state, so treat gaps as "check the
delivery log", not proof of loss.
Event catalog
Subscribe an endpoint to any combination of the event types below. Types marked planned can already be selected, but nothing fires for them yet and their payload shape is not final; it is published here when the event goes live.
| Event | Status | Payload (data) |
|---|---|---|
documents.indexed | Live | Documents became searchable. Aggregated per workspace: at most one event per aggregation window, carrying a count. |
documents.failed | Live | Documents terminally failed processing. Aggregated per workspace: at most one event per aggregation window, carrying a count. |
sync.completed | Planned | A connector sync run completed. Payload shape is not final. |
sync.failed | Planned | A connector sync run failed. Payload shape is not final. |
ingestion.completed | Planned | An ingestion run completed. Payload shape is not final. |
ingestion.failed | Planned | An ingestion run failed. Payload shape is not final. |
endpoint.disabled | Live | A webhook endpoint was automatically disabled after sustained delivery failures. Delivered to the workspace's other endpoints. |
delivery.failing | Live | Deliveries to a webhook endpoint started failing: an early warning at the start of the auto-disable grace window, delivered to the workspace's other endpoints. |
webhook.ping | Live | Directed test event sent by the dashboard's send-test action to exactly one endpoint. Never subscribable; data is always empty. |
Example data objects for the live events:
// documents.indexed
{ "count": 12 }
// documents.failed
{ "count": 2 }
// endpoint.disabled
{
"endpointId": "0198f2a4-6c1e-7d30-b6a1-2f9d54c08a11",
"reason": "sustained_failure",
"disabledAt": "2026-07-23T09:14:07.000Z"
}
// delivery.failing
{
"endpointId": "0198f2a4-6c1e-7d30-b6a1-2f9d54c08a11",
"failingSince": "2026-07-23T09:14:07.000Z"
}
// webhook.ping
{}The operational events (endpoint.disabled, delivery.failing) report on the
health of other endpoints: the endpoint an event is about is always
excluded from that event's delivery, since delivering there would be
guaranteed noise.
Verifying deliveries
Every delivery is signed following the Standard Webhooks specification. Always verify the signature before trusting a request: your endpoint URL is reachable by anyone on the internet.
Each request carries three headers:
| Header | Value |
|---|---|
webhook-id | Unique id of this delivery. Stays the same across automatic retries of the same delivery, so it doubles as an idempotency key. |
webhook-timestamp | Unix timestamp in seconds at send time. |
webhook-signature | One or more signatures, space separated, each in the form v1,{base64}. |
To verify:
- Reconstruct the signed content as
{webhook-id}.{webhook-timestamp}.{raw body}. Use the raw request body bytes exactly as received, before any JSON parsing. - Compute HMAC-SHA256 over that string. The key is the base64-decoded
portion of the secret after the
whsec_prefix (the Standard Webhooks key derivation, which is what the reference libraries implement). - Base64-encode the HMAC and compare it against each
v1,signature in the header using a constant-time comparison. The delivery is authentic if any one matches. - Reject deliveries whose
webhook-timestampis more than 5 minutes from your current time, in either direction. This bounds replay of captured requests.
The header can carry more than one signature: while a secret rotation grace
window is open, deliveries are signed with both the new and the previous
secret (v1,NEW_SIG v1,OLD_SIG), so verification keeps succeeding no matter
which secret your service has rolled to.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
/**
* @param {string} secret - The whsec_ signing secret from endpoint creation.
* @param {Record<string, string>} headers - Lowercased request headers.
* @param {string} rawBody - The raw request body, unparsed.
*/
export function verifyWebhook(secret, headers, rawBody) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatureHeader = headers["webhook-signature"];
if (!id || !timestamp || !signatureHeader) return false;
const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(skew) || skew > TOLERANCE_SECONDS) return false;
const key = Buffer.from(secret.slice("whsec_".length), "base64");
const expected = createHmac("sha256", key)
.update(`${id}.${timestamp}.${rawBody}`)
.digest();
return signatureHeader.split(" ").some((entry) => {
const comma = entry.indexOf(",");
if (comma === -1 || entry.slice(0, comma) !== "v1") return false;
const received = Buffer.from(entry.slice(comma + 1), "base64");
return received.length === expected.length && timingSafeEqual(received, expected);
});
}Because the format follows the Standard Webhooks specification, open-source verification libraries for other languages work as-is; pass them the secret and the three headers.
Retries and failure
Your endpoint has 10 seconds to respond. A response with a 2xx status counts as delivered; anything else, including a timeout or a redirect (deliveries never follow redirects), counts as a failure and is retried. Respond 2xx as soon as you have durably accepted the event, and do the heavy processing asynchronously.
A failed delivery is retried on this schedule:
| Attempt | Delay after the previous failure |
|---|---|
| 1 | immediate |
| 2 | 5 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 5 hours |
| 7 | 10 hours |
| 8 | 10 hours |
That is 8 attempts over roughly 27.5 hours, so a full-day outage on your side
does not lose events. Each delay carries up to 20% random jitter in either
direction, which prevents synchronized retry bursts against a recovering
endpoint. If your endpoint returns a Retry-After header, the requested delay
is honored within limits: it is clamped between the scheduled delay and twice
the scheduled delay. Responses with status 429 and timed-out attempts are
retried no sooner than 60 seconds, regardless of how early the schedule slot
falls.
Auto-disable
An endpoint that keeps failing is eventually disabled rather than hammered forever:
- When deliveries start failing, a
delivery.failingevent fires once (delivered to the workspace's other endpoints) and an in-app notification is created. This is the early warning. - Any successful delivery resets the failure window.
- After 5 days of consecutive failures, the endpoint is disabled: an
endpoint.disabledevent fires (again, to other endpoints), and the endpoint's creator is emailed.
A disabled endpoint receives no further deliveries, and its send-test and resend actions are blocked. Once your receiver is healthy again, re-enable the endpoint from workspace settings (click its status badge): re-enabling clears the failure history, so the endpoint starts a fresh 5-day window. Events that occur while an endpoint is disabled are recorded in its delivery log with the dropped status instead of being sent. After re-enabling, use Send test to confirm deliveries succeed, then Replay failed + dropped to catch up on everything the outage cost in one action.
Manual redelivery and testing
The delivery log in workspace settings shows every delivery attempt with its status code and response. From there:
- Resend re-delivers the original payload of a past delivery, byte for
byte, as a single attempt. It does not enter the retry schedule and never
counts toward auto-disable, so it is safe to use while debugging a flaky
receiver. The resend is a new delivery with a new
webhook-id. - Replay failed + dropped bulk-replays every terminally-failed and
dropped delivery of the endpoint, oldest first (deliveries still on their
automatic retry schedule are excluded, so a replay can never duplicate a
retry that was going to succeed anyway). Replayed deliveries use the full
retry schedule and carry the original
webhook-id: to your service a replay is the same message again, so idempotency handling onwebhook-idmakes catch-up safe whether or not the original ever arrived. Large ranges are processed in capped batches: if more remain, run it again and it resumes exactly where it stopped. Replays arrive out of sequence order; order the caught-up events by the payload'ssequence. - Send test delivers a signed
webhook.pingevent with an emptydataobject to exactly that endpoint, regardless of its event subscriptions. Use it to confirm reachability and to exercise your signature verification end to end.
Best practices
- Deduplicate on
webhook-id. Automatic retries reuse the same id, so storing processed ids gives you exactly-once processing on top of at-least-once delivery. - Never treat a payload as current state. Payloads are thin and can arrive late; use the event as a trigger and read the current state from the API.
- Do not rely on ordering. Retries and parallel delivery mean events can arrive out of order.
- Return 2xx before heavy work. Queue the event and process it asynchronously; a handler that does its work inline risks hitting the 10-second timeout and being retried, which turns one event into several duplicate attempts.
- Rotate secrets with a grace window. Rotation from workspace settings keeps the previous secret valid for a window you choose, and deliveries are signed with both secrets during it, so a rolling deploy of your services never drops a delivery.