Docs / API and integrations
Webhooks
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.)
#The payload
{
"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
2xxanswer 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
upcan arrive before itsdown. Usededup_keyandatto 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 syntax. The json function
quotes a value safely:
{"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:
X-Webhook-Signature: t=1791760867,v1=5f1c9a…
tis when it was sent, in Unix seconds.v1is the hex HMAC-SHA256 oft, 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
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
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
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.
View as Markdown · Something unclear? Tell us