# Webhooks
Source: https://nordvec.com/docs/guides/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:

```json
{
  "event": "documents.indexed",
  "timestamp": "2026-07-23T09:14:07.000Z",
  "tenantId": "0198f2a4-6c1e-7d30-b6a1-2f9d54c08a11",
  "sequence": 42,
  "data": { "count": 12 }
}
```

A test event you send from the dashboard or the API also carries
`"test": true`; a real event never has the field.

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](/docs/guides/authentication). 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. A test event carries no `sequence`.

## Event catalog [#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:

```json
// 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 [#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 previous one 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.

Reading the trail does not trigger an event. Every search and export is
recorded in the trail as `audit_log_viewed` or `audit_log_exported`, and those
entries are not counted toward `audit.recorded`, so the pull you make in
answer to an event never causes the next one. They stay in the trail: a
search returns them with everything else once some other entry opens a
window.

## Verifying deliveries [#verifying-deliveries]

Every delivery is signed following the
[Standard Webhooks specification](https://www.standardwebhooks.com/). 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:

1. Reconstruct the signed content as `{webhook-id}.{webhook-timestamp}.{raw body}`.
   Use the raw request body bytes exactly as received, before any JSON parsing.
2. 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).
3. 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.
4. Reject deliveries whose `webhook-timestamp` is 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.

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

### Rotating the signing secret [#rotating-the-signing-secret]

Rotate an endpoint's secret from workspace settings with the rotate button on
its card. The new secret is shown once, when the rotation completes. The
dialog asks how long the current secret stays valid: no grace period,
15 minutes, 1 hour, 6 hours or 24 hours (the default).

- **With a grace period**, deliveries are signed with both the new and the
  current secret until the period ends, so receivers keep verifying while you
  roll the new secret out to every service. Use this for routine rotation.
- **With no grace period**, the current secret stops verifying at once: the
  next delivery carries only the new signature. Use this when a secret may
  have leaked, and deploy the new secret to your receivers straight away,
  because deliveries fail verification until you do.

An endpoint keeps one previous secret. While a grace period from the last
rotation is still open, the only rotation accepted is one with no grace
period, and it revokes both old secrets at once: the current one and the one
still in its grace period. This is what makes a leak recoverable even right
after a routine rotation. A rotation with a grace period is refused until the
open one has ended; the dialog shows when that is and offers only the
immediate rotation until then. Over the API, `GET /webhooks` returns the end
of the open grace period as `previousSecretExpiresAt` (null when none is
open), and `POST /webhooks/rotate-secret` takes the window as `gracePeriodMs`:
`0`, `900000`, `3600000`, `21600000` or `86400000`, defaulting to 24 hours. Each
rotation is recorded in the audit log with its grace period and whether it
revoked a secret that was still in its grace period; the secrets themselves
are never logged.

## Retries and failure [#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 event or Resend delivery 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 wait at
least 60 seconds before jitter is applied, so never less than 48 seconds,
regardless of how early the schedule slot falls.

### Auto-disable [#auto-disable]

An endpoint that keeps failing is eventually disabled rather than hammered
forever:

- When deliveries have been failing for 30 minutes, a `delivery.failing`
  event 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.recovered` event 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.disabled` event 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
event** to confirm the endpoint is reachable, then **Replay undelivered** to
catch up on everything the outage cost in one action.

## Manual redelivery and testing [#manual-redelivery-and-testing]

The delivery log in workspace settings shows each delivery with its latest
status code, how many attempts it took, and the last error. From there:

- **Resend delivery** 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 **original `webhook-id`**, so a receiver
  that already processed the event before answering with an error treats it
  as the same message.
- **Replay undelivered** 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 on `webhook-id`
  makes 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. The preview checks
  up to 500 events; when the range holds more, it says so and shows the count
  as a minimum ("at least N"), because the rest of the range has not been
  counted yet. The replay itself has no such limit: it pages through the whole
  range 500 events at a time, with progress shown as it goes, and reports the
  final totals when it finishes. Replays arrive
  out of sequence order; order the caught-up events by the payload's
  `sequence`.
- **Send test event** delivers a signed event 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. Pick
  `webhook.ping`, whose `data` is empty, to confirm reachability and exercise
  your signature verification end to end, or any live event type to receive
  that event with the example `data` shown above and exercise your handler for
  it. Every test event carries `"test": true` in its envelope, and a real event
  never carries the field, so your handler can tell an example from the real
  thing and skip acting on it. A test event never opens, advances or resets the
  failure window.

## Best practices [#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.** A grace period keeps the previous
  secret valid while deliveries are signed with both, so a rolling deploy of
  your services never drops a delivery. For a secret that may have leaked,
  rotate with no grace period instead, as the section on rotating the signing
  secret above describes.
