Skip to content

Webhooks

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.

  1. In the admin panel, open Events, select the source, and turn on Webhook. The admin panel shows the URL with its token once.
  2. Paste the URL into the sending app and send one test delivery from the app. Citadel keeps the delivery and answers held: pending review.
  3. Write the mapping in the admin panel and run Preview over the kept deliveries. Save.
  4. Optional: set a signature and mark it required; set the allowed URL hosts.
  5. 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.
StateDeliveriesEvents
pendingKept and answered held: pending review; signature, body size and rate limits applyNone
activeKept with their outcomePublished
disabledRefused with 404 hook_not_foundNone

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:

  1. Find the webhook by token. Refuse unknown tokens, turned-off webhooks and archived sources.
  2. Check the signature when one is set (see Signatures).
  3. Pending webhook: keep the delivery, answer held: pending review, stop.
  4. Parse the body.
  5. Run the source's mapping: zero or more events.
  6. Drop the delivery when the delivery repeats one (see Duplicates).
  7. Publish each event: field validation, URL host filter, idempotency_key dedupe, noise budget, rate limit.
  8. Keep the delivery with its outcome.
Response 200
{"ok":true,"outcome":"2 events: evt_01JBX7Q2W8V5K3M9N4P6R2T8YC,dropped: rule 1,evt_01JBX7Q2W8V5K3M9N4P6R2T8YD","events":["evt_01JBX7Q2W8V5K3M9N4P6R2T8YC","evt_01JBX7Q2W8V5K3M9N4P6R2T8YD"]}
outcomeMeaning
evt_…One event, stored
heldOne 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 reviewNo event: the webhook is pending; the delivery is kept for review
3 events: evt_a,held,dropped: rule 1One entry per item of a split
…; url dropped: host <host> not allowedA mapped url outside the allowed hosts was removed
…; 12 items over 1000 droppedA 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

RequestThe mapping reads
Content-Type: application/json, …+json, or any body that parses as JSONThe JSON value
Content-Type: application/x-www-form-urlencodedAn 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>"}
$headersRequest headers, names lowercased, one string each (repeats joined by , )
$queryThe 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:

KeyTaken fromDedupe
idempotencyThe mapping's idempotency value, per eventAn event with a key stored in the last 7 days returns the stored event
Delivery headerThe 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 idempotencyAs idempotency
BodySHA-256 of the body as received, after the signature checkdropped: 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

KeyValueResult
typeValueEvent type: lowercased; each run of characters outside [a-z0-9_-] becomes .; cut to 48; empty: webhook
textValueOne line, cut to 300 characters; empty: a summary of the item, at most 200 characters
detailValueNewlines kept, cut to 1000 characters
subjectValueOne line, cut to 80 characters
urlValueKept when an absolute https:// URL of at most 2048 characters
severityTemplate, or {"rules":[{"when","then"}],"default"}A severity name; anything else: info
stickyCondition, or true/falsedisplay.lifetime: "sticky"
lifetimeSeconds 1..86400display.lifetime
idempotencyValueidempotency_key; over 128 characters: sha256:<hex> of the value
occurred_atValueRFC 3339, or Unix seconds (milliseconds above 10^12); anything else: time of receipt
dataObject of key: path, template or valueEvent data; at most 20 keys
splitPath of an arrayOne item per element; an empty array makes no event (dropped: split: 0 items)
dropArray of conditions or {"when","reason"}An item matching a rule makes no event
casesArray of {"when", ...fields} or {"when","drop"}The first matching case overrides fields or drops the item
varsObject of name: valueNamed values per item, read as $vars.<name>
kindValueThe delivery's label in the kept list; default the first event's type
deliveryValueThe delivery's id in the kept list; default the first event's idempotency
collapsetrue (default) or falsefalse takes the source's events out of the noise budget
dedupe_bodytrue (default) or falsefalse publishes a delivery whose body repeats within 10 minutes; see Duplicates
redactRE2 regular expressionValues of matching key names become [redacted] before mapping and keeping
enrichArray of enricher namesAdds $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

