sign in →

docs / api reference

API reference

91 endpoints under https://api.nolvin.com. Auth labels: any key (read or full), full key (full scope, or an owner or editor session), owner (owner session or full key). Also as OpenAPI 3.1 JSON.

docs/api-referenceapi v1

API keys#

Keys are bound to one project. The plaintext key is returned once, at creation or rotation; only a hash is stored.

GET

/v1/project/keys

any key

List API keys — All keys of the project, newest first, including revoked ones. #

200 { keys: [{ id, name, scope, created_at, last_used_at, revoked_at }] }

POST

/v1/project/keys

owner

Create an API key #

BodyTypeDescription
namestringLabel for the key (default key).
scopestring
full · read
read keys can only call GET endpoints; anything else creates a full key.

201 { id, key, scope } — key is nlv_… and is shown only once

POST

/v1/project/keys/{keyId}/rotate

owner

Rotate an API key — Issues a new secret for the key; the old one stops working immediately. #

200 { id, key }

errors 404 key not found

DELETE

/v1/project/keys/{keyId}

owner

Revoke an API key #

200 { ok: true }

Project and members#

A project is the tenant: it owns keys, members and any number of domains.

GET

/v1/project

any key

Get the current project #

200 { id, name, role, scope }

PATCH

/v1/project

owner

Rename the project #

BodyTypeDescription
name requiredstringNew project name.

200 { ok: true }

errors 400 name required

DELETE

/v1/project

owner

Delete the project — Permanently deletes every domain, graph, key, invite and membership of the project. #

QueryTypeDescription
confirm requiredstringMust equal the project name exactly.

200 { deleted, domains, users }

errors 400 pass ?confirm=<project name> to delete

GET

/v1/project/members

any key

List members #

200 { members: [{ id, email, name, role, created_at }] }

PATCH

/v1/project/members/{userId}

owner

Change a member's role #

BodyTypeDescription
role requiredstring
owner · editor · viewer
New role.

200 { ok: true }

DELETE

/v1/project/members/{userId}

owner

Remove a member #

200 { ok: true }

errors 400 cannot remove the last owner

POST

/v1/project/invites

owner

Create an invite #

BodyTypeDescription
rolestring
editor · viewer · owner
Role for the invitee (default editor).

201 { token, role, accept_path }

GET

/v1/project/invites

any key

List invites #

200 { invites: [{ id, role, created_at, accepted_by, accepted_at }] }

Domains#

A domain is one knowledge graph with its own ontology, sources, gate, budget and entities. Nothing crosses domain boundaries.

POST

/v1/domains

full key

Create a domain — Creates an empty domain. To build one from a sentence instead, use autopilot. #

BodyTypeDescription
name requiredstringDisplay name.
descriptionstringWhat the graph covers. The extraction agent reads it.
ontologystring[]Entity kinds (default ["person","object","location","event"]).
max_depthintegerLink-follow depth from sources (default 2).
max_pagesintegerPage budget (default 100).
max_spend_usdnumberMonthly model spend cap in USD (default none).

201 { id, name, ontology }

errors 400 name is required

GET

/v1/domains

any key

List domains #

QueryTypeDescription
allstring1 includes archived domains.

200 { domains: [{ id, name, description, ontology, max_depth, max_pages, max_spend_usd, archived, crawl_state, created_at, entity_count, page_count }] }

GET

/v1/domains/{domainId}

any key

Get a domain #

200 { id, name, description, ontology, attributes, max_depth, max_pages, max_spend_usd, archived, crawl_state, active_run, published_spec_version, created_at }

errors 404 domain not found

PATCH

/v1/domains/{domainId}

full key

Update a domain — Adding a custom attribute with a new key sends the domain's extracted pages back for a full read on their next re-scrape. #

BodyTypeDescription
namestringDisplay name.
descriptionstringScope description.
ontologystring[]Entity kinds. Kinds that still have entities cannot be removed.
attributesobject[]Custom attributes [{ key, label, kinds, description }], at most 6; [] clears them.
max_depthintegerLink-follow depth.
max_pagesintegerPage budget.
max_spend_usdnumber|nullMonthly spend cap; null removes it.

200 { ok: true }

errors 400 cannot remove types with existing entities: … · 400 invalid attributes: … · 400 nothing to update

POST

/v1/domains/{domainId}/archive

full key

Archive a domain — Stops crawling, re-scrapes and spend. Data is kept. #

200 { ok: true }

POST

/v1/domains/{domainId}/unarchive

