Webhooks

The 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.

Last reviewed

On this page

A webhook notification sends each alert to a URL you control, as a JSON POST. This page is the reference for what that request contains, how to verify that OctoPing sent it, and what happens when your server doesn't answer. The rules that decide when an alert is sent are on the alert rules page.

Set up a webhook notification

  1. Prepare an endpoint

    It must accept a POST with a JSON body and answer with a 2xx status within 10 seconds. Do the slow work after you've answered.

  2. Add the notification

    On the site's Notifications page, choose Add Notification and set Notification Type to Webhook. Enter the endpoint in Webhook URL. It must start with https:// or http://.

  3. Set a Signing Secret

    Open Delivery settings and enter a Signing Secret. With one set, every request carries a signature your server can check. The secret is write-only. The app never shows it again, and a blank field on an existing notification keeps the current secret. To remove it, delete the notification and create it again.

  4. Add Custom Headers (optional)

    Choose Add Header for each header you need, for example an Authorization header for your endpoint.

  5. Choose Send Test

    A test event is sent at once. The result appears on the page and in the log. A heartbeat has a Webhook option on its own Notifications page, with two switches. Its form takes only the URL, so heartbeat requests carry no signature header and no custom headers.

The request

Every delivery is an HTTP POST with these headers.

HeaderValue
Content-Typeapplication/json
User-AgentOctoPing-Webhook/1.0
X-OctoPing-SignatureOnly when a Signing Secret is set: sha256= followed by the lowercase hex HMAC-SHA256 of the raw body.
Your custom headersAs you entered them. A custom header with the name of a built-in one replaces it, except Content-Type and X-OctoPing-Signature, which stay fixed.
  • Content-Type and X-OctoPing-Signature are reserved. A custom header with either name is ignored, whatever its capitalization.
  • A header name is up to 128 characters and a value up to 1024. Neither may contain a line break.
  • The body is ASCII. Every non-ASCII character, such as the emoji and dash in a subject line, is written as a \uXXXX escape, which any JSON parser decodes for you. The signature covers the bytes as sent.

The payload

The body is one JSON object. These keys are at the top level.

KeyTypeMeaning
eventstringWhat happened. The values are listed below, and they're the same as the Trigger column in the notification log.
subjectstringThe alert's one-line title, the same text an email would use as its subject.
messagestringThe alert as plain text, the same text an email would carry. Times inside it are written for people to read, so take machine times from the timestamp field.
timestampstringWhen this request was built, as a UTC ISO 8601 timestamp such as 2026-11-06T14:31:02.418213+00:00.
webpageobjectFor a website alert: id and url of the site. Present instead of heartbeat.
heartbeatobjectFor a heartbeat alert: id and name of the heartbeat. Present instead of webpage.
detailsobjectThe alert's structured data. Its keys depend on the event. It's absent from test events and from every heartbeat event.

Events

Website events

EventAlertKeys in details
downSite downstatus_code, error_message, response_time, platform_link, consecutive_failures, failure_code, failure_label, failure_detail, http_status_name, regions_down, regions_up, regions_stale, regions_summary, regions_scope, vantages
primary_blockedChecker blocked noticestatus_code, regions_up, allowlist_ips, platform_link, regions_down, regions_stale, regions_summary, regions_scope, vantages
slow_responseSlow responseresponse_time_ms, threshold_ms, platform_link
ssl_expiringSSL certificate expiringdays_until_expiry, issuer, platform_link
whois_expiryDomain expiringdays_until_expiry, registrar, platform_link
recoveredRecoveredplatform_link, downtime, downtime_seconds, outage_started_at, failure_code, failure_label, failure_detail, http_status_name, regions_down, regions_up, regions_stale, regions_summary, regions_scope, vantages
changedContent changedplain_diff, platform_link
status_changedHTTP status changedold_status_code, new_status_code, platform_link
dns_changedDNS records changedcheck_type, old_records, new_records, platform_link
robots_changedrobots.txt changedplain_diff, old_indexable, new_indexable, platform_link
whois_changedWHOIS details changedold_registrar, new_registrar, changes_summary, platform_link
hosting_changedHosting changedold_asn, old_asn_name, new_asn, new_asn_name, new_is_cdn, new_cdn_provider, platform_link

