Skip to content
Beta. This surface may change before GA; breaking changes are dated in the changelog.

Events and webhooks

Every Brain keeps one ordered stream of what happened to it: records accepted and deleted, statements asserted and retracted, documents served and retired, schema changes, changes staged, decided and published, holds, source snapshots planned and applied, and declared dates reached. Read it with a cursor, or have it pushed to your own URL. Use it to keep a downstream copy in sync, to rebuild an answer when the facts under it move, or to start your own agent when something changes.

Nicia tells you that something changed and where. Your agents decide what to do about it.

Events carry addresses and versions — a record’s id and kind, the names of the fields a change set, a document’s id, a change id, a version number — and never a value or any text. Read content the way you always do, through /context, /query and GET /records/{id}, so every answer still has a receipt.

GET /changes stays what it is: the review queue. The stream is the feed.

Reads are not on the stream. It carries what the Brain accepted and how its changes moved; what the Brain served, and to whom, is in its receipts — list them, or ask who was served the address an event names.

Any key with read can read the stream:

Terminal window
curl "$API/v1/brains/tenant:acme/events?types=record.accepted,record.deleted&limit=100" \
-H "authorization: Bearer $NICIA_KEY"
{
"data": {
"events": [
{
"cursor": "e1.41.1.gen_7Hq2",
"type": "record.accepted",
"occurredAt": "2026-09-23T10:14:02.118Z",
"data": {
"record": { "id": "bob", "kind": "person" },
"operation": "updated",
"fields": ["role"],
"links": [],
"change": "chg_44a1e0",
"stateVersion": "native:r57"
}
},
{
"cursor": "e1.43.1.gen_7Hq2",
"type": "record.deleted",
"occurredAt": "2026-09-23T10:15:40.502Z",
"data": {
"record": { "id": "acme-old", "kind": "company" },
"change": "chg_51c2d9"
}
}
],
"cursor": "e1.44.0.gen_7Hq2",
"hasMore": false
}
}
  • after — the cursor of a page, or of any single event. Omit it to read from the beginning of the Brain’s history.
  • types — a comma-separated list of the event types below. An unknown name is a 400, not a filter that silently matches nothing.
  • limit — up to 500 events per page; the default is 100.

Keep the page’s cursor and pass it back as after, also when events is empty: the cursor moves past everything the page looked at, including the events your types left out, so the next poll starts where this one stopped. hasMore: false means you have reached the end of what the Brain has committed.

The stream is ordered by commit, and it has no gaps: an event is never returned while an event that committed before it is still being written. A reader that walks the stream page by page, resuming from each page’s cursor, sees every event exactly once and in order — however many writers are working on the Brain at the time.

Cursors are opaque; do not build or parse them. A cursor from before a Brain was deleted and recreated under the same handle is refused with 409, because it points into a different Brain’s history.

Every event has a cursor, a type, an occurredAt, and a data object. change in data, where present, is the id GET /changes and the change lifecycle use.

Type data
record.accepted record (id, kind), operation (created or updated), fields and links it touched, retypedFrom when it changed kind, change, stateVersion
record.deleted record, change, stateVersion
statement.asserted statement (id), predicate, subject (id, kind), change, stateVersion
statement.retracted statement, change, stateVersion
document.served document (id, plus source for a document of a source), version — the number a receipt pins — and change
document.retired document, change
schema.evolved schemaVersion — the declaredVersion GET /schema now returns — and previousSchemaVersion
change.staged change, and supersedes when a decision split an earlier change
change.decided change, decision (pending, approved, rejected, superseded), application (not_applied, scheduled, blocked, applied, cancelled)
change.published change, stateVersion
availability.changed from, to (active, held, deleted)
snapshot.planned source, snapshot, planRevision
snapshot.applied source, snapshot, planRevision, and reviewChange when the apply staged a change
date.reached record (id, kind), field, offset — the notifyBefore duration crossed, P0D for the date itself — and dueAt, the instant it was due

stateVersion is the Brain state a change produced — the same value a receipt’s snapshot.stateVersion carries — when the Brain recorded one.

A reviewed change reads as change.staged, then change.decided when someone approves or rejects it, then — if it is published — the record.*, statement.* and document.* events it applied, followed by change.published.

A date or datetime field that declares notifyBefore (Schemas) puts the passage of time on the stream: when a record’s value minus an offset is reached, the Brain emits one date.reached. A date is reached at the start of its day in UTC.

