Skip to content

Sources and publishing

An app publishes events into one source with a publish key. Base URL: https://citadel.albastov.com/v1/events.

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

Adding a source

  1. In the admin panel, open Events and create a source: slug ([a-z0-9-]{2,48}), name, icon, color, retention (days, default 90).
  2. Mint a publish key for the source. The admin panel shows the abk_ secret once, with a brief for the app's developer or coding agent.
  3. Store the key on the app's server as CITADEL_EVENTS_KEY. Never ship the key in a browser bundle, a mobile app, a repository or a log line.

A source takes events from publish keys, from a webhook, or both. Archiving a source stops publishing and webhooks into it; the history stays readable until retention removes it.

Source display

FieldValue
nameThe service or product as readers know it: Stripe, GitHub, Narkis. At most 80 characters
iconA Simple Icons slug (stripe) or an SF Symbol name (shippingbox.fill); [a-z0-9.-]{1,48}; empty for a monogram
colorBrand color: #rgb, #rrggbb or #rrggbbaa

Put the sub-system in each event's subject (narkis.ai, api · production), not in the source name. Use one source per service a reader recognises.

PATCH /v1/events/sources/self

Sets the display fields of the publish key's own source. Omitted fields keep their values; "" clears icon or color; {} returns the current values. Changes apply to the source's whole history and are audited.

Shell
curl -X PATCH https://citadel.albastov.com/v1/events/sources/self \
  -H "Authorization: Bearer $CITADEL_EVENTS_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Sentry","icon":"sentry","color":"#362d59"}'
Response 200
{"slug":"sentry","name":"Sentry","icon":"sentry","color":"#362d59"}

Publishing

POST /v1/events

Publishes one event into the key's source. Citadel stores the event before responding.

FieldRequiredValue
typeYes[a-z0-9._-]{1,48}, dotted nouns: deploy.succeeded, pr.merged, site.down
textYesOne line, at most 300 characters; inline **bold**, *italic*, `code`
severityNodebug, info, notice, warn, error, critical; default info
detailNoPlain text, at most 1000 characters, newlines allowed
subjectNoOne line, at most 80 characters
urlNoAbsolute https:// URL, at most 2048 characters
occurred_atNoRFC 3339; default now; clamped to 7 days either side of receipt
displayNoObject; keys below
dataNoJSON object, at most 8 KB
idempotency_keyNoAt most 128 characters
sourceNoMust equal the key's source slug
display keyValue
lifetimeSeconds 1..86400, or "sticky" (until dismissed)
layoutsplit or inline
effectnone, blink, flash, highlight, wave, gradient
targetemphasis (the bold spans) or message (the whole line)
periodEffect cycle in seconds, 0.5..10
colorAccent for emphasized spans: #rgb, #rrggbb, #rrggbbaa

Citadel refuses unknown fields and unknown display keys or values with 400, naming the field in meta.field.

StatusBody
201The stored event
200The original event: the source used idempotency_key in the last 7 days

curl

curl https://citadel.albastov.com/v1/events \
  -H "Authorization: Bearer $CITADEL_EVENTS_KEY" \
  -H "Content-Type: application/json" \
  --max-time 5 \
  -d '{"type":"deploy.failed","severity":"error","text":"Deploy **failed** · web `9f3c2e1`","detail":"npm run build exited 1","display":{"lifetime":"sticky"},"idempotency_key":"deploy-9f3c2e1"}'

Python

import json, os, urllib.request

req = urllib.request.Request(
    "https://citadel.albastov.com/v1/events",
    data=json.dumps({"type": "pr.merged", "text": "PR **#128** merged · Add invoice export"}).encode(),
    headers={"Authorization": f"Bearer {os.environ['CITADEL_EVENTS_KEY']}", "Content-Type": "application/json"},
    method="POST",
)
print(urllib.request.urlopen(req, timeout=5).status)

JavaScript

const res = await fetch("https://citadel.albastov.com/v1/events", {
  method: "POST",
  headers: { authorization: `Bearer ${process.env.CITADEL_EVENTS_KEY}`, "content-type": "application/json" },
  body: JSON.stringify({ type: "backup.done", text: "Backup **done** · 412 MB" }),
  signal: AbortSignal.timeout(5000),
});
console.log(res.status);

POST /v1/events/batch

Publishes 1 to 100 events in one request: {"events":[{...},{...}]}, each with the fields above. One invalid event refuses the whole batch; meta.index names the event. The response is {"events":[...]} in request order, with status 201 when at least one event is new, else 200.

Shell
curl https://citadel.albastov.com/v1/events/batch \
  -H "Authorization: Bearer $CITADEL_EVENTS_KEY" \
  -H "Content-Type: application/json" \
  -d '{"events":[{"type":"job.done","text":"Job **export** done"},{"type":"job.done","text":"Job **import** done"}]}'

Dedupe

An idempotency_key used again on the same source within 7 days returns the original event with 200 and stores nothing. Set idempotency_key on every event the app retries: deploy-<sha>, backup-2026-10-31.

Retries

Publish outside the request path with a 5 s timeout. A failed publish must not block, slow or fail the app.

ResponseAction
Network error, 5xxRetry at most 3 times after 1 s, 4 s and 15 s, plus jitter; then drop the event and log one line
429Wait Retry-After seconds, then retry within the same 3 attempts
400, 401, 403, 404, 413Do not retry; log message

Errors

Every error is JSON with code and message:

JSON
{"ok":false,"error":"invalid-field","code":"invalid_field","message":"text is 312 characters; at most 300","meta":{"field":"text"}}
StatusCodeCause
400invalid_field, unknown_field, bad_jsonInvalid body; meta.field names the field, meta.index the batch item
401invalid_key, publish_key_requiredMissing, unknown or revoked key; a read key on a publish route
403scope_missing, key_unboundThe key is not a publish key
403source_mismatchsource names another source
403source_archivedThe key's source is archived
413body_too_largeBody over 16 KB; batch over 256 KB
429rate_limitedOver the key's rate; Retry-After gives the seconds to wait

Limits

LimitValue
Requests, per publish key120 a minute, bursts of 30
Events, per publish key300 a minute; each event of a batch counts
Body16 KB; batch 256 KB, at most 100 events
idempotency_key window7 days

Events from publish keys are stored and streamed one by one. The noise budget, which turns more than 5 events a minute into one summary event, applies to webhooks and Citadel's own sources only.