A test send uses the event test and has no details. The switch that controls each event is in the table on the alert rules page.

Heartbeat events

EventAlertSent when
missedHeartbeat missedAn expected ping hasn't arrived and the retry threshold and repeat interval allow a message.
recoveredHeartbeat recoveredPings resumed after a missed alert was sent.

Heartbeat events carry the key heartbeat and no details. The message holds the heartbeat's name, description, expected interval and last ping. A test send uses the event test.

Reference for details

KeyTypeMeaning
status_codeinteger or nullThe HTTP status of the failing check, or null when the request got no response.
error_messagestring or nullThe error text of the failing check.
response_timenumber or nullThe failing check's response time in milliseconds.
platform_linkstringA link to the site in OctoPing.
consecutive_failuresintegerHow many checks in a row have failed.
failure_codestring or nullThe stable failure code. The values are on the failure reasons page.
failure_labelstring or nullThe failure reason as a short label.
failure_detailstring or nullA sentence explaining the failure reason.
http_status_namestringThe name of the HTTP status, or an empty string.
regions_downarray of stringsThe checking locations that currently see a failure.
regions_uparray of stringsThe checking locations that currently see the site up.
regions_stalearray of stringsThe locations with no recent result.
regions_summarystringOne line summarizing the locations, or an empty string.
regions_scopestringall when every location with data is down, partial when some are, none when none is.
vantagesarray of objectsEvery location with its latest result: vantage (primary, us, europe or asia), label, status (Up, Down or No recent data), is_up (true, false or null), status_code and checked_at.
allowlist_ipsarray of stringsThe addresses OctoPing's checker uses, to allow through a firewall.
downtimestringThe length of the outage as text, or an empty string.
downtime_secondsnumber or nullThe length of the outage in seconds.
outage_started_atstring or nullWhen the outage began, as a UTC ISO 8601 timestamp.
plain_diffstringA unified-style text diff between the previous and the current version.
old_status_codeintegerThe status code before the change.
new_status_codeintegerThe status code after the change.
check_typestringmx or ns.
old_recordsarray of stringsThe records before the change.
new_recordsarray of stringsThe records after the change.
old_indexableboolean or nullWhether robots.txt allowed indexing before. Null on the first check.
new_indexablebooleanWhether robots.txt allows indexing now.
response_time_msnumberThe slow response time in milliseconds.
threshold_msintegerThe site's Response Time Alert Threshold in milliseconds.
days_until_expiryintegerDays left before the certificate or the domain expires.
issuerstringThe certificate issuer.
registrarstringThe domain registrar.
old_registrarstringThe registrar before the change.
new_registrarstringThe registrar after the change.
changes_summarystringA short description of what changed.
old_asnstring or nullThe network number before the change, such as AS13335.
old_asn_namestring or nullThe network's organization name before the change.
new_asnstring or nullThe network number after the change.
new_asn_namestring or nullThe network's organization name after the change.
new_is_cdnbooleanWhether the new network belongs to a CDN.
new_cdn_providerstring or nullThe CDN's name, when new_is_cdn is true.

Examples

A down alert for a site that three of four locations see failing.

