Guide

Receive signed webhooks from OctoPing

Goal

Send OctoPing alerts to your own server and check each request's signature before you act on it.

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.
  • openssl to make a secret and to try the signature by hand.

Build the receiver and connect it

  1. 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 32
  2. Save 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()
    
  3. Start it

    Run it with the secret in the environment. It listens on port 8080.

    bash
    OCTOPING_SIGNING_SECRET=your-secret python3 -u webhook.py
  4. Add 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.

  5. 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
{
  "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.

bash
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"
Output
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.

webhook.mjs (Node.js 18 or later, no packages)
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

Stop finding out last.

Add a site, pick which of the eight checks matter, and get on with your day. The trial is free for 14 days and needs no card.