Substrate event subscriptions deliver write-path notifications to external consumers via webhook (POST with HMAC signature) or Server-Sent Events (long-lived GET /events/stream?subscription_id=<id>). Subscriptions are first-class subscription entities so their config, history, and provenance live in the same SQLite + reducer model as any other Neotoma record.
This document covers:
- The
subscriptionentity schema (filters, delivery, circuit breaker). - The matcher (
subscriptionMatchesEvent) and the bridge that fans events out (subscription_bridge.ts). - Webhook delivery (signing, retries, rate limit, allow-list).
- SSE hub (ring buffer,
Last-Event-IDresume). - The action surface (
subscribe,unsubscribe,list_subscriptions,get_subscription_status). - Loop prevention for cross-instance peer sync (
sync_peer_id).
It does NOT cover:
- Substrate event semantics or determinism (see
substrate_events.md). - Cross-instance replication via
/sync/webhook(seepeer_sync.md). - The Inspector subscription UI (
/inspector/peersand related routes).
Many agent and integration workloads need to react to write-path events without polling. Subscriptions provide a typed, filterable, idempotent fan-out so external systems can:
- Trigger workflow steps when a new
taskorissuelands. - Mirror selected entity types into a peer Neotoma instance.
- Stream a live activity feed to the Inspector or third-party dashboards.
All deliveries are bounded: subscriptions enforce per-user limits, webhook endpoints have a circuit breaker that auto-deactivates after consecutive failures, and the SSE buffer is capped per process.
- Subscriptions are entities. Each subscription is a row in the
subscriptionentity type (canonical_name_field:subscription_id). Updates flow throughcorrectso history is preserved. - Filters are required. A subscription with no
entity_types,entity_ids, orevent_typesis rejected — there is no firehose mode. - Per-user cap.
NEOTOMA_MAX_SUBSCRIPTIONS_PER_USER(default 50) bounds active subscriptions per user. - HTTPS in production. Webhook URLs MUST be HTTPS unless the host is
localhost/127.0.0.1. The check is performed byisWebhookUrlAllowedand gates both initial registration and queued delivery. - HMAC signing. Every webhook POST carries
X-Neotoma-Signature-256: sha256=<hex>over the canonicalized JSON body, computed from the per-subscriptionwebhook_secret. Stable JSON serialization is provided bystableStringify. - Loop prevention. Subscriptions whose
sync_peer_idmatches an event'ssource_peer_idare skipped, so peer-sync replication does not retransmit observations back to their origin. - No write-path coupling. The bridge runs inside a substrate event listener; exceptions are logged and never propagated to the writer.
src/services/subscriptions/seed_schema.ts registers the global subscription schema. Fields:
subscription_id(string, required, canonical) — UUID assigned at create.watch_entity_types,watch_entity_ids,watch_event_types— array filters; at least one must be non-empty.delivery_method(string, required) —webhookorsse.webhook_url,webhook_secret— required whendelivery_method = "webhook".active(boolean, required) — toggled tofalseby the circuit breaker aftermax_failuresconsecutive errors.created_at(date),last_delivered_at(date) — provenance.consecutive_failures(number),max_failures(number, default 10).sync_peer_id(string) — opt-in loop prevention; skip events stamped with this peer id.
Reducer policies: last_write for scalar mutables; merge_array for filters; last_write for created_at. Schema versions track the file (1.0 at initial release).
flowchart LR
Subscribe[subscribe action] --> Store[store subscription entity]
Store --> Refresh[refreshSubscriptionInIndex]
Write[committed observation] --> Bus[substrateEventBus]
Bus --> Bridge[handleSubstrateEventForSubscriptions]
Bridge --> Match{subscriptionMatchesEvent}
Match -- yes, webhook --> Queue[queueWebhookDelivery]
Match -- yes, sse --> Sse[broadcastSubstrateEventToSse]
Match -- yes, sync_peer --> PeerOut[queuePeerSyncDelivery]
Queue --> POST[fetch POST + HMAC]
POST -- 2xx --> Success[reset failure counter]
POST -- non-2xx / timeout --> Retry[exponential backoff]
Retry -- exceeded max_failures --> Deactivate[active = false via correct]
The bridge is wired once at server startup by installSubscriptionBridge. The in-memory subscription index is rebuilt from SQLite at boot (rebuildSubscriptionIndex) and refreshed whenever a subscription entity changes.
subscription_actions.ts—subscribeUser,unsubscribeUser,listSubscriptionsForUser. Validates filters, enforces the per-user cap, mintssubscription_idand (for webhooks)webhook_secret.subscription_bridge.ts— substrate event listener. On every event:- Pushes into the SSE ring buffer (
pushSubstrateEventToRing). - Broadcasts to matching SSE clients.
- Iterates the in-memory index and queues matching webhook or peer-sync deliveries.
- When the event itself is a
subscriptionentity change, refreshes the index instead of fanning out.
- Pushes into the SSE ring buffer (
subscription_index.ts— in-memory map ofsubscription_idtoSubscriptionRecord, kept in sync with SQLite viarefreshSubscriptionInIndex.subscription_types.ts—parseSubscriptionSnapshot(snapshot → record) andsubscriptionMatchesEvent(record + event → boolean).sse_hub.ts— ring buffer (capacityNEOTOMA_SSE_EVENT_BUFFER, default 1000, max 10000),registerSseClient,broadcastSubstrateEventToSse. Each broadcast writesid:,event:,data:lines so SSE clients can resume withLast-Event-ID.webhook_delivery.ts— fetch POST with 10s timeout, HMAC signing, exponential backoff ([1s, 5s, 30s, 5m]), per-subscription rate limit, circuit breaker viacorrecton the subscription entity.install_subscription_bridge.ts— server boot hook that registers the bridge listener and rebuilds the index.
- Method:
POSTtowebhook_url. - Headers:
Content-Type: application/json,X-Neotoma-Signature-256: sha256=<hex>,User-Agent: neotoma-webhook/<version>. - Body: canonical JSON of the
SubstrateEventpayload viastableStringify(sorted keys, deterministic). Receivers can recompute the signature with the sharedwebhook_secret. - Retries: HTTP non-2xx and timeouts trigger
[1s, 5s, 30s, 5m]retries, after which the failure incrementsconsecutive_failuresvia acorrectwrite. Reachingmax_failuresflipsactive = false. - Allow-list:
https://*always;http://localhost/http://127.0.0.1only outside production. Otherhttp://URLs are refused at registration and at delivery.
- Endpoint:
GET /events/stream?subscription_id=<id>(auth required; subscription must be owned by the caller). This is the canonical path (registered insrc/actions.tsand exposed inopenapi.yaml); the legacyGET /subscriptions/sseshorthand was dropped before v0.12.0 — update any older clients. - Frame format:
id: <seq>\nevent: <event_type>\ndata: <json>\n\n. Theidis the durable event-logseq(monotonic, AUTOINCREMENT), not the in-memory ring counter — so a client'sLast-Event-IDis a stable cursor that survives a server restart. - Resume order (
Last-Event-ID: <seq>):- In-memory ring — if the cursor is still buffered, replay from the ring (hot path).
- Durable event log — if the cursor has left the ring (restart, or a gap larger than
NEOTOMA_SSE_EVENT_BUFFER) but is still within retention, replay from thesubstrate_eventstable. Resume is gap-free across restarts within the retention window. - Gap signal — only when the cursor predates the retained window (or is not a valid cursor), the stream emits a one-time
event: gapframe (reason: cursor_beyond_retention) and resumes from the current head. A consumer requiring gap-free delivery treats this as "reconcile via a full read."
- Durable event log (
substrate_events): every emitted event is persisted synchronously in the same path as the ring push, so durability holds as soon as the event is emitted. Retention is a rolling window,NEOTOMA_EVENT_RETENTION_DAYS(default 7); a background prune runs on startup and every 6h. The ring remains the fast path; a log write/prune failure never blocks delivery.
When a subscription is configured with sync_peer_id = "<peer_id>", the bridge:
- Skips events whose
source_peer_idequalssync_peer_id(those came from the peer; sending them back would loop). - Routes matching webhook deliveries through
queuePeerSyncDelivery(src/services/sync/sync_webhook_outbound.ts) instead of the generic webhook queue, so the outbound payload uses the peer-sync envelope and signing rules. Seepeer_sync.md.
Environment variables:
NEOTOMA_MAX_SUBSCRIPTIONS_PER_USER— soft cap; default 50.NEOTOMA_SSE_EVENT_BUFFER— ring capacity; clamped 100–10000, default 1000.NEOTOMA_EVENT_RETENTION_DAYS— durablesubstrate_eventsretention window; min 1, default 7. Events older than this are pruned (startup + every 6h).NEOTOMA_ENV/NODE_ENV— switches the production allow-list for webhook URLs.NEOTOMA_DEBUG_SUBSTRATE_EVENTS— debug logging on the underlying bus.
Operator playbook:
- Use
neotoma entities list --type subscriptionto audit live subscriptions. - An auto-deactivated subscription stays in the table with
active = false; reactivate by issuing acorrect(orsubscribeagain with the same filters). - Forced rotation of a
webhook_secretrequiresunsubscribe+subscribe.
tests/subscriptions/— unit + integration tests for the matcher, bridge, webhook delivery, SSE hub.tests/unit/subscription_types.test.ts—subscriptionMatchesEventtruth table includingsync_peer_idskip.tests/integration/agentic_eval_matrix.test.tsexercises the bus indirectly via harness scenarios.
substrate_events.md— the upstream event source.peer_sync.md— cross-instance replication that piggy-backs on subscriptions whensync_peer_idis set.agent_attribution_integration.md—agent_thumbprintpropagation into events.docs/specs/MCP_SPEC.md—subscribe/unsubscribe/list_subscriptions/get_subscription_statusaction contracts.