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):
actoris unconditionally the authenticated subject.provenancedefaultsclient-asserted, escalating tobackend-verifiedonly via a verified render-token inmetadata.render_token. - App/publisher (service-JWT): additionally checked against
(tenant_id, app_id, event_type)—403if not registered/allowed.source.appis stamped from the token;actor.subject_idis whatever the app asserts viaactor_subject_id(not cross-checked against identity-mapping in Phase 1 — a flagged gap: an app can currently assert any string as actor).provenanceis alwaysbackend-verified.
- End-user (SSO-JWT):
- 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 Acceptedwith the fully server-stamped envelope (see Events & the envelope /schemas/event.schema.json), or a4xxon validation/authz/trust failure.
GET /v1/feed
- Auth: required (subscribe/read plane — the requested
subject_idmust 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:
200withadapters/store.PageResultJSON.
:::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 ownAuthorizeSubscribecallback (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, oneevent.EventJSON perdata: ...\n\nframe.
:::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(aclient-assertedevent carrying a token inmetadatais 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:
200with the decoded token fields on success,4xxon 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.