API endpoints

Status: Source-derived from v2/api/worker.ts and v2/auth/worker.ts on 2026-10-30. The hosted product API is unverified. Response examples are synthetic shapes, not live captures.

Every request below needs an API key and organization scope. Inject HARVY_API_KEY and HARVY_ORGANIZATION_ID via an approved secret mechanism (Get started); never hard-code them. Browser clients use session cookies instead and omit these headers. The server derives tenant/workspace from the actor — request parameters cannot widen scope. API examples has the narrative walkthrough; this page is the per-endpoint reference. Headers and roles are explained in Authentication; analytics queries in Query guide.

GET /api/v1/context

Returns the actor’s tenant, workspace, role, allowed products, capabilities and contract revisions. Start every integration here to learn which products and capabilities the key actually has.

Parameters: none.

Request:

curl --fail-with-body --silent --show-error \
  -H "x-api-key: $HARVY_API_KEY" \
  -H "x-harvy-organization-id: $HARVY_ORGANIZATION_ID" \
  https://harvy.dev/api/v1/context
// Node 18+ (global fetch)
const response = await fetch("https://harvy.dev/api/v1/context", {
  headers: {
    "x-api-key": process.env.HARVY_API_KEY,
    "x-harvy-organization-id": process.env.HARVY_ORGANIZATION_ID,
  },
});
if (!response.ok) throw new Error(`context ${response.status}`);
const context = await response.json();
console.log(context.allowedProducts, context.capabilities);
# Python 3 (requires: pip install requests)
import os, requests
 
response = requests.get(
    "https://harvy.dev/api/v1/context",
    headers={
        "x-api-key": os.environ["HARVY_API_KEY"],
        "x-harvy-organization-id": os.environ["HARVY_ORGANIZATION_ID"],
    },
    timeout=30,
)
response.raise_for_status()
context = response.json()
print(context["allowedProducts"], context["capabilities"])

Response (synthetic):

{
  "principalId": "00000000-0000-4000-8000-000000000001",
  "tenantId": "00000000-0000-4000-8000-000000000002",
  "workspaceId": "00000000-0000-4000-8000-000000000002",
  "tenantName": "Example organization",
  "role": "member",
  "allowedProducts": ["Newsk", "Tower"],
  "capabilities": ["query.view", "events.view", "evidence.view", "events.edit"],
  "featureRevision": "3",
  "ontologySchemaRevision": "1",
  "hqlVersion": 1
}

Note the camelCase fields in context versus snake_case in event records — a known contract inconsistency documented in Developer standards. Follow the actual response. Errors: 401 no auth/org, 403 no membership.

GET /api/v1/events

Lists product-scoped events, newest first, with cursor pagination.

Parameters (query):

NameTypeRequiredDescription
productstringyes*Newsk, Tower, Ops, or Civic. Exact case. *Required unless the tenant has exactly one enabled product.
qstringnoSearch in title/summary. Max 200 characters.
fromstringnoISO date; last_seen_at lower bound.
tostringnoISO date; last_seen_at upper bound.
statusstringnoOne of new, reviewing, saved, closed.
source_idstringnoOnly events with evidence from this source.
limitintegerno1–100. Default 50.
beforestringnoOpaque cursor from the previous page’s next_cursor.

Request:

curl --fail-with-body --silent --show-error \
  -H "x-api-key: $HARVY_API_KEY" \
  -H "x-harvy-organization-id: $HARVY_ORGANIZATION_ID" \
  'https://harvy.dev/api/v1/events?product=Newsk&status=reviewing&limit=5'
const params = new URLSearchParams({ product: "Newsk", status: "reviewing", limit: "5" });
const response = await fetch(`https://harvy.dev/api/v1/events?${params}`, {
  headers: {
    "x-api-key": process.env.HARVY_API_KEY,
    "x-harvy-organization-id": process.env.HARVY_ORGANIZATION_ID,
  },
});
const page = await response.json();
for (const event of page.items) console.log(event.id, event.title);
import os, requests
 
