Webhooks send each OctoPing alert to a server you run, so a down alert can open a ticket, post in a chat tool or start a script. This guide builds a small receiver, points an alert at it and checks the signature on every request, so only a request signed with your secret gets through.
What you need
- A monitored site, and a role of Member, Admin or Owner to add a notification to it.
- Python 3.10 or later, or Node.js 18 or later. The receiver uses nothing but the standard library.
- A place to host the receiver at an address OctoPing can reach over the internet. While you try it out, a tunnel from your laptop works for that.
opensslto make a secret and to try the signature by hand.
Build the receiver and connect it
Make a signing secret
Generate a long random string and keep it with the rest of your secrets. OctoPing signs each request with it, and your receiver uses the same string to check the signature.
bash openssl rand -hex 32Save the receiver
This program reads the secret from the environment variable
OCTOPING_SIGNING_SECRET. It answers 401 for a missing or wrong signature and 204 for a good one, and it prints the event for each request it accepts. It's the same sample the webhooks reference lists, and that page also has PHP and Go versions.webhook.py import hashlib import hmac import json import os from http.server import BaseHTTPRequestHandler, HTTPServer SIGNING_SECRET = os.environ.get("OCTOPING_SIGNING_SECRET", "") def is_valid(secret: str, raw_body: bytes, header: str | None) -> bool: """True when header is "sha256=" plus the HMAC-SHA256 of the raw body, keyed with the secret.""" if not secret or not header or not header.startswith("sha256="): return False digest = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(("sha256=" + digest).encode(), header.encode()) class Handler(BaseHTTPRequestHandler): def do_POST(self): raw_body = self.rfile.read(int(self.headers.get("Content-Length", "0"))) if not is_valid(SIGNING_SECRET, raw_body, self.headers.get("X-OctoPing-Signature")): self.send_response(401) self.end_headers() return alert = json.loads(raw_body) print(alert["event"], alert.get("webpage") or alert.get("heartbeat")) self.send_response(204) self.end_headers() if __name__ == "__main__": HTTPServer(("", 8080), Handler).serve_forever()Start it
Run it with the secret in the environment. It listens on port 8080.
bash OCTOPING_SIGNING_SECRET=your-secret python3 -u webhook.pyAdd the webhook notification
On the site's Notifications page, choose Add Notification and set Notification Type to Webhook. Enter your receiver's address in Webhook URL. Open Delivery settings and paste the secret into Signing Secret. The app never shows the secret again, so keep your own copy.
Send a test
Choose Send Test. OctoPing sends a request at once and shows the result on the page. OctoPing waits up to 10 seconds for your receiver to answer. The receiver prints the event it accepted, and the Python server also writes its own request log line for each request.
What the receiver printed (the id is shortened) test {'id': 'c4c868fa-...', 'url': 'https://example.com/'}
What a request looks like
Each delivery is a JSON POST with a Content-Type of application/json and the user agent OctoPing-Webhook/1.0. When a Signing Secret is set it also carries X-OctoPing-Signature, which is sha256= followed by the lowercase hex HMAC-SHA256 of the raw body, keyed with your secret. The body is one JSON object. This is the test event, with the message shortened.
{
"event": "test",
"subject": "\ud83e\uddea Test Notification: https://example.com/",
"message": "This is a test notification from OctoPing. ...",
"timestamp": "2026-11-07T04:54:14.798244+00:00",
"webpage": {
"id": "00000000-0000-0000-0000-000000000000",
"url": "https://example.com/"
}
}Real alerts use the same top-level keys with an event such as down or recovered, plus a details object. Every event and every key in details is on the webhooks reference.
Verify it works
You don't have to wait for an alert to see the check do its job. This script signs a body with the same method OctoPing uses and sends it to your receiver three ways. The secret here is a placeholder, so use the one your receiver runs with.
export OCTOPING_SIGNING_SECRET=example-signing-secret
BODY='{"event":"test","subject":"Test","message":"Hello","timestamp":"2026-11-07T04:54:14+00:00"}'
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$OCTOPING_SIGNING_SECRET" -hex | sed 's/^.* //')"
echo "$SIG"
curl -s -o /dev/null -w "signed: %{http_code}\n" -X POST http://localhost:8080 -H "X-OctoPing-Signature: $SIG" -d "$BODY"
curl -s -o /dev/null -w "tampered: %{http_code}\n" -X POST http://localhost:8080 -H "X-OctoPing-Signature: $SIG" -d "${BODY/Hello/Goodbye}"
curl -s -o /dev/null -w "unsigned: %{http_code}\n" -X POST http://localhost:8080 -d "$BODY"sha256=003a28be6e43ec9a17585b32ca214d07e82f97281e99471bcf65efb5ccc0302d
signed: 204
tampered: 401
unsigned: 401- The signed request gets 204 and your handler runs.
- Changing one word in the body gets 401, because the signature no longer matches the bytes.
- A request with no signature gets 401. Treat a missing header as invalid whenever you rely on signing.
The notification's log on the site's Notifications page records every real delivery as Sent or Failed, with the error text when it failed. The reference lists what each error means.
Keep it reliable in production
- Sign-check the raw bytes. Parse the JSON only after the signature passes, because parsing and writing it back changes spacing and escapes.
- Answer fast and do the slow work afterwards. A request that doesn't get a 2xx status within 10 seconds counts as failed, and OctoPing retries it up to 5 times.
- Expect an occasional duplicate. A retry is a new request with a new timestamp and a new signature, so don't rely on the timestamp to spot it. Treat the same event for the same site id arriving within a couple of minutes as one delivery, and make your handler safe to run twice.
- Order alerts by the timestamp in the body. Alerts are delivered in the background, so two sent close together can arrive in either order.
- Reject old requests. The signature has no expiry of its own, so refusing a request whose timestamp is more than a few minutes old limits how long a captured request stays useful.
The same receiver in Node.js
If your stack is JavaScript, this version does the same job and reads the same environment variable.
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import { pathToFileURL } from "node:url";
const SIGNING_SECRET = process.env.OCTOPING_SIGNING_SECRET ?? "";
// True when header is "sha256=" plus the HMAC-SHA256 of the raw body, keyed with the secret.
export function isValid(secret, rawBody, header) {
if (!secret || typeof header !== "string" || !header.startsWith("sha256=")) return false;
const expected = Buffer.from("sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex"));
const received = Buffer.from(header);
return received.length === expected.length && timingSafeEqual(received, expected);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
createServer((req, res) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => {
const rawBody = Buffer.concat(chunks);
if (!isValid(SIGNING_SECRET, rawBody, req.headers["x-octoping-signature"])) {
res.writeHead(401).end();
return;
}
const alert = JSON.parse(rawBody.toString("utf8"));
console.log(alert.event, alert.webpage ?? alert.heartbeat);
res.writeHead(204).end();
});
}).listen(8080);
}
Read the reference for Webhooks
Last reviewed
Related
- DocsWebhooksThe JSON payload for every website and heartbeat event, the request headers, how to verify the HMAC signature in Python, Node.js, PHP and Go, and how retries work.
- FeatureWebsite alerts by email, Slack, Discord, webhook and pushChoose where alerts go, how many failures it takes to send one and how often reminders repeat. Recovery messages and a log of every attempt are included.
- GuideSend uptime alerts to a Discord channelGet down and recovery alerts for your sites in a Discord channel your team already watches.