Skip to content

Receiving events

A client reads events with a read key: a list for history, a server-sent event stream for live events. The routes follow the Ambient protocol, version ambient/1; Pager is the reference client. Base URL: https://citadel.albastov.com/v1/events.

Note. The examples need a read key (abk_); the example checker skips them.

Read keys

The owner mints a read key in the admin panel with a name (adam-mac) and a grant: every source, or a list of sources. Every response and stream frame contains events of granted sources only. Dismissal state belongs to the read key: two clients with the same key share dismissals; two keys do not.

A publish key on a read route fails with 403.

GET /v1/events/info

Returns the key's kind and grant. Accepts a publish key or a read key. Call once when a connection is added.

Shell
curl https://citadel.albastov.com/v1/events/info -H "Authorization: Bearer $CITADEL_EVENTS_READ_KEY"
Response 200
{"protocol":"ambient/1","name":"Albastar Citadel","key":"reader","reader":"adam-mac","sources":["github","narkis"],"features":["batch","search"],"docs":"https://citadel.albastov.com/v1/events/docs"}
FieldValue
keyreader or publish
readerThe read key's name; null for a publish key
sourcesSlugs the key reaches
featuresbatch, search

GET /v1/events

Lists events, newest first. With after, lists oldest first (catch-up).

ParameterValue
sourceComma-separated slugs; default every granted source
typeExact type
severity_minLowest severity: debug … critical
qText search, at most 200 characters
afterCursor; events after it, oldest first
beforeCursor; events before it, newest first
limit1..500, default 100
stickyopen: sticky events this key has not dismissed
Response 200
{"events":[{"id":"evt_01JBX7Q2W8V5K3M9N4P6R2T8YC","cursor":"1734","source":"github","source_name":"GitHub","source_color":"#181717","source_icon":"github","type":"github.push","severity":"info","text":"site: 2 commits by Adam — Fix login redirect","detail":"1a2b3c4 Fix login redirect\n9f3c2e1 Add invoice export","subject":"site","url":"https://github.com/albastar/site/compare/1a2b3c4d5e6f...9f3c2e1aa0b1","occurred_at":"2026-10-31T10:01:00Z","received_at":"2026-10-31T10:01:02Z","display":null,"data":null,"dismissed_at":null}],"next_before":null,"latest_cursor":"1734"}

next_before is the cursor for the next older page; null when the page holds fewer than limit events, and with after. latest_cursor is the newest cursor among granted sources; pass the value as after or Last-Event-ID to resume.

GET /v1/events/sources

Returns the granted sources as an array:

Response 200
[{"slug":"github","name":"GitHub","color":"#181717","icon":"github","last_event_at":"2026-10-31T10:01:02Z","count_24h":14,"archived":false}]

GET /v1/events/stream

A text/event-stream of new events. Optional ?source=a,b.

ResumeBehaviour
No cursorLive events from now
Last-Event-ID: <cursor> or ?after=<cursor>Events after the cursor first (at most 1000), then live events
Frames
id: 1734
event: event
data: {…event…}

event: state
data: {"id":"evt_01JBX7Q2W8V5K3M9N4P6R2T8YC","dismissed_at":"2026-10-31T10:05:00Z"}

event: gap
data: {"after":"1200","skipped":2311,"resume":"3511"}

: keepalive
FrameMeaning
eventOne event; id is its cursor
stateThe read key dismissed (dismissed_at set) or restored (dismissed_at: null) an event
gapMore than 1000 events were pending: the skipped events after cursor after, up to and including cursor resume, were not sent; fetch them with GET /v1/events?after=<after>
: keepaliveComment every 20 s
Shell
curl -N https://citadel.albastov.com/v1/events/stream -H "Authorization: Bearer $CITADEL_EVENTS_READ_KEY"

Reconnecting

  1. On start, call GET /v1/events/info, then GET /v1/events?sticky=open for open sticky events, then GET /v1/events?limit=1 for latest_cursor.
  2. Open the stream with Last-Event-ID: <latest_cursor>.
  3. Store the id of every event frame.
  4. Reconnect when 60 s pass without a byte, or when the connection closes. Wait 1 s, then double the wait up to 30 s, with jitter; reset the wait after a successful connection.
  5. Resume with Last-Event-ID: <last stored id>. On a gap frame, fetch the skipped range with GET /v1/events.
  6. On 429 too_many_streams, close an unused stream of the same key, then retry.

POST /v1/events/{id}/dismiss and DELETE /v1/events/{id}/dismiss

POST sets this key's dismissal; DELETE clears it. Both are idempotent: a second POST keeps the first time. The response is the event with dismissed_at. Each change sends a state frame to every stream of the same key.

Shell
curl -X POST https://citadel.albastov.com/v1/events/evt_01JBX7Q2W8V5K3M9N4P6R2T8YC/dismiss \
  -H "Authorization: Bearer $CITADEL_EVENTS_READ_KEY"

Ambient protocol

Citadel implements ambient/1 from the Pager protocol. Paths are relative to the base URL https://citadel.albastov.com/v1/events.

ambient/1 routeCitadel routeKey
POST /POST /v1/eventsPublish
POST /batchPOST /v1/events/batchPublish
GET /GET /v1/eventsRead
GET /streamGET /v1/events/streamRead
POST /{id}/dismiss, DELETE /{id}/dismissPOST, DELETE /v1/events/{id}/dismissRead
GET /sourcesGET /v1/events/sourcesRead
GET /infoGET /v1/events/infoEither
GET /docsGET /v1/events/docsNone
  • Cursors are opaque strings, strictly increasing in hub order.
  • Times are RFC 3339 UTC. Optional fields are null.
  • Errors are JSON with code and message.
  • Readers give data.panel meaning: true, or an object with title, body, pinned, seconds (5..86400) and sound, raises a full Pager panel once per event.

Errors

StatusCodeCause
400invalid_fieldBad parameter; meta.field names it
400unknown_sourcesource names no source (keys granted every source)
401invalid_key, events_key_requiredMissing, unknown or revoked key
403scope_missing, key_unboundNot a read key
403source_not_grantedsource names a source outside the grant
404not_foundNo event with that id within the grant
429too_many_streamsThe key already holds 8 open streams
429rate_limitedOver the key's rate; see Retry-After