response = requests.get(
    "https://harvy.dev/api/v1/events",
    headers={
        "x-api-key": os.environ["HARVY_API_KEY"],
        "x-harvy-organization-id": os.environ["HARVY_ORGANIZATION_ID"],
    },
    params={"product": "Newsk", "status": "reviewing", "limit": 5},
    timeout=30,
)
response.raise_for_status()
for event in response.json()["items"]:
    print(event["id"], event["title"])

Response (synthetic):

{
  "items": [
    {
      "id": "00000000-0000-4000-8000-000000000003",
      "product": "Newsk",
      "title": "Example event title",
      "summary": "Example summary text.",
      "status": "reviewing",
      "assigned_to": null,
      "revision": 2,
      "first_seen_at": 1793100000000,
      "last_seen_at": 1793350000000,
      "latitude": null,
      "longitude": null,
      "evidence_count": 4
    }
  ],
  "next_cursor": "1793350000000:00000000-0000-4000-8000-000000000003"
}

Timestamps are Unix milliseconds. next_cursor is null when the page is complete; to fetch the next page pass it as before with identical filters. Errors: 400 invalid filter/cursor, 403 missing events.view or product entitlement, 401 no auth/org.

GET /api/v1/events/:event_id

Returns one event with its evidence and state history.

Parameters:

NameTypeRequiredDescription
event_idpath (UUID)yesEvent ID returned by the list endpoint.
productquery stringyes*Same rule as the list endpoint.

Request:

curl --fail-with-body --silent --show-error \
  -H "x-api-key: $HARVY_API_KEY" \
  -H "x-harvy-organization-id: $HARVY_ORGANIZATION_ID" \
  "https://harvy.dev/api/v1/events/$EVENT_ID?product=Newsk"
const response = await fetch(
  `https://harvy.dev/api/v1/events/${eventId}?product=Newsk`,
  {
    headers: {
      "x-api-key": process.env.HARVY_API_KEY,
      "x-harvy-organization-id": process.env.HARVY_ORGANIZATION_ID,
    },
  },
);
const detail = await response.json();
for (const item of detail.evidence) console.log(item.publisher, item.source_url);
import os, requests
 
response = requests.get(
    f"https://harvy.dev/api/v1/events/{event_id}",
    headers={
        "x-api-key": os.environ["HARVY_API_KEY"],
        "x-harvy-organization-id": os.environ["HARVY_ORGANIZATION_ID"],
    },
    params={"product": "Newsk"},
    timeout=30,
)
response.raise_for_status()
for item in response.json()["evidence"]:
    print(item["publisher"], item["source_url"])

Response (synthetic): the event object above, plus:

{
  "evidence": [
    {
      "id": "00000000-0000-4000-8000-000000000004",
      "source_id": "registered-source-id",
      "source_url": "https://publisher.example/article",
      "publisher": "Publisher name",
      "headline": "Source headline",
      "excerpt": "Relevant excerpt.",
      "captured_at": 1793300000000,
      "artifact_id": "manifest-id-or-null"
    }
  ],
  "activity": [
    {
      "revision": 2,
      "status": "reviewing",
      "assigned_to": null,
      "changed_by": "00000000-0000-4000-8000-000000000001",
      "changed_at": 1793350000000
    }
  ]
}

Evidence is capped at 100 rows, activity at 50. artifact_id is metadata, not a download URL. An event outside the actor’s scope returns 404, not 403 — do not probe other tenants’ IDs. Errors: 403 missing evidence.view, 404 not found in scope.

PATCH /api/v1/events/:event_id

Changes an event’s status or assignment with optimistic concurrency.

Parameters:

NameTypeRequiredDescription
event_idpath (UUID)yesEvent ID.
productquery stringyes*Same rule as the list endpoint.
statusbody stringno*new, reviewing, saved, or closed.
assigned_tobody string/nullno*Currently only the actor’s own ID or null.
expected_revisionbody integeryesThe revision last read. A mismatch returns 409.

