# Webhooks

What Pulsefast sends to a webhook channel, how delivery works, and how to verify signatures in Python, Node and Go.


A **webhook** alert channel sends every alert as an HTTP `POST` to your URL. Use it to feed your own
tools: a chat bot, an incident tracker, a script that restarts something.

(Agent on-call uses a different payload, made for agents: see [Agent on-call](/docs/agents).)

## The payload

```json
{
  "kind": "down",
  "title": "DOWN: Shop",
  "text": "https://shop.example.com is not responding as expected.",
  "link": "https://pulsefast.io/app/monitors/12",
  "fields": [
    {"label": "Cause", "value": "timeout after 10s"},
    {"label": "Confirmed from", "value": "eu-central, us-east"}
  ],
  "dedup_key": "pulsefast-incident-345",
  "source": "https://shop.example.com",
  "at": "2026-10-12T09:41:07Z"
}
```

| Field | Notes |
|---|---|
| `kind` | `down`, `up` (recovered), `ssl_expiring` or `test` (the channel's *Send test* button). |
| `title` | One line, for a notification. |
| `text` | A sentence or two. |
| `link` | Where to see more in Pulsefast. |
| `fields` | Details as label/value pairs: cause, regions, response time, how long it was down. Labels may be added over time, so don't rely on their order. |
| `dedup_key` | The same for an incident's `down` and `up`: use it to update rather than duplicate. |
| `source` | What was monitored: the URL, host and port, or heartbeat name. |
| `at` | When it happened, in RFC 3339 UTC. |

New fields may appear; existing ones won't change meaning. Ignore what you don't know.

## Delivery

- **Success** is any `2xx` answer within 5 seconds.
- **Retries:** after a failure or timeout, two more attempts, about 0.5 s and 1 s later. Each attempt and
  its outcome is listed on the incident under *Alerts sent*.
- **Redirects aren't followed.** Give the final URL.
- **Private addresses are refused** (localhost, 10.x, 192.168.x and so on): the URL must be reachable
  from the internet. Use HTTPS.
- **Order isn't guaranteed:** with retries, a quick recovery's `up` can arrive before its `down`. Use
  `dedup_key` and `at` to put them in order.

## Your own format

To send something other than the JSON above (for a tool that expects its own format), set a **body
template** on the channel, in [Go template](https://pkg.go.dev/text/template) syntax. The `json` function
quotes a value safely:

```text
{"content": {{json .Title}}, "url": {{json .Link}}}
```

Available: `.Kind`, `.Title`, `.Text`, `.Link`, `.Fields` (each with `.Label` and `.Value`), `.DedupKey`,
`.Source` and `.At`. Set *Content type* if it isn't JSON.

## Verifying signatures

Set a **signing secret** on the channel, and every request carries a signature header:

```text
X-Webhook-Signature: t=1791760867,v1=5f1c9a…
```

- `t` is when it was sent, in Unix seconds.
- `v1` is the hex HMAC-SHA256 of `t`, a dot, and the **raw request body**, keyed with your secret.

Check that it matches, and reject old timestamps so a captured request can't be replayed later. Use the
raw body exactly as received: parsing and re-serializing JSON changes the bytes.

### Python

```python
import hashlib, hmac, time

def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    t, sig = parts.get("t", ""), parts.get("v1", "")
    if not t.isdigit() or abs(time.time() - int(t)) > tolerance:
        return False
    expected = hmac.new(secret.encode(), t.encode() + b"." + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)
```

With Flask, pass `request.get_data()` as `body`.

### Node

```js
const crypto = require("node:crypto");

function verify(secret, header, body, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.`).update(body).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 ?? "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

With Express, read the body with `express.raw({ type: "*/*" })`, so `body` is the raw `Buffer`.

### Go

```go
import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"strconv"
	"strings"
	"time"
)

func verify(secret, header string, body []byte, tolerance time.Duration) bool {
	var ts, sig string
	for _, part := range strings.Split(header, ",") {
		k, v, _ := strings.Cut(part, "=")
		switch k {
		case "t":
			ts = v
		case "v1":
			sig = v
		}
	}
	t, err := strconv.ParseInt(ts, 10, 64)
	if err != nil || time.Since(time.Unix(t, 0)).Abs() > tolerance {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(ts + "."))
	mac.Write(body)
	want, _ := hex.DecodeString(sig)
	return hmac.Equal(mac.Sum(nil), want)
}
```

Read the body with `io.ReadAll(r.Body)` before decoding it.

These three snippets run in Pulsefast's own test suite against real signatures, so they stay correct.
