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.
Read the stream
Section titled “Read the stream”Any key with read can read the stream:
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— thecursorof 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 a400, 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.
No gaps, no reordering
Section titled “No gaps, no reordering”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.
Event types
Section titled “Event types”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.
Date reached
Section titled “Date reached”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_enddrops 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.
Webhooks
Section titled “Webhooks”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:
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.
What you receive
Section titled “What you receive”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.
Order, retries, and catching up
Section titled “Order, retries, and catching up”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:
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.
Manage webhooks
Section titled “Manage webhooks”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.
From an agent
Section titled “From an agent”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.