# Webhook Templates

> Shape incoming webhook payloads into structured Alphorn notifications with per-webhook template fields - no external proxies needed.

Source: https://docs.alphorn.dev/webhook-templates/

Many tools send webhooks in their own shape - GitHub, Sentry, Prometheus, and others each use different payload formats. Alphorn turns these into structured notifications without any external proxy or transform service.

You have three options, from most to least work on the sender side:

1. **Configure the sender natively** - if the service supports custom webhook body templating (Grafana, Healthchecks.io, Gatus), make it send Alphorn's native format directly. See [Integrations](/integrations).
2. **Use Alphorn's per-webhook template fields** - for services with fixed payload shapes (GitHub, Sentry, Prometheus Alertmanager), fill in the four template fields on the webhook (or start from a [source preset](#source-presets)) and Alphorn extracts fields from the body.
3. **Rely on the heuristic fallback** - leave the templates empty and Alphorn picks reasonable defaults from common field names.

This page covers options 2 and 3.

## Template fields

Each webhook has four optional template fields under **Advanced: field templates**, both when creating a webhook and in **Webhooks → (your webhook) → Edit**:

| Field | Produces | Notes |
|---|---|---|
| Title template | `title` (≤ 200 chars) | Short headline, truncated if longer |
| Message template | `message` (≤ 4000 chars) | Notification body, truncated if longer |
| Tags template | `tags[]` | Comma-separated; each part trimmed, empty parts dropped |
| Priority template | `priority` (1-5) | Must resolve to a number. Rounded and clamped into 1-5. A non-numeric value means no priority; an empty result falls back to the body |

Each template can be up to 500 characters long.

### Syntax

Templates use `{dotted.path}` placeholders resolved against the JSON request body:

- `{title}` → `body.title`
- `{repository.full_name}` → `body.repository.full_name`
- `{alerts.0.labels.severity}` → the first entry of the `alerts` array (array items are addressed by index)

Missing paths, objects, and arrays render as empty; strings, numbers, and booleans are inserted as text. There are no conditionals, loops, or helpers - just substitution. That's intentional: templates stay predictable and can't be used to smuggle logic or secrets.