* At least one of status or assigned_to must be present.

Request (curl):

curl --fail-with-body --silent --show-error -X PATCH \
  -H "x-api-key: $HARVY_API_KEY" \
  -H "x-harvy-organization-id: $HARVY_ORGANIZATION_ID" \
  -H 'Content-Type: application/json' \
  --data "{\"status\":\"reviewing\",\"expected_revision\":$EVENT_REVISION}" \
  "https://harvy.dev/api/v1/events/$EVENT_ID?product=Newsk"
// Read-modify-write: never hard-code a revision.
const detail = await response.json(); // from GET event detail
const update = await fetch(
  `https://harvy.dev/api/v1/events/${eventId}?product=Newsk`,
  {
    method: "PATCH",
    headers: {
      "x-api-key": process.env.HARVY_API_KEY,
      "x-harvy-organization-id": process.env.HARVY_ORGANIZATION_ID,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ status: "reviewing", expected_revision: detail.revision }),
  },
);
if (update.status === 409) {
  // Conflict: re-read the event, then decide whether to retry.
  throw new Error("revision_conflict");
}
import os, requests
 
detail = requests.get(
    f"https://harvy.dev/api/v1/events/{event_id}",
    headers={
        "x-api-key": os.environ["HARVY_API_KEY"],
        "x-harvy-organization-id": os.environ["HARVY_ORGANIZATION_ID"],
    },
    params={"product": "Newsk"},
    timeout=30,
).json()
 
update = requests.patch(
    f"https://harvy.dev/api/v1/events/{event_id}",
    headers={
        "x-api-key": os.environ["HARVY_API_KEY"],
        "x-harvy-organization-id": os.environ["HARVY_ORGANIZATION_ID"],
    },
    params={"product": "Newsk"},
    json={"status": "reviewing", "expected_revision": detail["revision"]},
    timeout=30,
)
if update.status_code == 409:
    raise RuntimeError("revision_conflict: re-read the event before retrying")
update.raise_for_status()

Response: the full event detail (same shape as GET), with the incremented revision. Errors: 400 invalid body/status, 403 missing events.edit or invalid assignment, 404 out of scope, 409 stale expected_revision. This writes real data — do not call it as a connection test.

GET /api/v1/search

Searches events, evidence, stories (Newsk), dashboards, Ops actions, investigations and entities in one call.

Parameters (query):

NameTypeRequiredDescription
qstringyesSearch term, 2–120 characters.
productstringyes*Product scope.
limitintegerno1–20. Default 10.
from, tostringnoISO date bounds.

Request:

curl --fail-with-body --silent --show-error \
  -H "x-api-key: $HARVY_API_KEY" \
  -H "x-harvy-organization-id: $HARVY_ORGANIZATION_ID" \
  'https://harvy.dev/api/v1/search?q=substation&product=Newsk&limit=12'

Response (synthetic):

{
  "items": [
    {
      "type": "event",
      "id": "00000000-0000-4000-8000-000000000003",
      "product": "Newsk",
      "title": "Example event title",
      "description": "Example summary text.",
      "updated_at": 1793350000000
    }
  ]
}

type is one of event, evidence, story, dashboard, action, investigation, entity; items may carry event_id or source_product. Results are capped at limit and sorted by updated_at descending. Errors: 400 invalid term/range, 403 missing query.view.

GET /api/v1/entitlements and PUT /api/v1/entitlements

Platform administration of product grants. Requires the entitlements.manage capability (platform-admin flag). GET lists one row per product. PUT changes one grant.

PUT body:

NameTypeRequiredDescription
productstringyesNewsk, Tower, Ops, Civic.
enabledbooleanyesNew state.
expected_revisionintegeryesCurrent revision (0 when the grant row does not exist yet).
curl --fail-with-body --silent --show-error -X PUT   -H "x-api-key: $HARVY_API_KEY"   -H "x-harvy-organization-id: $HARVY_ORGANIZATION_ID"   -H 'Content-Type: application/json'   --data '{"product":"Newsk","enabled":true,"expected_revision":0}'   https://harvy.dev/api/v1/entitlements

