Product documentation
Consume the Events API
Poll GET /api/v1/events to replay your tenant's user, client, and federation lifecycle events forward from a cursor — the signature-free way to keep your own systems in sync. Covers auth and scope, the event object, cursor checkpointing for downtime-safe catch-up, type filtering, test vs live mode, and error handling — grounded in the shipped code.
Consume the Events API
The Events API is a read-only, pollable feed of your tenant's lifecycle
events — the user.*, client.*, federation.*, and organization.* things
that happened (a user was created, an OAuth client was rotated, a federation
member was deleted, an organization member was added or invited). Your
integration polls GET /api/v1/events, walks the events forward
from a stored cursor, and advances the cursor past the last one it handled. If
your consumer is down for an hour it simply resumes from its checkpoint — a
downtime-safe catch-up model, with no webhook receiver to run and no signature
to verify.
It is the poll-based, signature-free counterpart to push delivery. Use it when you want to pull events on your own schedule (a reconciliation job, a mirror of users into a downstream system, an ops dashboard). If instead you need Thoryn to call your endpoint and inject logic into the auth flow, that is a different surface — see Actions / Hooks.
Push-based tenant webhook delivery (Thoryn POSTing each event to an endpoint you register) is a separate, not-yet-shipped surface. Today, the way to consume lifecycle events is to poll the Events API described here.
How it differs from the audit trail
Thoryn also exposes a tenant audit trail at GET /api/v1/audit/events
(scope tenant:audit.read, see the Audit API reference).
Both read from the same underlying store, but they are different products:
Events API (/api/v1/events) | Audit trail (/api/v1/audit/events) | |
|---|---|---|
| Purpose | Developer integration — replay lifecycle events into your systems. | Compliance viewer — the tamper-evident record of what happened. |
| Order | Oldest-first (replay forward from a checkpoint). | Newest-first. |
| Rows | Only user.* / client.* / federation.* / organization.* lifecycle families. | Every audited action (backups, key events, retention purges, …). |
| Shape | Curated event object (id, event, createdAt, actor, target, data). | Full audit row, including the hash-chain verification columns. |
| Scope | tenant:events.read | tenant:audit.read |
Pick the Events API to integrate; pick the audit trail to prove.
For end users — poll the feed
The API is served by the customer-plane product-api behind the public gateway
at https://api.<env>.thoryn.org (staging: https://api.stg.thoryn.org). Every
call carries a bearer token; the tenant is taken from the token's tnt claim, so
you only ever see your own tenant's events. Because this is a machine-to-machine
integration, obtain the token with a dedicated OAuth client that holds the
tenant:events.read scope (see Integrate an application
for registering a client, and Enabling the scope below).
-
Fetch the first page. Omit
cursoron the very first call.curl -s "https://api.stg.thoryn.org/api/v1/events?limit=100" \ -H "Authorization: Bearer $TOKEN"The response is the standard collection envelope — a
dataarray (oldest first) and apaginationobject:{ "data": [ { "id": "0f9c7c2e-1d3a-4a1e-9b2c-6b4f8e2a1d55", "event": "client.created", "createdAt": "2026-07-24T10:12:03.456Z", "actor": "admin-4f2a…", "target": "app-checkout-prod", "data": { "name": "Checkout (prod)" } } ], "pagination": { "cursor": "eyJ2Ijoi…", "hasMore": true } } -
Process each event, then advance. Handle every item in
datain order. Whenpagination.cursoris non-null there are more pages — save it as your checkpoint and fetch the next page immediately:curl -s "https://api.stg.thoryn.org/api/v1/events?limit=100&cursor=eyJ2Ijoi…" \ -H "Authorization: Bearer $TOKEN" -
Reach the tail, then wait. When
pagination.cursorisnullandhasMoreisfalse, you have consumed everything up to now. Keep your last non-null cursor (do not overwrite your checkpoint withnull), pause a few seconds, and poll again with that same cursor. New events appear after it. -
Fetch one event by id (for example to re-inspect a payload):
curl -s "https://api.stg.thoryn.org/api/v1/events/0f9c7c2e-1d3a-4a1e-9b2c-6b4f8e2a1d55" \ -H "Authorization: Bearer $TOKEN"
The checkpoint loop, precisely
The cursor is only minted when there is a further page (hasMore: true). On
the final page the cursor is null. So a naive "always overwrite my checkpoint
with pagination.cursor" loop would discard the checkpoint at the tail and
re-read the whole feed on the next poll. The correct loop:
- Persist a single
cursorcheckpoint; start it empty. - Poll with
?cursor=<checkpoint>(omit the param when the checkpoint is empty). - Process every event in
data, de-duplicating byidso your handlers are idempotent — re-reading the tail can re-deliver the last events you already saw. - If
pagination.cursoris non-null, store it as the new checkpoint and poll again straight away (drain all pages). - If
pagination.cursorisnull, you are caught up: keep the current checkpoint, sleep, and poll again.
Because delivery is at-least-once at the tail, idempotent, id-keyed handling is required, not optional.
Endpoint reference
The generated request/response schema is on the Events API reference page.
| Method | Path | Description | Scope |
|---|---|---|---|
GET | /api/v1/events | One oldest-first, cursor-paginated page of lifecycle events. | tenant:events.read |
GET | /api/v1/events/{id} | A single lifecycle event by id. 404 if it does not exist for your tenant and mode. | tenant:events.read |
GET /api/v1/events query parameters (all optional):
| Parameter | Description |
|---|---|
type | Exact-match filter on the event type string (e.g. client.created). Omit for all families. |
actorId | Filter to one user's timeline. Pass user:{identityUserId} (or a bare platform sub) to list just that user's activity. Matches all of a user's actor keys, not one literal value: passing user:{id} resolves the user's login principal(s) tenant-scoped and matches user:{id}, the bare id, and the email together — so interactive login rows (which key the actor to the email) appear in the timeline, not only the id-keyed lifecycle rows. A per-user filter also widens the surfaced families to the user's authentication activity — login.*, magic-link.*, password.reset.* — on top of the four lifecycle families (see below). Those auth families appear only under an actorId filter, never on the unfiltered feed. |
organizationId | Exact-match filter on the affected resource (the target field). Pass an organization id to list that org's lifecycle timeline — the organization.* events (org create/update/delete, member add/remove/role-change, and the org-invitation lifecycle) all carry the org id as target. audit_events has no dedicated organization column, so the org dimension rides target; an unknown org simply returns an empty page. |
cursor | Opaque cursor from a previous page's pagination.cursor. Omit for the first page. Never construct or edit it by hand. |
limit | Page size, clamped server-side to the range 1 to 500 (default 500). |
The event object (data[] items and the GET /events/{id} body):
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Stable event id. Use it to de-duplicate and as your checkpoint reference. |
event | string | The lifecycle event type as recorded (see families below). |
createdAt | string | RFC 3339 UTC instant the event occurred. |
actor | string or null | The acting subject (sub), or null for system-originated events. |
target | string or null | The affected resource id (user / client / federation member), or null. |
data | object or null | The sanitised event payload, or null when the row carries none. |
Event families and the v1 catalog
Four lifecycle families surface on this API. The v1 catalog of canonical event ids:
| Family | Canonical v1 event ids |
|---|---|
user.* | user.created, user.updated, user.deleted |
client.* | client.created, client.updated, client.deleted, client.rotated |
federation.* | federation.member.created, federation.member.updated, federation.member.deleted |
organization.* | organization.created, organization.updated, organization.deleted, organization.member.added, organization.member.removed, organization.member.status_changed, organization.member.role_changed, organization.member.invited, organization.member.invite_revoked, organization.member.invite_accepted |
The catalog is versioned: adding an event is additive; renaming or removing one is a breaking change that ships under a new version, never an in-place edit.
Which families surface on the Events API is decided by the lifecycle allowlist in
AuditEventRepository (user.* / client.* / federation.* / organization.*).
The curated TenantEventCatalog in core/lib/common is the canonical contract for
the separate push-based tenant webhooks surface (not yet shipped); its v1 scope
is the user.* / client.* / federation.member.* core, and the organization.*
family will be added there when webhooks ship.
Per-user timeline — authentication activity too. When you filter by a specific
user (?actorId=user:{id}), the response additionally includes that user's
authentication events — login.* (login.success / login.failure),
magic-link.*, and password.reset.* — so the tab shows a user's complete history.
These families are deliberately absent from the unfiltered general feed (its contract
is the four lifecycle families only); they surface only for a per-user actorId query.
The organization.* family carries the org id as target
Every organization.* event records the organization id as its target
(never the member subject or invitee email — those ride the sanitised data
payload). That is what makes ?organizationId=<orgId> return an org's whole
lifecycle timeline in one filtered call: the filter matches target. The acting
admin is the actor; for organization.member.invite_accepted the actor is the
accepting user, and the row is filed on the invitation's own tenant.
Filtering by type — exact match, and a legacy-alias caveat
?type= is an exact string match on the event value, not a prefix match.
The event field carries the type as it was recorded on the underlying row,
and a few producers still emit a legacy alias that the v1 catalog reconciles to
the canonical id but which is not rewritten on the stored row:
client.rotatedis recorded asclient.secret.rotatedfederation.member.createdis recorded asfederation.member.registeredfederation.member.deletedis recorded asfederation.member.removed
So ?type=client.rotated currently returns nothing — the stored string is
client.secret.rotated. For robust filtering, either match the exact string you
observe in the feed, or (more reliably) fetch the family unfiltered and filter by
the client. / user. / federation. prefix in your own code. The user.*
events are recorded under their canonical ids.
Test vs live mode
The Events API is mode-scoped. Your bearer token carries a mode claim
(live or test); a live token sees only live events and a test token sees only
test events — the two datasets never mix. A token with no mode claim defaults to
live. This means an event id fetched in one mode returns 404 in the other
(see the troubleshooting table). Use a test-mode credential to exercise the feed
against sandbox data before pointing your integration at live events.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
401 | Bearer token missing, expired, signed by an untrusted issuer, or missing the tnt claim. | Re-authenticate with a token minted for your tenant. |
403 | The token does not carry tenant:events.read. | Grant the scope to the client (see below) and re-authenticate. |
400 invalid_cursor | The cursor was truncated, hand-edited, or minted for a different tenant or a different list endpoint. | Drop the cursor and re-poll from your last known-good checkpoint (or from the start). Never build a cursor yourself. |
404 on GET /events/{id} | The event does not exist for your tenant, is in the other mode (test vs live), or is not a lifecycle event. | Confirm the id came from your own GET /api/v1/events in the same mode. |
Empty data, hasMore: false on the first poll | No lifecycle events exist yet in this mode. | Create a user / client / federation member, then poll again. |
?type=client.rotated (or a federation.member.* id) returns nothing | The stored type is a legacy alias. | Match the exact recorded string (client.secret.rotated, federation.member.registered, federation.member.removed), or filter by prefix client-side. See the filtering note above. |
| The feed keeps re-delivering the last few events | Your checkpoint was overwritten with the tail's null cursor, or your handlers are not id-idempotent. | Keep the last non-null cursor at the tail; de-duplicate by id. |
Enabling the scope
tenant:events.read is granted to the customer-plane clients (console BFF,
thoryn CLI, and the demo client) by hub migration
V95__add_events_read_scope_to_customer_plane_clients.sql. To poll from your own
machine client, a tenant admin grants tenant:events.read to that client through
the console's application scope selector (you can only delegate a tenant:* scope
you hold yourself). A client must carry the scope for the hub to mint it into the
token; requesting an un-granted scope fails sign-in with invalid_scope.
Security notes
- Read-only and tenant-scoped by construction. The tenant comes from the
tntclaim and is ANDed into every query; there is no cross-tenant identifier to supply. A foreign tenant's event is indistinguishable from "no such event". - Cross-tenant and cross-mode access is
404, never403. The API never confirms that an id it will not return exists — no existence leak across the tenant or the test/live boundary. - Payloads are pre-sanitised, but they are still tenant data.
datais safe to surface verbatim (the emitters sanitise it at write time), yet it can carry user identifiers — store and transmit it as personal data. - There is no per-item signature. You trust the authenticated TLS channel to the gateway, not a signature on each event. If you need signed push delivery into your own endpoint, use Actions / Hooks instead.
- Grant least privilege. Give
tenant:events.readto a dedicated machine-to-machine client, not to an interactive admin client, and nothing more.