0018. Structured webhook bodies with `${namespace.path}` interpolation
0018. Structured webhook bodies with `${namespace.path}` interpolation
- Status: Accepted
- Date: 2026-07-13
Context
A raw template: string run through Go’s text/template forces notifier authors
to hand-build JSON: {{json}} + printf + backtick raw strings + positional
%s args — hard to read, easy to get wrong (a stray brace breaks the payload). It
also blurred two interpolation layers: bare ${VAR} env expansion worked in some
fields but not others — ${PAGERDUTY_KEY} inside a template body silently did
nothing.
Decision
Remove JSON authoring entirely and collapse the two layers into one syntax.
- Bodies are structured, not hand-written JSON. A notifier declares
body:as a YAML structure; certel renders each string value andjson.Marshals the whole thing, so quotes and newlines are escaped for free. Authors write the message, never a brace. - One
${namespace.path}grammar, two namespaces:${env.VAR}(secrets, resolved once at config load, valid inurl,headersandbody) and${alert.Path}(per-alert data, resolved at render,bodyonly). Notext/template, no{{...}}. A value that is exactly one${alert.X}keeps the datum’s native JSON type (days_left: ${alert.DaysLeft}→ a number); a reference inside a larger string yields a string. Substitution is single-pass and non-recursive, so an interpolated cert CN of${env.SECRET}is inert — it can neither leak a secret nor self-reference. A literal${is written$${. - No inline control flow. Severity routing is already handled by multiple notifiers
with
min_severityfloors (0007 ), and the prober setsMessage/Status/Severitycorrectly for both firing and recovery, so the common case renders both from onebody. The one genuine divergence — recovery wording — is an optionalrecovery_body:, a sparse deep-merge override ontobody(name only the keys that differ), matching howtarget_defaultsoverrides work elsewhere. Not a{{if .Recovered}}.
Timestamp fields take an optional | format suffix — a named preset (date, datetime,
time, human, rfc3339) or a strftime pattern (%Y-%m-%d) — so the unreadable Go
reference layout (2006-01-02) is never exposed.
Consequences
- Every JSON webhook (Telegram, PagerDuty’s nested schema, a generic receiver) is expressed in config with no braces — the config-only notifier model (0005 ) now has an author-friendly body.
- Uniform
${env.*}scope overurl/headers/bodycloses the latent bug where an env reference in a body did nothing; secrets stay out of the config file. - Static validation at load replaces the old sample-render guess: every
${...}is parsed, an unknown${alert.Path}(not in the field allowlist) or an unset${env.VAR}fails at startup, not at alert time. A representative sample render still runs to catch encoding/format bugs. - This is a pre-1.0 breaking change with no alias path (aliases start at 1.0):
template:→body:, bare${VAR}→${env.VAR},{{.Field}}→${alert.Field}, and the top-levelNotAfteris gone (DaysLeftis the effective decision number; the rare chain-earliest date is${alert.Cert.EarliestNotAfter}). The alert data model nests all certificate facts under.Cert, which renders empty when the handshake never completed.
Alternatives considered
- Keep
text/templatewith{{json}}helpers. Rejected: it is the hand-built-JSON problem this ADR removes. - A
{{if .Recovered}}conditional inside one body. Rejected in favor of the sparserecovery_body:override — no inline control flow anywhere, and the diverging wording lives in exactly one place with no duplication to drift. - A low-level
raw_body:string for non-JSON (form-encoded, plaintext) receivers. Deferred: not a current need;bodycovers every JSON webhook. Reintroduce only if a real non-JSON receiver appears.
References
internal/alert/template.go,internal/config/config.go(AlertConfig.Body,expandEnv),internal/probe/result.go(CertInfo),config.example.yaml.- “Body” section in the README .