Skip to main content

Data plane API (/v1/...)

Event ingestion and read, for end users and apps/publishers. See API Reference → conventions for auth/tenant-scope rules that apply across every route below.

GET /healthz

  • Auth: none.
  • Purpose: liveness check for deploy/ops tooling. Not part of the original design contract — minimal ops scaffolding.
  • Response: 200 text/plain "ok".

POST /v1/events

  • Auth: required. The one route that accepts either an end-user SSO-JWT or an app/publisher service-JWT — routed by a cheap unverified peek (EdDSA algorithm + fixed issuer claim), not a client-supplied flag. See Authentication.
    • End-user (SSO-JWT): actor is unconditionally the authenticated subject. provenance defaults client-asserted, escalating to backend-verified only via a verified render-token in metadata.render_token.
    • App/publisher (service-JWT): additionally checked against (tenant_id, app_id, event_type)403 if not registered/allowed. source.app is stamped from the token; actor.subject_id is whatever the app asserts via actor_subject_id (not cross-checked against identity-mapping in Phase 1 — a flagged gap: an app can currently assert any string as actor). provenance is always backend-verified.
  • Request body (client-suppliable subset only — everything else is server-derived, see Events & the envelope):
{
"event_type": "invoice.paid",
"target": { "subject_ids": ["sub_finance_team"] },
"event_time": "2026-09-20T10:00:00Z",
"session_id": "sess_xyz",
"seq": 42,
"monotonic_offset_ms": 1234,
"data": { "invoice_id": "inv_789" },
"metadata": { "render_token": "..." },
"actor_subject_id": "sub_alice"
}
  • Response: 202 Accepted with the fully server-stamped envelope (see Events & the envelope / schemas/event.schema.json), or a 4xx on validation/authz/trust failure.

GET /v1/feed

  • Auth: required (subscribe/read plane — the requested subject_id must resolve to the caller or an authorized group; server-enforced, see Authz planes).
  • Purpose: paginated activity/notification timeline for a subject — matches events where the subject is actor or target.
  • Query params: subject_id (default: caller), event_type (repeatable), since/until (RFC3339), cursor, limit.
  • Response: 200 with adapters/store.PageResult JSON.

:::caution Verified wire shape: PascalCase, not the lowercase ROUTES.md/SDK docs describe adapters/store.PageResult has no JSON tags, so json.Encode marshals it under Go's default rule — exported field names verbatim:

{ "Events": [ /* event.Event[] */ ], "NextCursor": "", "HasMore": false }

The project's own api/ROUTES.md and the TypeScript SDK's PageResult type both currently describe a lowercase events/next_cursor/has_more shape instead. This was confirmed by reading internal/adapters/store/store.go's struct definition and api/handlers.go's getFeed (json.NewEncoder(w).Encode(result), no re-serialization step) directly — it is a real, present discrepancy between the documentation/SDK and the running server's actual output, not a documentation preference. The same struct, and therefore the same caution, applies everywhere else PageResult is returned — GET /admin/v1/notifications, GET /admin/v1/notifications/feed/{subject_id}, and GET /admin/v1/activity (see Admin API). :::

A group-scope query (subject_id resolving to an authorized group, not the caller's own subject) currently returns 501 Not Implemented — the store's query shape is subject-keyed only in Phase 1.

GET /v1/stream

  • Auth: required — same subscribe/read-plane rule as GET /v1/feed, plus the realtime adapter's own AuthorizeSubscribe callback (see the caution below).
  • Purpose: realtime event stream via Server-Sent Events, transported over the internal event bus (not a proxy of the realtime adapter's own stream — see Realtime & streaming).
  • Query params: channel — a subject id or group scope; defaults to the caller's own subject.
  • Response: 200 text/event-stream, one event.Event JSON per data: ...\n\n frame.

:::caution Requires a configured realtime adapter GET /v1/stream also calls realtime.Realtime.AuthorizeSubscribe as a second authorization check, so its decision matches what a direct realtime-provider subscribe to the same channel would decide. The noop realtime adapter (an unconfigured deployment's default) fails this check closed by design — so this endpoint returns 403 on any deployment that hasn't configured a real realtime adapter, even though the actual delivery mechanism (the bus) doesn't depend on one. Verified by reading internal/adapters/realtime/noop/noop.go and api/handlers.go's getStream directly. :::

Group-channel messages are not yet membership-filtered on this path — an authorized group-scope stream currently receives every tenant event once authorized (a flagged Phase-1 gap), not just events targeting that group's members.

POST /v1/render-tokens/verify

  • Auth: required.
  • Purpose: verify a render-token's signature, freshness, and single-use nonce standalone. The primary redemption path is still inline inside POST /v1/events (a client-asserted event carrying a token in metadata is verified and escalated during normal ingestion) — this route is for pre-validation (ops/debug, or a frontend confirming freshness before emitting).
  • Request body: { "token": "<carrier-specific encoded token>" }
  • Response: 200 with the decoded token fields on success, 4xx on invalid/expired/replayed:
{
"app": "docs-svc", "tenant": "acme", "subject": "sub_alice", "doc_id": "doc_42",
"issued_at": "2026-09-20T10:00:00Z", "expires_at": "2026-09-20T10:01:00Z",
"nonce": "...", "key_version": 1
}

Note the field names here (subject, issued_at, expires_at, key_version) differ from the render-token's own JWT claim names (sub, iat, exp, key_ver) — this endpoint returns the decoded struct as ordinary JSON, not the JWT claim set verbatim. Both are verified directly from api/handlers.go.

Not yet routed

markViewed() has no dedicated route — in Phase 1 it's just POST /v1/events with event_type: "viewed" and the render-token in metadata. Tenant/app onboarding, credential minting/rotation, policy config, and event replay all live in the Admin API instead of the data plane.