PathReads
repo.nameKey name of key repo of the current item
commits.0.id, commits.-1.idArray elements by index; negative counts from the end
$root.monitorThe whole body, also inside split
$headers.x-github-eventA request header
$query.envA query parameter
$vars.whoA value of vars
$enrich.coolify.deployment.commitA 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.

FilterResult
shortFirst 7 characters: `{{sha
firstlineFirst line, trimmed
onelineNewlines and runs of spaces become one space
lower, upperCase
trimLeading 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
countNumber 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

ValueResult
{"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:

OperatorTrue when
eqThe value equals a string, number or bool, compared as text, case-insensitive; "eq": null matches missing or null
neeq is false (true for a missing value)
inThe value eqs an element of the array
containsThe text contains the string, case-insensitive; for an array: an element eqs it
matchesThe text matches the RE2 expression (case-sensitive; (?i) ignores case)
existstrue: present and not null; false: missing or null
gt, ltThe 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 drop drops the item; the outcome is dropped: <drop>, or dropped: case N when drop renders empty.
  • Any other case overrides the mapping's fields with its own: type, text, detail, subject, url, severity, sticky, lifetime, idempotency, occurred_at. Case data keys replace or add to the mapping's data keys.

Fields outside every case apply to every item.

data

EntryResult
"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>"].

EnricherAddsBehaviour
coolify.deployment$enrich.coolify.deployment.commit, .messageFor 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, .countedRuns 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.

LimitValue
Mapping32 KB
Rules (drop, cases, severity rules together)50
Condition and value nesting8
Regular expression512 characters
split items1000
data keys, vars names20 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

Delivery body
{
  "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"}
}
Mapping
{
  "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"}
}
Events
[
  {"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

Delivery body
{"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):

Mapping
{
  "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}}"
}
Events
[
  {"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.

Headers
{"x-github-event": "push", "x-github-delivery": "6f1e7a20-b5d1-11ef-8f2a-1c3e2d9a7b01"}
Delivery body
{
  "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"}
  ]
}
Mapping
{
  "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"}
  ]
}
Events
[
  {"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. idempotency keys, 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.

FieldValue
schemehmac-sha256, hmac-sha1 or stripe
headerHeader with the signature; default X-Signature
prefixText before the signature, for example sha256=; default none
encodinghex (default) or base64
secretThe shared secret, at most 512 characters; write-only
requiredtrue (default): refuse every delivery without a valid signature
SchemeCheck
hmac-sha256, hmac-sha1header holds prefix + the HMAC of the raw body under the secret, hex (any case) or base64
stripeStripe-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

RequestResponseRecorded
Unknown token, webhook turned off, source archived404 hook_not_foundNothing
Missing or invalid signature on a source that requires one401 bad_signatureTime, client address and reason on the source; never the body
More than 30 refused requests from one client address429 rate_limited with Retry-AfterNothing
Body over 2 MiB413 body_too_largeNothing
Over 120 deliveries a minute on one source (bursts of 30)429 rate_limited with Retry-AfterNothing
  • A 404 costs one SHA-256 and one lookup, the same for every token.
  • Every 404 and 401 from 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's CF-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 pending and 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 mapped url on another host is removed and the outcome says url dropped: host <host> not allowed. A url must be https://.
  • 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 404 at 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.

SourceURLAuthenticationAllowed URL hostsEnricher
githubPOST /v1/events/hooks/githubX-Hub-Signature-256, hmac-sha256, requiredgithub.com
narkis (Stripe)POST /v1/events/hooks/stripeStripe-Signature, requireddashboard.stripe.com
coolifyPOST /v1/events/hooks/coolify/{token}TokenThe Coolify server's hostcoolify.deployment
hostkeyGET or POST /v1/events/hooks/hostkey/{token}Token
uptimePOST /v1/events/hooks/uptime/{token}Tokenuptime.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

StatusCodeCause
400bad_deliveryAn enricher refused the delivery (malformed uptime report)
401bad_signatureMissing or invalid signature where one is required or sent
404hook_not_foundUnknown token, webhook off, source archived
413body_too_largeBody over 2 MiB
429rate_limitedSource over its rate, or client address over its refusals; see Retry-After
503seal_key_missingA signature is set and the server cannot decrypt the secret; the delivery is refused