A webhook turns HTTP requests from another app (a CI, a monitor, a shop) into events of one source, without a publish key and without code. A mapping, a JSON object stored on the source, reads each request and names the event fields. GitHub, Coolify, Stripe, HOSTKEY and remote uptime checkers use the same receiver with seeded mappings.
New source: send a test, review, activate
A new webhook starts pending: Citadel keeps its deliveries and publishes no event until the webhook is activated.
Readers and Pager see nothing from a pending webhook.
- In the admin panel, open Events, select the source, and turn on Webhook. The admin panel shows the URL with its token once.
- Paste the URL into the sending app and send one test delivery from the app. Citadel keeps the delivery and
answers
held: pending review. - Write the mapping in the admin panel and run Preview over the kept deliveries. Save.
- Optional: set a signature and mark it required; set the allowed URL hosts.
- Activate. With Publish the kept deliveries, the held deliveries run through the saved mapping, oldest first,
under the noise budget and the duplicate rules. Without it, each held delivery's outcome becomes
dropped: not published at activation.
| State | Deliveries | Events |
|---|---|---|
pending | Kept and answered held: pending review; signature, body size and rate limits apply | None |
active | Kept with their outcome | Published |
disabled | Refused with 404 hook_not_found | None |
Built-in webhooks start active. Turning a webhook off makes the webhook disabled; turning the webhook on again
restores pending or active. The admin panel shows each source's held deliveries.
The receiver
POST /v1/events/hooks/in/{token}
The token is 32 random bytes, base64url (43 characters). Citadel stores the token's SHA-256 only; the admin panel shows the URL once, then the last 4 characters. Rotate issues a new token and invalidates the old one at once.
Every delivery takes the same steps:
- Find the webhook by token. Refuse unknown tokens, turned-off webhooks and archived sources.
- Check the signature when one is set (see Signatures).
- Pending webhook: keep the delivery, answer
held: pending review, stop. - Parse the body.
- Run the source's mapping: zero or more events.
- Drop the delivery when the delivery repeats one (see Duplicates).
- Publish each event: field validation, URL host filter,
idempotency_keydedupe, noise budget, rate limit. - Keep the delivery with its outcome.
{"ok":true,"outcome":"2 events: evt_01JBX7Q2W8V5K3M9N4P6R2T8YC,dropped: rule 1,evt_01JBX7Q2W8V5K3M9N4P6R2T8YD","events":["evt_01JBX7Q2W8V5K3M9N4P6R2T8YC","evt_01JBX7Q2W8V5K3M9N4P6R2T8YD"]}outcome | Meaning |
|---|---|
evt_… | One event, stored |
held | One event, held by the noise budget; part of the next summary event |
dropped: <reason> | No event: a drop rule or case matched (rule 1, case 3, or the rule's reason) |
dropped: duplicate of delivery <id> | No event: the same body arrived within 10 minutes; see Duplicates |
held: pending review | No event: the webhook is pending; the delivery is kept for review |
3 events: evt_a,held,dropped: rule 1 | One entry per item of a split |
…; url dropped: host <host> not allowed | A mapped url outside the allowed hosts was removed |
…; 12 items over 1000 dropped | A split array was longer than 1000 |
…; mapping error: <message> | The mapping failed; the delivery made the fallback event |
Citadel keeps the newest 200 deliveries per source with their outcome, bodies cut at 256 KiB, for Preview and Replay.
Request parsing
| Request | The mapping reads |
|---|---|
Content-Type: application/json, …+json, or any body that parses as JSON | The JSON value |
Content-Type: application/x-www-form-urlencoded | An object of strings; a repeated key becomes an array |
Empty body (a GET or an empty POST) | The query string, read as a form |
| Any other body | {"body": "<the body as text>"} |
$headers | Request headers, names lowercased, one string each (repeats joined by , ) |
$query | The URL query, read as a form |
$headers leaves out Authorization, Proxy-Authorization, Cookie, the signature header and forwarding headers
(CF-Connecting-IP, X-Forwarded-For, X-Real-IP, True-Client-IP).
Duplicates
A sender may deliver one request twice: a retry after a timeout, or a manual resend. Citadel dedupes each delivery on the first key available:
| Key | Taken from | Dedupe |
|---|---|---|
idempotency | The mapping's idempotency value, per event | An event with a key stored in the last 7 days returns the stored event |
| Delivery header | The first of X-GitHub-Delivery, Idempotency-Key, Webhook-Id, X-Webhook-Id, X-Delivery-Id, as <header>:<value> (plus :<item> for a split); set on events without idempotency | As idempotency |
| Body | SHA-256 of the body as received, after the signature check | dropped: duplicate of delivery <id> when a kept delivery of the source had the same body in the last 10 minutes |
The body key applies only to a delivery with an event that has neither idempotency nor a delivery header. The 10
minutes count from the original delivery, not from its duplicates: a body resent every 5 minutes makes one event
every 10 minutes, and a monitor's identical down body on the next day makes a new event. "dedupe_body": false in
the mapping turns the body key off; the seeded uptime mapping sets false, because a remote checker sends the
same report every minute.
Mapping reference
Every key is optional. No mapping, or {}, makes one event per delivery: type webhook, severity info, text a
summary of the body.
Keys
| Key | Value | Result |
|---|---|---|
type | Value | Event type: lowercased; each run of characters outside [a-z0-9_-] becomes .; cut to 48; empty: webhook |
text | Value | One line, cut to 300 characters; empty: a summary of the item, at most 200 characters |
detail | Value | Newlines kept, cut to 1000 characters |
subject | Value | One line, cut to 80 characters |
url | Value | Kept when an absolute https:// URL of at most 2048 characters |
severity | Template, or {"rules":[{"when","then"}],"default"} | A severity name; anything else: info |
sticky | Condition, or true/false | display.lifetime: "sticky" |
lifetime | Seconds 1..86400 | display.lifetime |
idempotency | Value | idempotency_key; over 128 characters: sha256:<hex> of the value |
occurred_at | Value | RFC 3339, or Unix seconds (milliseconds above 10^12); anything else: time of receipt |
data | Object of key: path, template or value | Event data; at most 20 keys |
split | Path of an array | One item per element; an empty array makes no event (dropped: split: 0 items) |
drop | Array of conditions or {"when","reason"} | An item matching a rule makes no event |
cases | Array of {"when", ...fields} or {"when","drop"} | The first matching case overrides fields or drops the item |
vars | Object of name: value | Named values per item, read as $vars.<name> |
kind | Value | The delivery's label in the kept list; default the first event's type |
delivery | Value | The delivery's id in the kept list; default the first event's idempotency |
collapse | true (default) or false | false takes the source's events out of the noise budget |
dedupe_body | true (default) or false | false publishes a delivery whose body repeats within 10 minutes; see Duplicates |
redact | RE2 regular expression | Values of matching key names become [redacted] before mapping and keeping |
enrich | Array of enricher names | Adds $enrich.<name> before mapping |
A value is a template string or a composite value. Severity names: debug, info, notice, warn, error,
critical. Aliases, case-insensitive: warning, err, fatal, information.
Paths
| Path | Reads |
|---|---|
repo.name | Key name of key repo of the current item |
commits.0.id, commits.-1.id | Array elements by index; negative counts from the end |
$root.monitor | The whole body, also inside split |
$headers.x-github-event | A request header |
$query.env | A query parameter |
$vars.who | A value of vars |
$enrich.coolify.deployment.commit | A value added by an enricher |
A key that contains . is not reachable.
Templates
{{path}} writes the value at path: strings as they are, numbers and booleans plain, objects and arrays as compact
JSON. {{path|fallback}} writes fallback when the value is missing, null or "". {{path|filter|filter:arg}}
runs filters left to right; a segment that is not a filter name starts the fallback. A template without {{ is a
constant.
| Filter | Result |
|---|---|
short | First 7 characters: `{{sha |
firstline | First line, trimmed |
oneline | Newlines and runs of spaces become one space |
lower, upper | Case |
trim | Leading and trailing white space removed |
plain | * and ` removed |
max:<n> | Cut to n characters (1..10000), ending in … |
prefix:<text>, suffix:<text> | text added when the value is not empty |
or:<path> | The value at path when this value is empty |
count | Number of elements of an array or keys of an object |
money:<path> | Minor units (cents) in the currency at path: `{{amount |
match:<regex> | First capture group of an RE2 match, else the whole match, else empty |
replace:<old>:<new> | Every old replaced by new |
money writes whole amounts without decimals (€29) and zero-decimal currencies (jpy, krw, and 14 more) in
whole units.
Composite values
| Value | Result |
|---|---|
{"join": sep, "parts": [v, …]} | The non-empty parts, joined by sep |
{"each": path, "value": v, "join": sep} | v for each element of an array, or each {key, value} entry of an object, joined by sep |
{"each": …, "where": condition} | Elements for which the condition holds |
{"each": …, "limit": n} | At most n elements (1..1000) |
{"each": …, "sort": true} | Entries by key, elements by their text |
{"if": condition, "then": v, "else": v} | then when the condition holds, else else (default empty) |
{"first": [v, …]} | The first non-empty option |
Inside each, paths read the element; $root reads the body.
Conditions
{"path": p, <operator>: value}, or a combination:
| Operator | True when |
|---|---|
eq | The value equals a string, number or bool, compared as text, case-insensitive; "eq": null matches missing or null |
ne | eq is false (true for a missing value) |
in | The value eqs an element of the array |
contains | The text contains the string, case-insensitive; for an array: an element eqs it |
matches | The text matches the RE2 expression (case-sensitive; (?i) ignores case) |
exists | true: present and not null; false: missing or null |
gt, lt | The number, or numeric string, is greater or less |
{"all": [c, …]} | Every condition holds |
{"any": [c, …]} | At least one condition holds |
{"not": c} | The condition does not hold |
eq, ne, contains, gt and lt also compare with another value: {"path": "ref", "ne": {"template": "refs/heads/{{repository.default_branch}}"}} or {"path": "paid", "lt": {"path": "total"}}. A missing value on
either side compares as "".
Drop rules and cases
drop runs first: an item that matches any rule makes no event, with outcome dropped: rule N or
dropped: <reason> for {"when": condition, "reason": template}.
cases run next, in order. The first case whose when holds (a case without when always holds) applies:
- A case with
dropdrops the item; the outcome isdropped: <drop>, ordropped: case Nwhendroprenders empty. - Any other case overrides the mapping's fields with its own:
type,text,detail,subject,url,severity,sticky,lifetime,idempotency,occurred_at. Casedatakeys replace or add to the mapping'sdatakeys.
Fields outside every case apply to every item.
data
| Entry | Result |
|---|---|
"commit": "build.commit" | The value at the path, with its JSON type; left out when missing |
"label": "#{{id}} {{status}}" | The rendered text; left out when empty |
"*": "meta" | Every non-null entry of the object at the path |
A value over 300 bytes becomes a string cut to 300 characters.
Enrichers
An enricher adds data that the request does not carry. A mapping opts in with "enrich": ["<name>"].
| Enricher | Adds | Behaviour |
|---|---|---|
coolify.deployment | $enrich.coolify.deployment.commit, .message | For event: deployment_success with deployment_uuid: the deployment's commit, read from Citadel's Coolify server; empty when the lookup fails |
uptime.report | $enrich.uptime.report.events, .counted | Runs a remote checker's report through Citadel's uptime state machine; events holds the state changes; a malformed report is refused with 400 bad_delivery |
Preview and Replay run enrichers without changing uptime state.
Validation and limits
Saving a mapping with an unknown key, a bad path, template, filter, regular expression, severity name or
condition fails with 400 invalid_field; meta.field names the location, for example
mapping.cases[2].severity.rules[0].when.
| Limit | Value |
|---|---|
| Mapping | 32 KB |
Rules (drop, cases, severity rules together) | 50 |
| Condition and value nesting | 8 |
| Regular expression | 512 characters |
split items | 1000 |
data keys, vars names | 20 each |
A mapping that fails at run time (a split path that is missing or not an array) makes the fallback event, and the
outcome carries the error. Citadel never loses a delivery to its mapping.
Examples
A CI webhook with severity rules
{
"repository": {"name": "narkis"},
"build": {"id": 4812, "number": 312, "status": "failed", "branch": "main", "duration_s": 340,
"message": "go test ./... failed: TestCheckout", "url": "https://ci.example.com/narkis/312",
"commit": "a1b2c3d", "finished_at": "2026-10-15T10:02:03Z"}
}{
"drop": [{"path": "build.branch", "matches": "^dependabot/"}],
"type": "ci.{{build.status}}",
"text": "**{{repository.name}}** build #{{build.number}} {{build.status}}",
"detail": "{{build.message}}",
"subject": "{{repository.name}} · {{build.branch}}",
"url": "{{build.url}}",
"severity": {"rules": [
{"when": {"path": "build.status", "in": ["failed", "errored"]}, "then": "error"},
{"when": {"path": "build.duration_s", "gt": 900}, "then": "warning"}],
"default": "info"},
"sticky": {"path": "build.status", "eq": "failed"},
"idempotency": "ci-{{build.id}}",
"occurred_at": "{{build.finished_at}}",
"data": {"commit": "build.commit"}
}[
{"type": "ci.failed", "severity": "error", "text": "**narkis** build #312 failed",
"detail": "go test ./... failed: TestCheckout", "subject": "narkis · main",
"url": "https://ci.example.com/narkis/312", "sticky": true, "idempotency_key": "ci-4812",
"occurred_at": "2026-10-15T10:02:03Z", "data": {"commit": "a1b2c3d"}}
]A batch with split
{"monitor": "homelab", "sent_at": 1792058523, "alerts": [
{"id": "a-91", "check": "disk /data", "state": "WARNING", "value": "87%", "host": "web-2"},
{"id": "a-92", "check": "backup", "state": "OK", "host": "web-2", "test": true},
{"id": "a-93", "check": "postgres", "state": "CRITICAL", "host": "db-1"}
]}One event per alert, test alerts dropped, the severity read from the alert's state (WARNING is an alias of
warn; OK is no severity name, so info):
{
"split": "alerts",
"drop": [{"when": {"path": "test", "eq": true}, "reason": "test alert {{id}}"}],
"type": "{{$root.monitor}}.{{check}}",
"text": "{{host}}: {{check}} {{state}} {{value}}",
"subject": "{{host}}",
"severity": "{{state}}",
"sticky": {"path": "state", "eq": "critical"},
"idempotency": "{{id}}",
"occurred_at": "{{$root.sent_at}}"
}[
{"type": "homelab.disk.data", "severity": "warn", "text": "web-2: disk /data WARNING 87%", "subject": "web-2",
"sticky": false, "idempotency_key": "a-91", "occurred_at": "2026-10-15T10:02:03Z"},
{"dropped": "test alert a-92"},
{"type": "homelab.postgres", "severity": "critical", "text": "db-1: postgres CRITICAL", "subject": "db-1",
"sticky": true, "idempotency_key": "a-93", "occurred_at": "2026-10-15T10:02:03Z"}
]The outcome reads 2 events: evt_…,dropped: test alert a-92,evt_….
GitHub pushes (from the seeded mapping)
An excerpt of the github source's default mapping: the push events. The full default mapping also covers pull
requests, failed workflow runs and releases.
{"x-github-event": "push", "x-github-delivery": "6f1e7a20-b5d1-11ef-8f2a-1c3e2d9a7b01"}{
"ref": "refs/heads/main",
"compare": "https://github.com/albastar/site/compare/1a2b3c4d5e6f...9f3c2e1aa0b1",
"repository": {"name": "site", "default_branch": "main"},
"pusher": {"name": "Adam"},
"sender": {"login": "adam", "type": "User"},
"commits": [
{"id": "1a2b3c4d5e6f", "message": "Fix login redirect\n\nThe session cookie was not sent.", "timestamp": "2026-10-31T09:58:00Z"},
{"id": "9f3c2e1aa0b1", "message": "Add invoice export", "timestamp": "2026-10-31T10:01:00Z"}
]
}{
"kind": "{{$headers.x-github-event}}",
"delivery": "{{$headers.x-github-delivery}}",
"drop": [{"when": {"path": "$headers.x-github-event", "eq": "ping"}, "reason": "ping"}],
"severity": "info",
"subject": "{{repository.name}}",
"idempotency": "{{$headers.x-github-delivery|prefix:github:}}",
"cases": [
{"when": {"all": [{"path": "$headers.x-github-event", "eq": "push"},
{"path": "ref", "ne": {"template": "refs/heads/{{repository.default_branch}}"}}]},
"drop": "not the default branch"},
{"when": {"path": "$headers.x-github-event", "eq": "push"},
"type": "github.push",
"url": "{{compare}}",
"text": {"join": "", "parts": [
"{{repository.name}}: {{commits|count}} ",
{"if": {"path": "commits.1", "exists": true}, "then": "commits", "else": "commit"},
" by {{pusher.name|or:sender.login}} — {{commits.0.message|firstline}}"]},
"detail": {"each": "commits", "value": "{{id|short}} {{message|firstline}}", "join": "\n"},
"occurred_at": "{{commits.-1.timestamp}}"},
{"drop": "event {{$headers.x-github-event}} not mapped"}
]
}[
{"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", "sticky": false,
"idempotency_key": "github:6f1e7a20-b5d1-11ef-8f2a-1c3e2d9a7b01", "occurred_at": "2026-10-31T10:01:00Z"}
]idempotency uses GitHub's X-GitHub-Delivery, so a redelivery of the same delivery returns the stored event.
Preview and replay
- Preview runs an unsaved mapping over the source's kept deliveries, newest first (20 by default, at most 50), and shows per delivery the events, dropped items and errors. Preview stores and publishes nothing.
- Replay runs the saved mapping over one kept delivery and publishes the events, outside the noise budget.
idempotencykeys, delivery headers and the body key still dedupe. A delivery whose kept body was cut at 256 KiB cannot be replayed. A pending webhook refuses Replay: activate the webhook first. - Activate ends a pending webhook's review and publishes the held deliveries or marks them not published; see New source.
- Reset to default replaces a built-in webhook's mapping with the mapping shipped in Citadel.
Signatures
A signature proves that a delivery came from the app: the app signs each request body with a secret shared with Citadel, and Citadel recomputes the signature.
| Field | Value |
|---|---|
scheme | hmac-sha256, hmac-sha1 or stripe |
header | Header with the signature; default X-Signature |
prefix | Text before the signature, for example sha256=; default none |
encoding | hex (default) or base64 |
secret | The shared secret, at most 512 characters; write-only |
required | true (default): refuse every delivery without a valid signature |
| Scheme | Check |
|---|---|
hmac-sha256, hmac-sha1 | header holds prefix + the HMAC of the raw body under the secret, hex (any case) or base64 |
stripe | Stripe-Signature: t=…,v1=…: HMAC-SHA256 of t.body; any v1 may match; t within 5 minutes of Citadel's clock |
With required: false, a delivery without the signature header is accepted on its token; a delivery with the header
is still checked. Citadel encrypts the secret (AES-GCM) and never shows the secret again. Saving a signature
without secret keeps the stored secret.
Security
| Request | Response | Recorded |
|---|---|---|
| Unknown token, webhook turned off, source archived | 404 hook_not_found | Nothing |
| Missing or invalid signature on a source that requires one | 401 bad_signature | Time, client address and reason on the source; never the body |
| More than 30 refused requests from one client address | 429 rate_limited with Retry-After | Nothing |
| Body over 2 MiB | 413 body_too_large | Nothing |
| Over 120 deliveries a minute on one source (bursts of 30) | 429 rate_limited with Retry-After | Nothing |
- A
404costs one SHA-256 and one lookup, the same for every token. - Every
404and401from a client address counts against one allowance per address across all webhook URLs: 30 refusals, refilling 1 every 2 seconds. Accepted deliveries never count. The client address is Cloudflare'sCF-Connecting-IP: the per-address limit relies on every request arriving through Cloudflare. - The admin panel shows per source: refused requests in the last 24 hours and the last 20 refusals.
- Replays: Stripe signatures older than 5 minutes are refused. Repeated deliveries dedupe on
idempotency, a delivery header or the body; see Duplicates. - New webhooks: a webhook starts
pendingand publishes nothing until the admin activates the webhook, so a URL handed to a new app reaches no reader before review. - Links: with allowed URL hosts set (exact hosts or
*.domain, at most 20), a mappedurlon another host is removed and the outcome saysurl dropped: host <host> not allowed. Aurlmust behttps://. - Noise budget: at most 5 events of a source a minute reach readers one by one; the rest become one summary event.
- Rotate the token after a leak; the old URL returns
404at once.
Built-in webhooks
Each built-in source has one webhook, seeded at startup with the default mapping. The URLs below predate the
/hooks/in/{token} form and stay in service; each enters the same receiver. A built-in webhook can be turned off,
edited and reset to its default mapping, not removed. Built-in webhooks start active.
| Source | URL | Authentication | Allowed URL hosts | Enricher |
|---|---|---|---|---|
github | POST /v1/events/hooks/github | X-Hub-Signature-256, hmac-sha256, required | github.com | |
narkis (Stripe) | POST /v1/events/hooks/stripe | Stripe-Signature, required | dashboard.stripe.com | |
coolify | POST /v1/events/hooks/coolify/{token} | Token | The Coolify server's host | coolify.deployment |
hostkey | GET or POST /v1/events/hooks/hostkey/{token} | Token | ||
uptime | POST /v1/events/hooks/uptime/{token} | Token | uptime.report |
GitHub and Stripe URLs carry no token: the signature is the only credential, and both URLs return 404 until the
signing secret is set. A built-in source also accepts its token at POST /v1/events/hooks/in/{token}.
Errors
| Status | Code | Cause |
|---|---|---|
400 | bad_delivery | An enricher refused the delivery (malformed uptime report) |
401 | bad_signature | Missing or invalid signature where one is required or sent |
404 | hook_not_found | Unknown token, webhook off, source archived |
413 | body_too_large | Body over 2 MiB |
429 | rate_limited | Source over its rate, or client address over its refusals; see Retry-After |
503 | seal_key_missing | A signature is set and the server cannot decrypt the secret; the delivery is refused |