event: down
{
  "event": "down",
  "subject": "\ud83d\udd34 Alert: https://example.com/checkout is down from Primary, US, Europe \u2014 Service Unavailable (HTTP 503)",
  "message": "Site Down Alert\n===============\n\nWebsite: https://example.com/checkout\nProblem: Service Unavailable (HTTP 503)\nThe server is unavailable, overloaded, or in maintenance.\nHTTP status: 503 Service Unavailable\nError: Service unavailable\nConsecutive failures: 3\nLast checked: Nov. 6, 2026, 2:30 p.m.\n\nChecked from\n------------\n  Primary: Down (HTTP 503) - 1\u00a0minute ago\n  US: Down (HTTP 503) - 2\u00a0minutes ago\n  Europe: Down (HTTP 503) - 3\u00a0minutes ago\n  Asia: Up (HTTP 200) - 3\u00a0minutes ago\n\nView details in OctoPing: https://octoping.com/w/a2b1f3c4-7d5e-4a98-b6c1-0e2f9d8c7b6a/websites/5f0d8a52-6c0e-4f0e-9a52-1c4b1a7a2f10?downtime=true",
  "timestamp": "2026-11-06T14:31:02.418213+00:00",
  "webpage": {
    "id": "5f0d8a52-6c0e-4f0e-9a52-1c4b1a7a2f10",
    "url": "https://example.com/checkout"
  },
  "details": {
    "status_code": 503,
    "error_message": "Service unavailable",
    "response_time": 412.6,
    "platform_link": "https://octoping.com/w/a2b1f3c4-7d5e-4a98-b6c1-0e2f9d8c7b6a/websites/5f0d8a52-6c0e-4f0e-9a52-1c4b1a7a2f10?downtime=true",
    "consecutive_failures": 3,
    "failure_code": "http_server",
    "failure_label": "Service Unavailable (HTTP 503)",
    "failure_detail": "The server is unavailable, overloaded, or in maintenance.",
    "http_status_name": "Service Unavailable",
    "regions_down": [
      "Primary",
      "US",
      "Europe"
    ],
    "regions_up": [
      "Asia"
    ],
    "regions_stale": [],
    "regions_summary": "Down from Primary, US, Europe \u00b7 Up from Asia",
    "regions_scope": "partial",
    "vantages": [
      {
        "vantage": "primary",
        "label": "Primary",
        "status": "Down",
        "is_up": false,
        "status_code": 503,
        "checked_at": "2026-11-06T14:30:01.002145+00:00"
      },
      {
        "vantage": "us",
        "label": "US",
        "status": "Down",
        "is_up": false,
        "status_code": 503,
        "checked_at": "2026-11-06T14:29:02.771300+00:00"
      },
      {
        "vantage": "europe",
        "label": "Europe",
        "status": "Down",
        "is_up": false,
        "status_code": 503,
        "checked_at": "2026-11-06T14:28:03.120044+00:00"
      },
      {
        "vantage": "asia",
        "label": "Asia",
        "status": "Up",
        "is_up": true,
        "status_code": 200,
        "checked_at": "2026-11-06T14:28:03.481920+00:00"
      }
    ]
  }
}

A recovery alert has a shorter shape: the outage length, when it began and the current results.

event: recovered
{
  "event": "recovered",
  "subject": "\u2705 Recovered: https://example.com/checkout is back online after 14m",
  "message": "Site Recovered\n==============\n\nWebsite: https://example.com/checkout\nStatus: Back online\nDowntime: 14m\nOutage started: Nov. 6, 2026, 2:16 p.m.\nCause: Service Unavailable (HTTP 503)\nThe server is unavailable, overloaded, or in maintenance.\nLast checked: Nov. 6, 2026, 2:30 p.m.\n\nChecked from\n------------\n  Primary: Up (HTTP 200) - 0\u00a0minutes ago\n\nThe site has recovered and is responding normally.\n\nView details in OctoPing: https://octoping.com/w/a2b1f3c4-7d5e-4a98-b6c1-0e2f9d8c7b6a/websites/5f0d8a52-6c0e-4f0e-9a52-1c4b1a7a2f10",
  "timestamp": "2026-11-06T14:30:05.902331+00:00",
  "webpage": {
    "id": "5f0d8a52-6c0e-4f0e-9a52-1c4b1a7a2f10",
    "url": "https://example.com/checkout"
  },
  "details": {
    "platform_link": "https://octoping.com/w/a2b1f3c4-7d5e-4a98-b6c1-0e2f9d8c7b6a/websites/5f0d8a52-6c0e-4f0e-9a52-1c4b1a7a2f10",
    "downtime": "14m",
    "downtime_seconds": 840.2,
    "outage_started_at": "2026-11-06T14:16:01.114500+00:00",
    "failure_code": "http_server",
    "failure_label": "Service Unavailable (HTTP 503)",
    "failure_detail": "The server is unavailable, overloaded, or in maintenance.",
    "http_status_name": "Service Unavailable",
    "regions_down": [],
    "regions_up": [
      "Primary"
    ],
    "regions_stale": [],
    "regions_summary": "Up from Primary",
    "regions_scope": "none",
    "vantages": [
      {
        "vantage": "primary",
        "label": "Primary",
        "status": "Up",
        "is_up": true,
        "status_code": 200,
        "checked_at": "2026-11-06T14:30:05.100231+00:00"
      }
    ]
  }
}