Response: { "product": "Newsk", "enabled": true, "revision": 1 }. Setting the same state returns the current revision without writing; a stale revision returns 409. Every change writes an audit record. Errors: 403 missing capability, 400 invalid fields, 409 conflict.

GET /api/v1/sources and PATCH /api/v1/sources/:source_id

Approved monitoring sources for one product. GET (capability monitors.view) lists up to 200 approved sources; without monitors.manage only enabled sources are visible. PATCH (capability monitors.manage) pauses or resumes one source.

PATCH body:

NameTypeRequiredDescription
enabledbooleanyesNew monitor state.
expected_revisioninteger ≥ 1yesCurrent source revision.
curl --fail-with-body --silent --show-error -X PATCH   -H "x-api-key: $HARVY_API_KEY"   -H "x-harvy-organization-id: $HARVY_ORGANIZATION_ID"   -H 'Content-Type: application/json'   --data '{"enabled":false,"expected_revision":3}'   "https://harvy.dev/api/v1/sources/$SOURCE_ID?product=Newsk"

Response: { "id": "…", "enabled": false, "revision": 4 }. Only approved, registered sources exist here; unregistered IDs return 404. Errors: 400, 403, 404, 409 revision_conflict.

Stories (Newsk)

Stories turn a Newsk event into a reviewable narrative. All story routes require the Newsk product enabled.

GET /api/v1/stories (capability stories.view) — lists up to 100 workspace stories: { id, event_id, title, notes, status, revision, created_at, updated_at }.

POST /api/v1/stories (capability stories.edit) — creates a draft from a scoped Newsk event.

Body fieldTypeConstraints
event_idUUIDmust be a Newsk event in the caller’s workspace
titlestring1–240 characters
notesstringup to 20,000 characters
curl --fail-with-body --silent --show-error -X POST   -H "x-api-key: $HARVY_API_KEY"   -H "x-harvy-organization-id: $HARVY_ORGANIZATION_ID"   -H 'Content-Type: application/json' \
  --data "{\"event_id\":\"$EVENT_ID\",\"title\":\"Draft story\",\"notes\":\"Working notes.\"}"   https://harvy.dev/api/v1/stories

Returns 201 with the created story (status: "draft", revision: 1). A non-scoped event returns 404.

GET /api/v1/stories/:story_id — one story or 404.

PATCH /api/v1/stories/:story_id — revision-checked update (capability stories.edit): body is { title, notes, status, revision } with status one of draft, ready, archived, and revision equal to the current one. A handed_off story cannot be edited (409 story_handed_off). Stale revision returns 409.

POST /api/v1/stories/:story_id/handoff (capability stories.handoff) — requires Newsk and Ops enabled. The story must be ready, its event must have at least one evidence row (409 insufficient_evidence otherwise). Creates an Ops action and marks the story handed_off in one batch. Response 201: { action_id, story_id, status: "open", created_at }. Idempotent per story.

Briefs (Newsk)

POST /api/v1/briefs (capability stories.edit) — generates an extractive, citation-bearing brief from a story. Body: { "story_id": "…" }. The story’s event must have at least one evidence row (409 insufficient_evidence otherwise). Response 201: { id, title, body, citations, generated_by: "extractive", created_at } where citations is the evidence list (URL, publisher, headline, captured time). The body concatenates story notes with numbered source citations — it is extractive, not model-generated prose.

GET /api/v1/briefs (capability stories.view) — up to 100 briefs.

Ops actions

GET /api/v1/actions (Ops enabled, capability ops.actions.view) — up to 100 actions with story/event titles and the latest evidence source URL and publisher.

PATCH /api/v1/actions/:action_id (capability ops.actions.edit) — body { status, revision }. Valid statuses and transitions:

