Skip to main content
Nordvec Docs

Webhooks

Receive signed event notifications when documents are indexed, deliveries fail, or endpoints change state. Verification, retries, and testing.

  • View as Markdown
  • View context pack

External assistants

These open a third-party AI service outside the EU. The link passes it this page's address, and anything you ask there is processed by that provider under its own terms.

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 }
}

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

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.

EventStatusPayload (data)
documents.indexedLiveDocuments became searchable. Aggregated per workspace: at most one event per aggregation window, carrying a count.
documents.failedLiveDocuments terminally failed processing. Aggregated per workspace: at most one event per aggregation window, carrying a count.
sync.completedPlannedA connector sync run completed. Payload shape is not final.
sync.failedPlannedA connector sync run failed. Payload shape is not final.
ingestion.completedPlannedAn ingestion run completed. Payload shape is not final.
ingestion.failedPlannedAn ingestion run failed. Payload shape is not final.
audit.recordedLiveThe workspace's audit trail gained entries. Aggregated per workspace over a window: a count and the window to pull, never the entries themselves.
endpoint.disabledLiveA 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.failingLiveDeliveries 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.enabledLiveA disabled webhook endpoint was switched back on. Closes the incident endpoint.disabled opened; delivered to the workspace's other endpoints.
delivery.recoveredLiveA delivery succeeded on an endpoint that delivery.failing was raised for. Closes that incident; delivered to the workspace's other endpoints.
webhook.pingLiveDirected 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 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

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:

HeaderValue
webhook-idMessage id. Identical on every retry, resend and replay of the same event; deduplicate on it.
webhook-timestampUnix timestamp in seconds at send time.
webhook-signatureOne 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.

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

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

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:

AttemptDelay after the previous failure
1immediate
25 seconds
35 minutes
430 minutes
52 hours
65 hours
710 hours
810 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

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

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

  • 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.

Was this page helpful?

On this page