Skip to content
Alerts · Concept Intermediate

Custom webhook bodies

Post the shape your receiver dictates instead of Sluicio's canonical alert JSON - a JSON body template with typed variable references, checked when you save the channel and previewed against a sample firing.

SLSluicio team 7 min read Updated Aug 2026

A webhook channel posts Sluicio’s canonical alert JSON, and most receivers are happy to parse it. Some are not: a transactional email API wants from / recipients / content, a ticket system wants its own field names, an internal bus wants an envelope with your correlation id in it. Since v0.11.134, a webhook channel can post that shape instead.

Set the channel’s Payload format to Custom body on Alerts → Notification channels (config.format: "template" over the API), and write the body you want in the editor that appears (config.body_template).

The template is a JSON document, not a text template

Section titled “The template is a JSON document, not a text template”

The body template is JSON, and variable references appear inside it as values:

{
"from": { "email": "alerts@example.com" },
"recipients": [{ "email": "oncall@example.com" }],
"content": {
"subject": "[${alert.severity}] ${rule.name}",
"text_body": "$alert.summary\n\n$alert.link"
},
"retries": 3
}

That it is JSON rather than text with holes in it is the whole design, and it is worth knowing why: a text template that emits JSON breaks the first time an alert summary contains a quote or a newline - and summaries carry integration names, error messages and attribute values, so it will. The receiver answers 400, nothing upstream says a word, and you find out because an alert never arrived. Substituting into a parsed JSON tree instead means the encoder does the escaping, and any string can appear in any field. Producing invalid JSON isn’t discouraged here; it’s unrepresentable.

Two consequences follow, and both of them are the point:

  • The template has to parse as JSON to be saved at all, so a malformed body is rejected in the editor rather than at 3am.
  • A misspelled path is rejected the same way, by name (unknown variable alert.sumary). The alternative is a body that delivers null forever and looks fine.

Whether a reference keeps its type depends on where it sits:

The template valueWhat is posted
"$service.error_count" - exactly one referenceThe value with its type: 128, not "128". A list stays a list; an absent value becomes null
"[${alert.severity}] ${rule.name}" - a string containing referencesText, built by substitution. An absent value renders as an empty string

So a receiver whose schema says “number” gets a number, and a receiver whose schema says “string” gets exactly the sentence you composed.

$path and ${path} are the same reference. The braces matter only when a reference is followed by a character that could continue the name: "${alert.state}s" reads alert.state, while "$alert.states" reads a path called alert.states and fails to save.

Object keys are never substituted, and any literal you write is passed through untouched - so the constants a receiver requires ("retries": 3, an API version, a fixed type discriminator) stay exactly as typed.

The paths are the ones the notification templates use for email and Slack: alert.*, rule.*, check.*, service.*, integration.*, org.*, sent_at, and the rule’s content toggles as include.*. The channel editor lists every one of them beside the field with a sample value, so you can see what check.value actually renders as before you depend on it. GET /api/v1/alerting/template-context-schema returns the same list, with each path’s type and when it carries a value.

Not every variable exists in every firing - check.* only on metric checks, service.* only on service-scoped rules - and a path that doesn’t apply to the alert being sent renders as null (or as empty text when interpolated), which is why the whole-value form is the safer one for a field your receiver validates.

The palette is the authority on what exists. One difference from the Liquid templates is worth knowing: individual metadata fields are not addressable in a body template. $service.metadata.runbook_url is rejected as an unknown variable, because the palette advertises metadata as a shape to iterate rather than as a set of paths.

The editor renders your template against a representative firing and shows the exact payload, using the same renderer delivery uses - not a second implementation that would agree right up to the case that matters.

Send test on the channel posts a real request through the same path. Use it: the preview proves the body is what you meant, and only the receiver can tell you it is what they wanted.

Limits, and what happens when rendering fails

Section titled “Limits, and what happens when rendering fails”
  • The template must parse as JSON with an object at the root, and is capped at 32 KB.
  • A custom body replaces the canonical payload outright - your receiver dictated the schema, and nesting their shape inside ours would mean it never matched. Custom body and CloudEvents are alternative choices of the same Payload format setting, not layers.
  • A template that fails to render at delivery time falls back to the canonical payload rather than dropping the notification. The receiver will probably reject the shape, but a 400 in the delivery log is a far better outcome than an alert that quietly went nowhere.
  • Signing covers the rendered body exactly as it covers the default one: the HMAC is always over the raw bytes that were posted.

The format and the template are two keys on a webhook channel’s config:

{
"kind": "webhook",
"name": "Ticketing",
"config": {
"url": "https://tickets.example.com/api/v2/incidents",
"format": "template",
"body_template": "{\"title\":\"$rule.name\",\"body\":\"$alert.summary\",\"priority\":\"${alert.severity}\",\"open\":true}",
"auth_header": "Bearer tk_live_…"
}
}

body_template is a string holding the JSON document, so it is escaped like any other JSON string value. The channel is rejected on save if the template doesn’t parse, references an unknown path, exceeds 32 KB, or is set without format: "template".

(auth_header in the example is unrelated to the body: it is the receiver’s own Authorization value, sent verbatim - see the receiver’s own authentication. Like every channel credential, it reads back masked.)