A heartbeat alert.

event: missed (heartbeat)
{
  "event": "missed",
  "subject": "\ud83d\udd34 Alert: Heartbeat 'Nightly backup' is late",
  "message": "Heartbeat Monitor: Nightly backup\nDescription: Runs pg_dump and uploads it to storage.\nStatus: Missed (consecutive failures: 3)\nExpected interval: 5min\nLast ping received: 2026-11-06 01:59:12+00:00\n\nThe heartbeat has missed 3 expected pings in a row.",
  "timestamp": "2026-11-06T02:08:00.512096+00:00",
  "heartbeat": {
    "id": "9c1e4d3a-2b7f-4e60-8a15-3d6f0b9e7c21",
    "name": "Nightly backup"
  }
}

A test send.

event: test
{
  "event": "test",
  "subject": "\ud83e\uddea Test Notification: https://example.com/checkout",
  "message": "This is a test notification from OctoPing.\n\nWebsite: https://example.com/checkout\nNotification: Webhook\nType: webhook\nRecipients: https://hooks.example.com/octoping\n\nIf you received this, your notification settings are working correctly!",
  "timestamp": "2026-11-06T14:30:05.902331+00:00",
  "webpage": {
    "id": "5f0d8a52-6c0e-4f0e-9a52-1c4b1a7a2f10",
    "url": "https://example.com/checkout"
  }
}

The headers of a website notification request, for a notification with a signing secret and one custom header (the signature value is illustrative).

request headers
{
  "User-Agent": "OctoPing-Webhook/1.0",
  "Authorization": "Bearer example-token",
  "Content-Type": "application/json",
  "X-OctoPing-Signature": "sha256=3b1f6c0e9a7d4f52c8e1a0b7d6f3e9c25a84b1d07f6e3c9a2b5d8e1f40a7c6d3"
}

Verify the signature

With a Signing Secret set, each request carries X-OctoPing-Signature. Website notifications are signed, and heartbeat webhooks are not. Verifying it proves the body came from a notification that holds your secret and wasn't changed on the way.

  1. Read the raw body bytes exactly as received, before any JSON parser touches them.
  2. Compute the HMAC-SHA256 of those bytes, using the signing secret as the key.
  3. Write the result as lowercase hex and put sha256= in front of it.
  4. Compare it with the header using a constant-time comparison. Reject the request when they differ or when the header is missing.

Each program below is complete. It reads the secret from the environment variable OCTOPING_SIGNING_SECRET, answers 401 for a missing or wrong signature and 204 for a good one.

Python

webhook.py (Python 3.10 or later, standard library only)
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()

Node.js

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);
}

PHP

webhook.php (PHP 8 or later)
<?php
// Needs PHP 8 or later. Run with: OCTOPING_SIGNING_SECRET=... php -S 0.0.0.0:8080 webhook.php

function is_valid_signature(string $secret, string $rawBody, ?string $header): bool
{
    if ($secret === '' || $header === null || !str_starts_with($header, 'sha256=')) {
        return false;
    }
    $expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
    return hash_equals($expected, $header);
}