Templates only apply to JSON bodies. Plain-text requests use the `X-Title`, `X-Priority`, and `X-Tags` headers instead (see [Webhooks](/webhooks#plain-text)).

### Request headers

Request headers are available under `$headers`, using lower-case header names - for example `{$headers.host}`. Placeholders only allow letters, digits, `_`, and `.`, so headers with a hyphen in their name (like `X-GitHub-Event` or `User-Agent`) can't be referenced.

Sensitive headers are removed before templates see them: `Authorization`, `Cookie`, `Set-Cookie`, `Proxy-Authorization`, and anything starting with `X-Auth` or `X-Api`.

### Order of precedence

For title, message, and tags, Alphorn uses the first source that produces a value:

1. The webhook's template for that field
2. For [Slack-compatible payloads](/webhooks#slack-compatible-payload): the Slack `text`, blocks, or attachments (title and message only)
3. The heuristic fallback below
4. For the message only: the whole JSON body, pretty-printed

**Priority** is slightly different: if the priority template resolves to an empty value (e.g. the field is missing), Alphorn falls back to the body's numeric `priority` field. If it resolves to something that isn't a number (e.g. `critical`), the notification has **no** priority - there is no fallback in that case.

### Heuristic fallback

If a template is empty, Alphorn walks the body plus common wrapper keys (`issue`, `pull_request`, `data`, `event`, `submission`, `object`, `payload`, `form_response`) and picks the first string-valued field from:

- **Title candidates**: `title`, `subject`, `name`, `headline`
- **Message candidates**: `message`, `body`, `msg`, `text`, `content`, `description`, `summary`

Top-level `priority` (number, clamped into 1-5) and `tags` (array or comma-separated string) are read directly. If present, a top-level `action` string is prefixed to the title (`opened: Some issue title`).

If nothing matches, the message renders as the full JSON body.

## Source presets

When creating a webhook, the **Source preset (optional)** selector pre-fills the four template fields for common services. You can adjust the fields afterwards - the preset is only a starting point.

| Preset | Title | Message | Tags |
|---|---|---|---|
| GitHub | `{action}: {repository.full_name}` | `{issue.title}{pull_request.title}` | `github,{repository.name}` |
| Grafana | `{title}` | `{message}` | `grafana,{state}` |
| Prometheus Alertmanager | `{commonAnnotations.summary}` | `{commonAnnotations.description}` | `alertmanager,{status}` |
| Sentry | `{data.issue.title}` | `{data.issue.culprit}` | `sentry,{data.issue.level}` |
| Stripe | `{type}` | `{data.object.description}` | `stripe` |

The presets leave the priority template empty, because these services don't send a numeric priority. The examples further down this page are more detailed alternatives you can paste in instead.

## GitHub

The event type is only carried in the `X-GitHub-Event` header, which templates can't reference (header names with a hyphen aren't allowed in placeholders) - so create one Alphorn webhook per GitHub event type and have GitHub deliver only that event to it. GitHub can't send an `Authorization` header, so leave **Require API key** off for these webhooks.

### Pull requests

```
Title:    PR {action}: {pull_request.title}
Message:  {sender.login} {action} PR #{pull_request.number} in {repository.full_name}
Tags:     github,pr,{action},{repository.name}
Priority: 3
```

### Issues

```
Title:    Issue {action}: {issue.title}
Message:  {sender.login} {action} issue #{issue.number} in {repository.full_name}
Tags:     github,issue,{action},{repository.name}
Priority: 2
```

### Pushes

```
Title:    Push to {repository.full_name}
Message:  {pusher.name} pushed to {ref} - {head_commit.message}
Tags:     github,push,{repository.name}
Priority: 2
```

## Sentry

Sentry's default webhook plugin sends events under `data.issue`:

```
Title:    Sentry: {data.issue.title}
Message:  Culprit: {data.issue.culprit} - Level: {data.issue.level} - Events: {data.issue.count}
Tags:     sentry,{data.issue.level},{data.issue.project.slug}
Priority: 4
```

## PagerDuty (incoming)

Fan PagerDuty incidents out to other channels with a PagerDuty webhook subscription (v3) pointing at an Alphorn webhook:

```
Title:    PagerDuty: {event.data.title}
Message:  Service: {event.data.service.summary} - Urgency: {event.data.urgency} - Status: {event.data.status}
Tags:     pagerduty,{event.data.urgency},{event.data.status}
Priority: 5
```

## Prometheus Alertmanager

Alertmanager sends arrays of alerts with `commonLabels` and `commonAnnotations` rollups. Use those to describe the whole group, or `{alerts.0.…}` for the first alert only:

```
Title:    {commonLabels.alertname}
Message:  {commonAnnotations.summary}
Tags:     prometheus,{status},{commonLabels.severity}
Priority: 4
```

## Tips

- **Priority can't branch on values.** If you need different priorities per severity (critical = 5, warning = 3), either create separate webhooks - one per severity - or keep priority constant and route severities to different channels via [filter rules](/filtering).
- **Narrow event delivery at the source.** Most services let you pick which events each webhook URL receives. Combining that with dedicated Alphorn webhooks keeps templates simple.
- **The full body is always stored** as `payload` and available for [filtering rules](/filtering#payload-paths), so you don't have to cram everything into the templated message.

## Using `payload` for routing

The full JSON body is stored on every message as `payload`, regardless of templating. Filter on it to route the same webhook to different channels. Paths are relative to the body, so for a GitHub push event:

| Channel | Filter | Purpose |
|---|---|---|
| PagerDuty | Payload `ref` **equals** `refs/heads/main` | Only alert on pushes to main |
| Slack (#frontend) | Payload `repository.name` **equals** `frontend` | Frontend repo activity |
| Slack (#backend) | Payload `repository.name` **equals** `api` | Backend repo activity |

See [Filtering Rules → Payload paths](/filtering#payload-paths) for details.

## Direct forwarding

For trivial cases, point any webhook at Alphorn without filling in any template. The raw body is accepted and the heuristic picks a reasonable title and message from common field names. Add templates later when you want more polished notifications.