full key

Unarchive a domain #

200 { ok: true }

DELETE

/v1/domains/{domainId}

owner

Delete a domain — Permanently deletes the domain and its whole graph. #

QueryTypeDescription
confirm requiredstringMust equal the domain name exactly.

200 { deleted }

errors 400 pass ?confirm=<domain name> to delete

GET

/v1/domains/{domainId}/scorecard

any key

Graph scorecard — Deterministic 0–100 quality score with components (coverage, precision, connectivity, corroboration, facets, diversity) and the underlying metrics. #

200 { scorecard: { score, components, metrics } }

Autopilot#

One sentence in; a brief, discovered sources, a crawled and assessed graph out. Stages: brief → discover → crawling → assess → grow → steady (or failed).

POST

/v1/domains/autopilot

full key

Start an autopilot domain #

BodyTypeDescription
prompt requiredstringThe domain in one sentence, 8–500 characters.
max_pagesintegerPage budget (default 150).
max_spend_usdnumberMonthly spend cap (default 25).
max_cyclesintegerCrawl → assess cycles before steady state (default 3).

202 { id, stage: "brief" }

errors 400 prompt: describe the domain in one sentence · 400 prompt too long (max 500 chars)

GET

/v1/domains/{domainId}/autopilot

any key

Get autopilot state #

200 { autopilot: { domain_id, prompt, stage, cycle, max_cycles, brief, last_report, history, error, assessed_at, created_at, updated_at } | null }

POST

/v1/domains/{domainId}/autopilot/retry

full key

Retry a failed stage #

200 { ok: true }

errors 409 autopilot is not in a failed state

Sources and crawling#

