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
- In the admin panel, open Events and create a source: slug (
[a-z0-9-]{2,48}), name, icon, color, retention (days, default 90). - 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. - 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
| Field | Value |
|---|---|
name | The service or product as readers know it: Stripe, GitHub, Narkis. At most 80 characters |
icon | A Simple Icons slug (stripe) or an SF Symbol name (shippingbox.fill); [a-z0-9.-]{1,48}; empty for a monogram |
color | Brand 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.
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"}'{"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.
| Field | Required | Value |
|---|---|---|
type | Yes | [a-z0-9._-]{1,48}, dotted nouns: deploy.succeeded, pr.merged, site.down |
text | Yes | One line, at most 300 characters; inline **bold**, *italic*, `code` |
severity | No | debug, info, notice, warn, error, critical; default info |
detail | No | Plain text, at most 1000 characters, newlines allowed |
subject | No | One line, at most 80 characters |
url | No | Absolute https:// URL, at most 2048 characters |
occurred_at | No | RFC 3339; default now; clamped to 7 days either side of receipt |
display | No | Object; keys below |
data | No | JSON object, at most 8 KB |
idempotency_key | No | At most 128 characters |
source | No | Must equal the key's source slug |
display key | Value |
|---|---|
lifetime | Seconds 1..86400, or "sticky" (until dismissed) |
layout | split or inline |
effect | none, blink, flash, highlight, wave, gradient |
target | emphasis (the bold spans) or message (the whole line) |
period | Effect cycle in seconds, 0.5..10 |
color | Accent for emphasized spans: #rgb, #rrggbb, #rrggbbaa |
Citadel refuses unknown fields and unknown display keys or values with 400, naming the field in meta.field.
| Status | Body |
|---|---|
201 | The stored event |
200 | The 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.
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.
| Response | Action |
|---|---|
Network error, 5xx | Retry at most 3 times after 1 s, 4 s and 15 s, plus jitter; then drop the event and log one line |
429 | Wait Retry-After seconds, then retry within the same 3 attempts |
400, 401, 403, 404, 413 | Do not retry; log message |
Errors
Every error is JSON with code and message:
{"ok":false,"error":"invalid-field","code":"invalid_field","message":"text is 312 characters; at most 300","meta":{"field":"text"}}| Status | Code | Cause |
|---|---|---|
400 | invalid_field, unknown_field, bad_json | Invalid body; meta.field names the field, meta.index the batch item |
401 | invalid_key, publish_key_required | Missing, unknown or revoked key; a read key on a publish route |
403 | scope_missing, key_unbound | The key is not a publish key |
403 | source_mismatch | source names another source |
403 | source_archived | The key's source is archived |
413 | body_too_large | Body over 16 KB; batch over 256 KB |
429 | rate_limited | Over the key's rate; Retry-After gives the seconds to wait |
Limits
| Limit | Value |
|---|---|
| Requests, per publish key | 120 a minute, bursts of 30 |
| Events, per publish key | 300 a minute; each event of a batch counts |
| Body | 16 KB; batch 256 KB, at most 100 events |
idempotency_key window | 7 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.