sign in →

docs / webhooks and events

Webhooks and events

Push, poll or stream: three ways to hear what a domain is doing.

docs/webhooksapi v1

Webhooks#

curl https://api.nolvin.com/v1/domains/$DOMAIN/webhooks -H "Authorization: Bearer $NOLVIN_KEY" -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/nolvin", "events": ["crawl.finished", "budget.hit"]}'
{"id": "…", "secret": "whsec_…", "events": ["crawl.finished","budget.hit"]}

Store the secret — it is shown once (rotate with POST …/webhooks/{id}/rotate-secret). Nolvin POSTs JSON:

{"id": "<delivery id>", "type": "crawl.finished", "domain_id": "…",
 "data": {"runId": "…", "pages": 42, "unchanged": 31, "deterministic": 2, "agentic": 9, "failed": 0, "entities_added": 17}}
EventWhendata
crawl.finishedA crawl run completed.runId, pages, unchanged, deterministic, agentic, failed, entities_added
budget.hitThe monthly spend cap was reached and the domain paused.spend, cap

budget.hit can arrive more than once while a domain stays over its cap.

Verifying signatures#

Each delivery has an X-Nolvin-Signature header: the hex SHA-256 of the secret, a dot, and the raw request body. Compute it over the raw bytes before parsing, and compare in constant time.

import { createHash, timingSafeEqual } from "node:crypto";

export function verifyNolvin(rawBody, signature, secret) {
  const expected = createHash("sha256").update(`${secret}.${rawBody}`).digest("hex");
  return signature?.length === expected.length &&
    timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

Retries and idempotency#

Any 2xx response counts as delivered. Otherwise Nolvin retries twice more with a short backoff (10-second timeout per attempt); deliveries interrupted mid-flight are re-driven by the scheduler. The delivery id stays the same across retries — use it to ignore duplicates. GET …/webhooks/{id}/deliveries shows the last 50 attempts with status and error, and POST …/webhooks/{id}/test sends a test event.

Polling events#

GET /v1/domains/{domainId}/events/poll?since=0
{"events": [{"id": 9812, "type": "page.update", "payload": {"url": "…", "status": "extracted", "mode": "agentic"}, "created_at": "…"}],
 "cursor": 9812}

Pass the returned cursor as since next time. Up to 200 events per call. Event types: run.update, page.update, crawl.finished, budget.hit. Events are retained for a limited window — poll at least daily if you rely on them.

Server-sent events#

curl -N https://api.nolvin.com/v1/domains/$DOMAIN/events -H "Authorization: Bearer $NOLVIN_KEY"
id: 9813
event: page.update
data: {"url":"https://…","page_id":"…","status":"extracted","mode":"unchanged","error":null}

The same events as a stream. The server closes it after about four minutes; reconnect with the Last-Event-ID header (browsers' EventSource does this automatically). A browser EventSource cannot send an Authorization header, so stream from your server. Agent activity across the project streams from GET /v1/agents/events.