$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_OCTOPING_SIGNATURE'] ?? null;

if (!is_valid_signature(getenv('OCTOPING_SIGNING_SECRET') ?: '', $rawBody, $header)) {
    http_response_code(401);
    exit;
}

$alert = json_decode($rawBody, true);
error_log($alert['event']);
http_response_code(204);

Go

main.go (standard library only)
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"log"
	"net/http"
	"os"
	"strings"
)

// validSignature reports whether header is "sha256=" plus the HMAC-SHA256 of rawBody, keyed with secret.
func validSignature(secret string, rawBody []byte, header string) bool {
	if secret == "" || !strings.HasPrefix(header, "sha256=") {
		return false
	}
	received, err := hex.DecodeString(strings.TrimPrefix(header, "sha256="))
	if err != nil {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write(rawBody)
	return hmac.Equal(received, mac.Sum(nil))
}

func handler(w http.ResponseWriter, r *http.Request) {
	rawBody, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
	if err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}
	if !validSignature(os.Getenv("OCTOPING_SIGNING_SECRET"), rawBody, r.Header.Get("X-OctoPing-Signature")) {
		http.Error(w, "invalid signature", http.StatusUnauthorized)
		return
	}
	var alert struct {
		Event string
	}
	if err := json.Unmarshal(rawBody, &alert); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}
	log.Println("received", alert.Event)
	w.WriteHeader(http.StatusNoContent)
}

func main() {
	http.HandleFunc("/octoping", handler)
	log.Fatal(http.ListenAndServe(":8080", nil))
}
  • OctoPing signs with the secret you typed, as UTF-8 text. Use the same string as the HMAC key and don't decode it.
  • The signature has no expiry of its own. The timestamp in the body is set when the request is built, so it's close to the time you receive it. Rejecting a request whose timestamp is more than a few minutes old limits how long a captured request stays useful.
  • A request sent without a Signing Secret has no signature header. If you rely on signing, treat a missing header as invalid.

Delivery, retries and ordering

SituationWhat OctoPing does
Your server answers with a 2xx statusThe delivery succeeds and the log records Sent.
Any other status, including a redirect, or a timeout, or a connection errorThe attempt fails. Redirects aren't followed. Each step of an attempt (connecting, sending and waiting for the response) has a 10-second limit.
A failed attemptOctoPing retries up to 5 times, so a delivery has at most 6 attempts. Before each retry it waits a random time between zero and 1, 2, 4, 8, 16 seconds in turn. The waits add up to at most 31 seconds.
Every attempt failsThe log records Failed once, with the last error and the text (after 6 attempts).
A heartbeat webhookOne attempt, with no retries. A failure is logged as Failed straight away.
A test sendOne attempt, made while you wait. The result appears on the page and is logged with the trigger test.
  • Only the final result of a website alert is logged, so retries don't fill the log.
  • Each attempt is a new request. It has a new timestamp, and so a new signature. There's no delivery ID. When your server did the work but answered too slowly, the retry delivers the same alert a second time. De-duplicate on what matters to you, for example the event, the site id and the timestamp rounded to the minute.
  • Alerts are delivered in the background, so two alerts sent close together can arrive in either order. Use the timestamp to order them.
  • A website notification marks itself as sent when the delivery is queued. The repeat interval therefore counts from the queue time, and doesn't wait for your server.

Errors in the log

Error textWhat it means
Client error and a status such as '401 Unauthorized' for urlYour server refused the request. Check the endpoint's authentication and the custom headers.
Server error and a status such as '500 Internal Server Error' for urlYour server failed while handling the request.
Redirect response and a status such as '301 Moved Permanently' for urlThe URL redirects. Redirects aren't followed, so enter the final URL.
A timeout messageYour server took longer than 10 seconds to answer.
No webhook URL configuredThe notification's URL is empty. Edit it and enter one.