What you'll get
- A
POSTto your address for every alert: problems, warnings, recoveries and acknowledgements, as JSON, by the same rules as every other channel. - A signature on every request, made with a secret only you and Gryphon hold, so your receiver can refuse anything Gryphon did not send, and anything replayed later.
- Somewhere to start anything: open a ticket, page through your own tool, light a lamp, post to a chat Gryphon has no integration for.
Before you start
- An HTTPS address on the public internet that accepts a
POSTand answers within ten seconds. Plain HTTP is refused: the body names your hosts. - In Gryphon, the owner of the account, or a member the owner has allowed to change hosts.
Steps
1Add the webhook
On the dashboard, open Integrations and choose Webhook. In the iPhone app it is in the account menu; on Android, under Settings.
- Name
- Incident bot
- Webhook URL
- https://example.com/gryphon
- What it sends
- Problems, Warnings, Recoveries, Acknowledgements
- Which hosts
- Every host, or only the hosts you choose
2Copy the signing secret
Saving takes you to the webhook's page with its signing secret showing, a string starting
whsec_. Copy it into your receiver's configuration, for instance as
GRYPHON_WEBHOOK_SECRET. It can be shown again on that page, and Make a new
secret replaces it: the old one stops working at once.
3Check the signature in your receiver
Every request carries a Gryphon-Signature header like
t=1791310863,v1=5b2c…. t is when it was signed, in Unix seconds; v1 is
the hex HMAC-SHA256 of t, a full stop, and the raw body, under your secret. Recompute it over the
body exactly as it arrived (before any JSON parsing), compare in constant time, and refuse a time more than a
few minutes away. Each of these was checked against a request Gryphon signed.
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"math"
"net/http"
"os"
"strconv"
"strings"
"time"
)
var secret = []byte(os.Getenv("GRYPHON_WEBHOOK_SECRET"))
// verify reports whether a request came from Gryphon, recently.
func verify(header string, body []byte) 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 || math.Abs(float64(time.Now().Unix()-t)) > 300 {
return false // no time, or more than five minutes from now: refuse a replay
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(ts + "."))
mac.Write(body)
want := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(want), []byte(sig))
}
func main() {
http.HandleFunc("POST /gryphon", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if !verify(r.Header.Get("Gryphon-Signature"), body) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusNoContent)
// ... act on the alert in body
})
http.ListenAndServe(":8080", nil)
}
const crypto = require("crypto");
const express = require("express");
const secret = process.env.GRYPHON_WEBHOOK_SECRET;
// verify reports whether a request came from Gryphon, recently.
function verify(header, body) {
const parts = Object.fromEntries((header || "").split(",").map((p) => p.split("=")));
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!parts.v1 || !(age <= 300)) return false;
const want = crypto.createHmac("sha256", secret).update(parts.t + ".").update(body).digest("hex");
return want.length === parts.v1.length &&
crypto.timingSafeEqual(Buffer.from(want), Buffer.from(parts.v1));
}
const app = express();
// The raw body: the signature is over the bytes Gryphon sent.
app.post("/gryphon", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.get("Gryphon-Signature"), req.body)) return res.status(401).send("bad signature");
res.sendStatus(204);
const alert = JSON.parse(req.body);
// ... act on alert.event, alert.host, alert.check
});
app.listen(8080);
import hashlib, hmac, os, time
from flask import Flask, abort, request
SECRET = os.environ["GRYPHON_WEBHOOK_SECRET"].encode()
app = Flask(__name__)
def verify(header, body):
"""Whether a request came from Gryphon, recently."""
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
try:
fresh = abs(time.time() - int(parts.get("t", ""))) <= 300
except ValueError:
fresh = False
want = hmac.new(SECRET, parts.get("t", "").encode() + b"." + body, hashlib.sha256).hexdigest()
return fresh and hmac.compare_digest(want, parts.get("v1", ""))
@app.post("/gryphon")
def gryphon():
if not verify(request.headers.get("Gryphon-Signature", ""), request.get_data()):
abort(401)
alert = request.get_json()
# ... act on alert["event"], alert["host"], alert["check"]
return "", 204
4Send a test
Press Test on the webhook. Your receiver gets a request whose event is
test, and Gryphon shows what your receiver answered. A test is tried once, so the answer comes
back within ten seconds.
What arrives
Content-Type: application/json
User-Agent: Gryphon-Webhook/1
Gryphon-Event: problem
Gryphon-Delivery: 6f1c0d2e-4b1a-4c55-9a2e-0d0c6b9f3a11
Gryphon-Signature: t=1791310863,v1=5b2c...
{
"version": 1,
"event": "problem",
"delivery_id": "6f1c0d2e-4b1a-4c55-9a2e-0d0c6b9f3a11",
"occurred_at": "2026-10-06T18:21:03Z",
"account_id": 12,
"event_id": 98123,
"title": "Problem: HTTPS on web-01",
"host": { "id": 7, "name": "web-01", "url": "https://.../admin/host/7#services" },
"check": { "id": 311, "name": "HTTPS", "status": "problem", "previous_status": "healthy" },
"message": "Down from every location: connection refused",
"acknowledged_by": null
}
event(and theGryphon-Eventheader) isproblem,warning,recovered,acknowledgedortest.check.statusisproblem,warningorhealthy.messageis what the check said: the reason, the regions it is down from, the days left on a certificate.- On an acknowledgement,
acknowledged_byis the person's name andevent_idis the problem they acknowledged. A test has nohostorcheck. versionis1. A change that would break a receiver will be a new version, never an edit of this one. Ignore fields you do not know.
How it is delivered
- Answer with any 2xx, within ten seconds. Do the slow work after answering.
- Retries. An alert is tried up to three times: at once, two seconds later, and ten
seconds after that. A
5xx, a408, a timeout or a refused connection is retried; a429waits out yourRetry-After(up to fifteen seconds). Any other4xxis not retried: it would get the same answer. - Duplicates. Every attempt of one delivery carries the same
Gryphon-Delivery, so drop one you have already seen. - Order. Alerts are sent as they happen, and a check that flaps can, rarely, have its
recovery arrive before its problem. Order by
occurred_atif it matters. - No redirects. A
3xxis a failure; give Gryphon the address it redirects to. - Public addresses only. An address on a private network or this machine is refused, checked again on what the name resolves to each time a request is sent.
If it stops working
When every attempt fails, Gryphon marks the webhook Failing on the Integrations page with your receiver's answer, and emails the people who manage the account once, not once per alert. After a week with no successful delivery it pauses the webhook and says so. Fix the receiver, send a test, and resume it: a test that arrives resumes it too.