Sources are entry points. Crawls stay on source hosts (plus the gate's allowed hosts) within the domain's depth and page budget.

POST

/v1/domains/{domainId}/sources

full key

Add a source — Registers the URL and queues it immediately. #

BodyTypeDescription
url requiredstringAn http(s) URL.

201 { url, queued }

errors 400 valid http(s) url required

POST

/v1/domains/{domainId}/sources/bulk

full key

Add sources in bulk #

BodyTypeDescription
urls requiredstring[]Up to 100 URLs; invalid ones are skipped.

201 { queued }

POST

/v1/domains/{domainId}/sources/sitemap

full key

Add sources from a sitemap #

BodyTypeDescription
url requiredstringSitemap URL. The first 50 <loc> entries are queued.

201 { sitemap, discovered, queued }

GET

/v1/domains/{domainId}/sources

any key

List sources #

200 { sources: [{ id, url, created_at, host, pages_seen, pages_extracted }] }

DELETE

/v1/domains/{domainId}/sources/{sourceId}

full key

Remove a source — Removes the source only; its pages and entities stay. #

200 { ok: true }

errors 404 source not found

POST

/v1/domains/{domainId}/sources/{sourceId}/crawl

full key

Crawl one source #

200 { queued }

POST

/v1/domains/{domainId}/crawl

full key

Crawl all sources — Queues every source not yet crawled. #

200 { queued }

POST

/v1/domains/{domainId}/recrawl

full key

Re-scrape the domain now — Queues every extracted page. Unchanged pages cost nothing; changed pages are read as a diff. #

200 { queued }

POST

/v1/domains/{domainId}/discover-sources

full key

Discover sources — Runs an agentic web search for candidate sources. Results are suggestions: review them and add the ones you want. #

202 { queued: true }

errors 409 a discovery is already running for this domain

GET

/v1/domains/{domainId}/discoveries

any key

List discovery runs #

200 { discoveries: [{ id, status, started_at, finished_at, result, sources: [{ url, reason, kinds }], summary, queries_tried }] } — last 5

POST

/v1/domains/{domainId}/plan-frontier

full key

Plan the frontier — Ranks unexplored links, queues the best, and runs gap searches. Also runs weekly on its own. #

202 { queued: true }

errors 409 a frontier plan is already running for this domain

GET

/v1/domains/{domainId}/plans

any key

List frontier plans #

200 { plans: [{ id, status, started_at, finished_at, result, selected, searches, notes, enqueued, frontier_added }] } — last 5

GET

/v1/domains/{domainId}/frontier

any key

Frontier counts #

200 { frontier: [{ status, n }] } — status: candidate | planned | crawled | rejected

Pipeline, runs and usage#

Page statuses: queued, extracted, irrelevant, skipped, failed. A page's last mode: agentic (a model read it), deterministic, or unchanged.

GET

/v1/domains/{domainId}/pages

any key

List pages #

QueryTypeDescription
statusstringFilter by page status.
limitintegerPage size (default 100, max 500).
offsetintegerRows to skip (default 0). Use the previous response's next_offset.

200 { pages: [{ id, url, depth, status, last_mode, error, source_host, run_id, fetched_at }], next_offset }

GET

/v1/domains/{domainId}/runs

any key

List crawl runs #

200 { runs: [{ id, status, started_at, finished_at, pages_total, pages_done, pages_unchanged, pages_deterministic, pages_agentic, pages_irrelevant, pages_skipped, pages_failed, entities_added, tokens_input, tokens_output, spend_usd }] } — last 20

POST

/v1/domains/{domainId}/runs/pause

full key

Pause crawling #

200 { crawl_state: "paused" }

POST

/v1/domains/{domainId}/runs/resume

full key

Resume crawling #

200 { crawl_state: "running", requeued }

POST

/v1/domains/{domainId}/runs/cancel

full key

Cancel the active run #

200 { cancelled: true, cancelled_pages }

errors 400 no active run

GET

/v1/domains/{domainId}/recipes

any key

List recipes — Legacy deterministic extractors and their health. #

200 { recipes: [{ id, url, version, status, consecutive_failures, last_run_at, last_error, created_at }] }

POST

/v1/domains/{domainId}/recipes/{recipeId}/invalidate

full key

Invalidate a recipe #

200 { ok: true }

errors 404 recipe not found

GET

/v1/domains/{domainId}/errors

any key

List failed pages #

200 { failed: [{ id, url, depth, error, fetched_at, source_host }] } — last 200

POST

/v1/domains/{domainId}/errors/requeue

full key

Requeue failed pages #

BodyTypeDescription
page_idsstring[]Up to 100 failed page ids.
allbooleantrue requeues up to 200 failed pages.

200 { requeued }

errors 400 pass page_ids or all=true

GET

/v1/domains/{domainId}/dead-letters

any key

List dead letters — Pages that failed extraction three times. #

200 { dead_letters: [{ id, url, depth, error, status, created_at }] }

POST

/v1/domains/{domainId}/dead-letters/{deadLetterId}/requeue

full key

Requeue a dead letter #

200 { ok: true }

errors 404 not found

POST

/v1/domains/{domainId}/dead-letters/{deadLetterId}/discard

full key

Discard a dead letter #

200 { ok: true }

GET

/v1/domains/{domainId}/usage

any key

Usage and spend #

QueryTypeDescription
daysintegerWindow in days (default 30, max 90).

200 { days: [{ day, pages_unchanged, pages_deterministic, pages_agentic, pages_failed, tokens_input, tokens_output, spend_usd }], totals }

Gate (spec)#

The gate is the domain's versioned relevance rules. Exactly one version is published; the pipeline enforces it.

GET

/v1/domains/{domainId}/spec

any key

List gate versions #

200 { specs: [{ id, version, status, instructions, include_patterns, exclude_patterns, allowed_hosts, language, created_at, published_at }] }

POST

/v1/domains/{domainId}/spec

full key

Create a draft gate #

BodyTypeDescription
instructionsstringPlain-language inclusion and exclusion rules the extraction agent applies.
include_patternsstring[]URL patterns a page must match (substring, or glob with *).
exclude_patternsstring[]URL patterns never to crawl.
allowed_hostsstring[]Hosts in scope beyond the source hosts.
languagestringISO 639-1 language for descriptions.

201 { id, version, status: "draft" }

POST

/v1/domains/{domainId}/spec/{specId}/publish

full key

Publish a gate version #

QueryTypeDescription
rejudgestring1 gives pages the old gate rejected a second look.

200 { ok: true, version }

errors 404 spec not found

POST

/v1/domains/{domainId}/dry-run

full key

Test a gate on one URL — Fetch, gate and extract one page without writing to the graph. Costs one extraction. #

BodyTypeDescription
url requiredstringPage to test.
spec_idstringDraft gate to test (default: the published one).

200 { url, gate_rejection, relevant, confidence, page_class, entities, relationships, next_urls, model, cost_usd, markdown, tokens }

Graph queries#

Read-only; any project key works. See the graph queries guide for worked examples.

GET

/v1/domains/{domainId}/entities

any key

List entities #

QueryTypeDescription
typestringperson, object, location or event.
qstringSubstring match on name or description.
limitintegerPage size (default 50, max 200).
offsetintegerRows to skip (default 0). Use the previous response's next_offset.

200 { entities: [{ id, type, name, description, aliases, created_at, updated_at }], next_offset }

GET

/v1/domains/{domainId}/entities/{entityId}

any key

Get an entity — The entity with its facet, custom attributes, relationships and up to 50 provenance rows. #

200 { entity, facet, attributes, relationships: [{ relation, direction, entity_id, name, type }], provenance: [{ snippet, url, created_at }] }

errors 404 not found

GET

/v1/domains/{domainId}/entities/{entityId}/neighbors

any key

Traverse the neighborhood #

QueryTypeDescription
depthintegerHops, 1–3 (default 1).

200 { root, nodes: [{ id, name, type }], edges: [{ source, relation, target }] }

GET

/v1/domains/{domainId}/relationships

any key

List relationships #

QueryTypeDescription
relationstringOne canonical predicate, e.g. organizes.

200 { relationships: [{ id, relation, source_entity_id, target_entity_id, source_name, source_type, target_name, target_type }] } — up to 500

GET

/v1/domains/{domainId}/timeline

any key

Timeline — Events with their participants, ordered by start. With entity_id, the events connected to any entity. #

QueryTypeDescription
fromstringEarliest starts_at (ISO date).
tostringLatest starts_at.
entity_idstringProject one entity onto the timeline.
granularitystring
year · month · day
Return counts per bucket instead of events.

200 { timeline: [{ id, name, description, starts_at, ends_at, participants }] } or { granularity, buckets: [{ bucket, n }] }

GET

/v1/domains/{domainId}/geo.geojson

any key

Map (GeoJSON) — Located entities as a GeoJSON FeatureCollection, each with its connected entities. With entity_id, one entity's footprint. #

QueryTypeDescription
entity_idstringProject one entity onto the map.
bboxstringminLng,minLat,maxLng,maxLat.
clusterstring1 returns grid clusters with counts.
cellnumberCluster cell size in degrees (default 2, min 0.25).

200 GeoJSON FeatureCollection — properties: { entity_id, name, description, address, connected }

GET

/v1/domains/{domainId}/stats

any key

Graph statistics #

200 { totals: { entities, relationships, facts, pages }, entities_by_type, top_relations, pages_by_status, pages_by_mode, recipes_by_status }

GET

/v1/domains/{domainId}/export

any key

Export the graph #

QueryTypeDescription
formatstring
json · graphml
Default json.

200 { domain, entities, relationships, facets: { locations, events, persons, objects, attributes } } or GraphML

Curation#

Human corrections. Every change is audited, and deletes leave a tombstone so a re-scrape does not bring the item back. All curation endpoints need write access.

POST

/v1/domains/{domainId}/curation/entities/{entityId}/rename

full key

Rename an entity — The old name is kept as an alias. #

BodyTypeDescription
name requiredstringNew name.

200 { ok: true, name }

errors 409 an entity of this type with that name exists — merge instead

POST

/v1/domains/{domainId}/curation/entities/{entityId}/merge

full key

Merge into another entity — This entity's edges, provenance and names move to the survivor. #

BodyTypeDescription
into requiredstringSurvivor entity id.

200 { ok: true, survivor, aliases }

PATCH

/v1/domains/{domainId}/curation/entities/{entityId}/facet

full key

Replace an entity's facet — Replaces the whole facet; omitted fields become null. Location: lat, lng, address. Event: starts_at, ends_at. Person: aliases, role. Object: category, identifier. #

BodyTypeDescription
…facet fieldsobjectFields for the entity's kind.

200 { ok: true }

DELETE

/v1/domains/{domainId}/curation/entities/{entityId}

full key

Delete an entity #

200 { ok: true }

DELETE

/v1/domains/{domainId}/curation/relationships/{relationshipId}

full key

Delete a relationship #

200 { ok: true }

GET

/v1/domains/{domainId}/curation/audit

full key

Curation audit log #

200 { audit: [{ id, user_id, action, subject_id, details, created_at }] } — last 100

Share links#

Read-only public views of a domain behind an unguessable token — no key needed to read them.

POST

/v1/domains/{domainId}/share

full key

Create a share link #

BodyTypeDescription
viewsstring[]Any of graph, timeline, map, search (default all).

201 { id, token, views, public_base } — token is shr_… and is shown only once

GET

/v1/domains/{domainId}/share

any key

List share links #

200 { shares: [{ id, views, created_at, revoked_at }] }

DELETE

/v1/domains/{domainId}/share/{shareId}

full key

Revoke a share link #

200 { ok: true }

GET

/v1/public/{token}/meta

share token

Shared domain metadata #

200 { name, description, views }

errors 404 invalid share token

GET

/v1/public/{token}/entities

share token

Shared entities — Needs the graph or search view. #

QueryTypeDescription
limitintegerDefault 100, max 200.

200 { entities: [{ id, type, name, description }] }

errors 403 view not shared

GET

/v1/public/{token}/timeline

share token

Shared timeline — Needs the timeline view. #

200 { timeline: [{ id, name, description, starts_at, ends_at }] }

errors 403 view not shared

GET

/v1/public/{token}/geo.geojson

share token

Shared map — Needs the map view. #

200 GeoJSON FeatureCollection

errors 403 view not shared

Webhooks#

Signed POSTs when a crawl finishes or a budget is hit. See Webhooks and events for payloads and signature verification.

GET

/v1/domains/{domainId}/webhooks

any key

List webhooks #

200 { webhooks: [{ id, url, events, active, created_at }] }

POST

/v1/domains/{domainId}/webhooks

full key

Create a webhook #

BodyTypeDescription
url requiredstringReceiver URL.
eventsstring[]Any of crawl.finished, budget.hit (default crawl.finished).

201 { id, secret, events } — secret is whsec_… and is shown only once

POST

/v1/domains/{domainId}/webhooks/{webhookId}/rotate-secret

full key

Rotate a webhook secret #

200 { secret }

errors 404 webhook not found

DELETE

/v1/domains/{domainId}/webhooks/{webhookId}

full key

Delete a webhook #

200 { ok: true }

POST

/v1/domains/{domainId}/webhooks/{webhookId}/test

full key

Send a test delivery — Dispatches a test crawl.finished to the domain's subscribed webhooks. #

200 { ok: true, note }

GET

/v1/domains/{domainId}/webhooks/{webhookId}/deliveries

any key

List deliveries #

200 { deliveries: [{ id, event_type, payload, status, attempts, last_error, created_at, delivered_at }] } — last 50

Events and agents#

Live progress: poll with a cursor or stream server-sent events.

GET

/v1/domains/{domainId}/events/poll

any key

Poll domain events #

QueryTypeDescription
sinceintegerCursor from the previous response (default 0).

200 { events: [{ id, type, payload, created_at }], cursor }

GET

/v1/domains/{domainId}/events

any key

Stream domain events (SSE) — Events: run.update, page.update, crawl.finished, budget.hit. Resumes from Last-Event-ID; the server closes the stream after about four minutes, so reconnect. #

QueryTypeDescription
sinceintegerStart after this event id.

200 text/event-stream

GET

/v1/agents

any key

List agent runs #

200 { agents: [{ id, kind, label, status, started_at, finished_at, tokens_input, tokens_output, result, domain_name, domain_id }] }

GET

/v1/agents/{runId}/activity

any key

Agent run activity #

200 { run, activity: [{ id, ts, text }] }

errors 404 agent run not found

GET

/v1/agents/activity/poll

any key

Poll agent activity #

QueryTypeDescription
sinceintegerCursor (default 0).
tailintegerReturn the last N rows instead (max 200).

200 { activity: [{ id, ts, text, agent_run_id, kind, label, status }], cursor }

GET

/v1/agents/events

any key

Stream agent activity (SSE) — Event name: agent.activity. #

200 text/event-stream

Feedback#

Report a problem or an idea; it reaches the team building Nolvin.

POST

/v1/feedback

any key

Send feedback #

BodyTypeDescription
pathname requiredstringWhere it happened.
full_url requiredstringFull URL.
description requiredstringWhat you saw or want (max 20,000 characters).
categorystring
idea · bug · critical · other
Default idea.
selectorstringCSS selector of the element concerned.

201 { id, status: "new" }

GET

/v1/feedback

any key

List your feedback #

QueryTypeDescription
statusstring
new · in_progress · shipped · closed · all
Filter (default all).
limitintegerDefault 50, max 200.

200 { feedback: [{ id, category, status, description, fix_url, fix_summary, created_at, … }] }

Service#

Unauthenticated.

GET

/api

no auth

Service description #

200 { name, description, docs }

GET

/healthz

no auth

Health check — 503 when the database is unreachable or the scheduler is stale. #

200 { ok, db, cron, cron_age_minutes, queued_pages, dead_pages_pending }