Skip to content

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)
PurposeDeveloper integration — replay lifecycle events into your systems.Compliance viewer — the tamper-evident record of what happened.
OrderOldest-first (replay forward from a checkpoint).Newest-first.
RowsOnly user.* / client.* / federation.* / organization.* lifecycle families.Every audited action (backups, key events, retention purges, …).
ShapeCurated event object (id, event, createdAt, actor, target, data).Full audit row, including the hash-chain verification columns.
Scopetenant:events.readtenant: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).

  1. Fetch the first page. Omit cursor on 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 data array (oldest first) and a pagination object:

    {
      "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 }
    }
  2. Process each event, then advance. Handle every item in data in order. When pagination.cursor is 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"
  3. Reach the tail, then wait. When pagination.cursor is null and hasMore is false, you have consumed everything up to now. Keep your last non-null cursor (do not overwrite your checkpoint with null), pause a few seconds, and poll again with that same cursor. New events appear after it.

  4. 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 cursor checkpoint; start it empty.
  • Poll with ?cursor=<checkpoint> (omit the param when the checkpoint is empty).
  • Process every event in data, de-duplicating by id so your handlers are idempotent — re-reading the tail can re-deliver the last events you already saw.
  • If pagination.cursor is non-null, store it as the new checkpoint and poll again straight away (drain all pages).
  • If pagination.cursor is null, 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.

MethodPathDescriptionScope
GET/api/v1/eventsOne 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):

ParameterDescription
typeExact-match filter on the event type string (e.g. client.created). Omit for all families.
actorIdFilter 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.
organizationIdExact-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.
cursorOpaque cursor from a previous page's pagination.cursor. Omit for the first page. Never construct or edit it by hand.
limitPage size, clamped server-side to the range 1 to 500 (default 500).

The event object (data[] items and the GET /events/{id} body):

FieldTypeDescription
idstring (UUID)Stable event id. Use it to de-duplicate and as your checkpoint reference.
eventstringThe lifecycle event type as recorded (see families below).
createdAtstringRFC 3339 UTC instant the event occurred.
actorstring or nullThe acting subject (sub), or null for system-originated events.
targetstring or nullThe affected resource id (user / client / federation member), or null.
dataobject or nullThe 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:

FamilyCanonical 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.rotated is recorded as client.secret.rotated
  • federation.member.created is recorded as federation.member.registered
  • federation.member.deleted is recorded as federation.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

SymptomCauseFix
401Bearer token missing, expired, signed by an untrusted issuer, or missing the tnt claim.Re-authenticate with a token minted for your tenant.
403The token does not carry tenant:events.read.Grant the scope to the client (see below) and re-authenticate.
400 invalid_cursorThe 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 pollNo 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 nothingThe 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 eventsYour 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 tnt claim 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, never 403. 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. data is 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.read to a dedicated machine-to-machine client, not to an interactive admin client, and nothing more.