CurrentAllowed next
openin_progress, cancelled
in_progressopen, done, cancelled
done— (terminal)
cancelledopen

An illegal transition returns 409 invalid_transition; a stale revision returns 409 revision_conflict.

Dashboards

GET /api/v1/dashboards (capability dashboards.view) — up to 100 visible dashboards: workspace-visible ones plus the caller’s own. Each row carries can_edit (owner only).

POST /api/v1/dashboards (capability dashboards.edit):

Body fieldTypeConstraints
titlestring1–160 characters
productstringone of the four products, enabled for the tenant
configobjectmust pass the shared dashboard schema; JSON ≤ 32,000 characters
descriptionstringoptional, ≤ 1,000 characters
visibilitystringprivate (default) or workspace

Response 201: { id, revision: 1, created_at, updated_at }. Arbitrary JSON is rejected (400 invalid_dashboard).

PATCH /api/v1/dashboards/:dashboard_id — owner-only; body repeats title, config, optional description/visibility, plus revision. Returns 409 revision_conflict on mismatch.

DELETE /api/v1/dashboards/:dashboard_id — owner-only soft delete; body { "expected_revision": n }. Response { "deleted": true }.

Runs (Automation dispatch)

POST /api/v1/runs (capability runs.manage) — registers and dispatches a collection run for an approved source.

Body fieldTypeConstraints
source_idUUIDapproved, enabled, registered source in the workspace
idempotency_keystring8–128 chars of A-Za-z0-9._:-, stable per logical run
curl --fail-with-body --silent --show-error -X POST \
  -H "x-api-key: $HARVY_API_KEY" \
  -H "x-harvy-organization-id: $HARVY_ORGANIZATION_ID" \
  -H 'Content-Type: application/json' \
  --data "{\"source_id\":\"$SOURCE_ID\",\"idempotency_key\":\"my-job-2026-10-30-a\"}" \
  https://harvy.dev/api/v1/runs

Response 202: { run_id, status: "queued", idempotency_key, trace_id }. Behavior worth knowing:

  • Idempotent: the same key returns the existing run (200 once it left queued). A key reused with a different source_id returns 409 idempotency_conflict.
  • Bounded: an hourly workspace quota can return 429 run_quota_exceeded.
  • No direct fallback: the dispatched envelope fixes the approved proxy path, requires preflight, and caps pages and a 300-second deadline. The dispatch call itself has a 15-second timeout; an ambiguous outcome is recorded as dispatch_unconfirmed (error_code) rather than silently failed.
  • The live Automation workflow is unverified — see Implementation status.

GET /api/v1/runs?product=… (capability runs.view) — up to 100 runs. idempotency_key is included only for runs.manage actors. GET /api/v1/runs/:run_id — one run or 404.

Error reference

All endpoints share the envelope from API examples: {"error": {"code", "message", "trace_id", "retryable"}}.

HTTPcode (common)MeaningClient action
400invalid_request, invalid_search, invalid_event_update, invalid_queryBounded input failed validationFix the request; do not retry unchanged
401unauthorizedMissing/invalid key or sessionAuthenticate and select an organization
403forbiddenCapability or product entitlement missingDo not retry; request access
404not_foundRecord absent or outside scopeTreat out-of-scope as not found
409revision_conflictStale expected_revisionRe-read, decide, retry if appropriate
413too_largeBody above 65,536 bytesReduce payload
503unavailableStorage or analytics dependency unprovisionedRetry later or report with trace_id

The complete catalog with every code is Errors; bounds are in Limits. Exact behavior is authoritative in v2/api/worker.ts; this is a source-derived summary, not generated OpenAPI.

See also

  • Get started — environment setup and first request.
  • API examples — pagination, evidence and safe-update walkthrough.
  • API reference — full route inventory including stories, dashboards, runs, investigations.
  • Code walkthrough — how these endpoints work inside the Workers.