{
"data": {
"events": [
{
"cursor": "e1.88.0.gen_7Hq2",
"type": "date.reached",
"occurredAt": "2026-11-01T00:00:41.207Z",
"data": {
"record": { "id": "site-042", "kind": "site" },
"field": "contract_end",
"offset": "P30D",
"dueAt": "2026-11-01T00:00:00.000Z"
}
}
],
"cursor": "e1.89.0.gen_7Hq2",
"hasMore": false
}
}
  • Once. Each record, field, value, and offset is emitted exactly once, however often the Brain checks.
  • A changed value reschedules. Moving contract_end drops the old value’s pending crossings and schedules the new value’s. Changing it back does not repeat a crossing already emitted for that value.
  • A deleted record cancels its pending crossings.
  • Only crossings the Brain watched. A crossing that is already in the past when the value is written — a record imported with an old contract date — is not emitted.
  • About a minute. The Brain checks once a minute, so an event arrives within about a minute of dueAt, then through webhooks on their own schedule.

dueAt is derived from the value, so it tells you the date; read the record itself, receipted, before acting on it.

A key with admin and read can have the stream pushed to a URL — admin because a webhook is configuration, read because it carries the stream out. Listing and deleting webhooks need only admin. Choose the event types you want:

Terminal window
curl -X POST "$API/v1/brains/tenant:acme/webhooks" \
-H "authorization: Bearer $NICIA_KEY" \
-H "content-type: application/json" \
-d '{ "url": "https://hooks.example.com/nicia", "types": ["record.accepted", "record.deleted"] }'
{
"url": "https://hooks.example.com/nicia",
"types": ["record.accepted", "record.deleted"]
}
{
"data": {
"webhook": {
"id": "01a0cef2-9115-7760-95cd-35c1a0abc32d",
"url": "https://hooks.example.com/nicia",
"types": ["record.accepted", "record.deleted"],
"createdAt": "2026-09-23T10:20:11.004Z",
"createdByKeyId": "key_7f3a1c",
"pending": false
},
"secret": "6f1c0e8a-3d52-4b7e-9f0a-2c8d4e6b1a90"
}
}

The secret is shown once. Delivery starts with the next event the Brain commits; to hand over from a reader without a gap, pass the last cursor you read as after when you create the webhook.

The URL must be HTTPS on a public host. Private, loopback, link-local and metadata addresses are refused when you create the webhook, and every delivery checks the address again, so a host that later resolves somewhere private still gets nothing.

Each delivery is a POST with one event — exactly as GET /events lists it — and the cursor of the event this webhook delivered before it:

{
"webhook": "01a0cef2-9115-7760-95cd-35c1a0abc32d",
"previousCursor": "e1.41.1.gen_7Hq2",
"event": {
"cursor": "e1.43.1.gen_7Hq2",
"type": "record.deleted",
"occurredAt": "2026-09-23T10:15:40.502Z",
"data": {
"record": { "id": "acme-old", "kind": "company" },
"change": "chg_51c2d9"
}
}
}

Every delivery is signed. X-Nicia-Signature is sha256= followed by the hex HMAC-SHA256 of the raw body with your secret; compare it before you trust the body:

import { createHmac, timingSafeEqual } from "node:crypto";
function verified(rawBody: string, header: string, secret: string): boolean {
const expected = `sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}`;
return header.length === expected.length && timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}

Answer with any 2xx to acknowledge it.

Deliveries are in order and at least once. The next event is not sent until the one before it has been acknowledged, and an event can arrive twice, so key your handling on its cursor. Delivery follows the committed stream on a schedule; expect an event within about a minute of its commit.

A delivery that fails is retried — three attempts in all, over the next couple of minutes. One that still fails is skipped, so a receiver that was down does not hold every later event back forever, and the webhook’s lastFailure says so. The next delivery tells you: its previousCursor is not the last cursor you processed. When that happens, read what you missed before handling it:

Terminal window
curl "$API/v1/brains/tenant:acme/events?after=$LAST_PROCESSED_CURSOR&types=record.accepted,record.deleted" \
-H "authorization: Bearer $NICIA_KEY"

The stream is the record; the webhook is how you hear about it.

GET /v1/brains/{handle}/webhooks lists the Brain’s webhooks with how far each has delivered — lastCursor, whether an event is pending, and the most recent lastFailure — and never their secrets. DELETE /v1/brains/{handle}/webhooks/{webhookId} stops one at once; an event being retried is not sent again. A Brain holds at most 25 webhooks.

Deleting a Brain that has been written to reaches its webhooks as availability.changed to deleted; after that the Brain’s events can no longer be read.

The Brain’s MCP server has the same read as the tool knowledge_events, for a key with read. It takes after, types (a list), and limit, and returns the same page.

  • Receipts — what a read was served, for reading the content an event points at, and who was served the version an event replaced.
  • Review — the queue behind change.staged.