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

#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…

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