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. |
audit.recorded | Live | The workspace's audit trail gained entries. Aggregated per workspace over a window: a count and the window to pull, never the entries themselves. |
endpoint.disabled | Live | A webhook endpoint was disabled: automatically after sustained delivery failures or a 410 Gone, or by an admin. Delivered to the workspace's other endpoints. |
delivery.failing | Live | Deliveries to a webhook endpoint are failing: raised once per failure window, 30 minutes after its first failed attempt, and delivered to the workspace's other endpoints. |
endpoint.enabled | Live | A disabled webhook endpoint was switched back on. Closes the incident endpoint.disabled opened; delivered to the workspace's other endpoints. |
delivery.recovered | Live | A delivery succeeded on an endpoint that delivery.failing was raised for. Closes that incident; 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 }
// audit.recorded
{
"count": 34,
"windowStart": "2026-07-23T09:05:00.000Z",
"windowEnd": "2026-07-23T09:10:00.000Z"
}
// 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"
}
// endpoint.enabled
{
"endpointId": "0198f2a4-6c1e-7d30-b6a1-2f9d54c08a11",
"enabledAt": "2026-07-24T08:02:51.000Z"
}
// delivery.recovered
{
"endpointId": "0198f2a4-6c1e-7d30-b6a1-2f9d54c08a11",
"failingSince": "2026-07-23T09:14:07.000Z",
"recoveredAt": "2026-07-23T11:40:12.000Z"
}
// webhook.ping
{}The operational events (delivery.failing, delivery.recovered,
endpoint.disabled, endpoint.enabled) 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.
Every live event is also described in the OpenAPI document at
/api/openapi.json, under webhooks: the full JSON Schema of the body and the
three signature headers, so you can generate receiver types instead of copying
the examples above. The schema is enforced on our side too: a payload that does
not match it is never sent.
Feeding an audit trail to a SIEM
audit.recorded is the push half of a SIEM feed, and it is deliberately not
the entries: an audit entry names a person, their address and what they
touched, and a webhook payload is stored in the delivery log and sent to a URL
you control, which may resolve anywhere. The event tells you a window closed
with entries in it; you pull the entries over the authenticated API with a key
carrying the audit:read scope.
On each event, search the trail with windowStart as startDate and
windowEnd as endDate, paging on the cursor until it comes back null. The
search's start bound is inclusive and the window's is not, so a page may
repeat the entry at exactly that instant; deduplicate on entry id, which you
need in any case because delivery is at-least-once.
The catalog endpoint lists every action the trail can record, with the category each belongs to and a version that changes when the vocabulary does. Read it once, compare the version on later reads, and treat an action missing from it as unknown rather than invalid: the trail is append-only and keeps the spelling each entry was written with.
Windows are contiguous for as long as you stay subscribed: each one starts where the last event you were sent ended, so a pass that is delayed makes the next window wider instead of losing what happened in between. A window with no entries sends nothing.
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 | Message id. Identical on every retry, resend and replay of the same event; deduplicate on it. |
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, connection setup included. 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. The one exception is 410 Gone: it tells us the route was
removed on purpose, so that delivery is not retried and the endpoint is
disabled at once, with an endpoint.disabled event (reason gone) to the
workspace's other endpoints and an email to every owner and admin. A 410 in
answer to Send test or a manual resend fails only that delivery.
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 have been failing for 30 minutes, a
delivery.failingevent fires once (delivered to the workspace's other endpoints) and every workspace owner and admin gets an in-app notification. This is the early warning; a blip that a success closes within those 30 minutes raises nothing. - As the failure window runs on, the warning escalates: at 3 days and again at 4.5 days every owner and admin gets an in-app notification and an email naming the time after which the endpoint is disabled if deliveries are still failing. Each warning reaches each person once per failure window.
- A successfully delivered event resets the failure window. A test event
does not: it proves the endpoint answers, not that it processes events. If
a warning had already gone out, a
delivery.recoveredevent fires and owners and admins get an in-app notification that the endpoint recovered. - After 5 days of consecutive failures, the endpoint is disabled: an
endpoint.disabledevent fires (again, to other endpoints), and every workspace owner and admin is emailed.
The disable needs a failed delivery after the final warning. If nothing was sent to the endpoint by the time the final warning named, the next failure raises a fresh final warning instead, and the endpoint is disabled only if deliveries are still failing 12 hours later.
Switching an endpoint off yourself fires endpoint.disabled with reason
manual to the workspace's other endpoints. 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, and an
endpoint.enabled event fires to the workspace's other endpoints. 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 the endpoint is reachable, 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 event of a past delivery (same
webhook-id, same JSON content) 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 carries the originalwebhook-id, so a receiver that already processed the event before answering with an error treats it as the same message. - 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. An event that a resend or an earlier replay already delivered is skipped, and so is one that is still being delivered. Choose how far back to go (the last 24 hours, the last 7 days, or everything retained), and the dialog previews how many events that range will send before you start; large ranges are paged through automatically, with progress shown as they go. 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, and shows the outcome (the status code, or why it failed) as soon as the attempt finishes. Use it to confirm reachability and to exercise your signature verification end to end. A test event never opens, advances or resets the failure window.
Best practices
- Deduplicate on
webhook-id. Automatic retries, resends and replays 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. An endpoint holds one previous secret, so a second rotation with a grace window is refused until the first window has ended; a rotation with no grace window is always accepted and revokes every old secret at once, which is the path for a leaked secret.