← All docs

Webhooks

A webhook channel sends every alert to your own URL as JSON: to open tickets, page someone, restart a service, or log outages wherever you like. Each request is signed, so you can check it really came from Spot Downtime. Webhooks are on every plan, including Free.

Add a webhook

  1. In the app, open Alerts → Add channel → Webhook and enter your URL (HTTPS recommended).
  2. Click the key icon next to the channel to copy its signing secret (whsec_…) into your receiver's configuration.
  3. Click Send test. Your endpoint gets a test event; answer with any 2xx status.

The request

Each alert is one POST with a JSON body and these headers:

HeaderValue
Content-Typeapplication/json
User-AgentSpotDowntime/1.0 (+alerts)
X-SpotDowntime-EventThe event type, e.g. down. The same as event in the body.
X-SpotDowntime-DeliveryA unique id for this alert, e.g. dlv_4f9a…. It stays the same when a delivery is retried.
X-SpotDowntime-Signaturet=<unix time>,v1=<signature>. See Verifying signatures.

Events and payloads

Every payload has event, at (when it happened, UTC, ISO 8601) and url (where to see it in Spot Downtime). Monitor events add a monitor object with its id, name and target.

eventSent whenExtra fields
downA check fails twice in a row (retried after 2 seconds), or a heartbeat misses its deadline or reports /fail.reason
upThe monitor recovers.down_for_seconds
ssl_expiringAn HTTPS certificate expires within 14 days. Once per certificate.ssl_expires_at
incident_updateSomeone on your team posts an incident update.incident_id, update
testYou click Send test.none

down

json
{
  "event": "down",
  "at": "2026-10-02T09:14:03.512Z",
  "url": "https://spotdowntime.com/app/monitors/8f1c2d4e-5a6b-4c7d-9e0f-1a2b3c4d5e6f",
  "monitor": { "id": "8f1c2d4e-5a6b-4c7d-9e0f-1a2b3c4d5e6f", "name": "API", "target": "https://api.example.com/health" },
  "reason": "HTTP 503"
}

up

json
{
  "event": "up",
  "at": "2026-10-02T09:21:40.077Z",
  "url": "https://spotdowntime.com/app/monitors/8f1c2d4e-5a6b-4c7d-9e0f-1a2b3c4d5e6f",
  "monitor": { "id": "8f1c2d4e-5a6b-4c7d-9e0f-1a2b3c4d5e6f", "name": "API", "target": "https://api.example.com/health" },
  "down_for_seconds": 457
}

ssl_expiring

json
{
  "event": "ssl_expiring",
  "at": "2026-10-02T06:00:12.940Z",
  "url": "https://spotdowntime.com/app/monitors/8f1c2d4e-5a6b-4c7d-9e0f-1a2b3c4d5e6f",
  "monitor": { "id": "8f1c2d4e-5a6b-4c7d-9e0f-1a2b3c4d5e6f", "name": "API", "target": "https://api.example.com/health" },
  "ssl_expires_at": "2026-10-14T23:59:59Z"
}

incident_update. url points to the incident. update.status is one of investigating, identified, monitoring, resolved or update. update.public is false for internal notes that don't appear on your status page.

json
{
  "event": "incident_update",
  "at": "2026-10-02T09:17:55.201Z",
  "url": "https://spotdowntime.com/app/incidents/1042",
  "monitor": { "id": "8f1c2d4e-5a6b-4c7d-9e0f-1a2b3c4d5e6f", "name": "API", "target": "https://api.example.com/health" },
  "incident_id": 1042,
  "update": {
    "status": "identified",
    "message": "A bad deploy; rolling back now.",
    "author": "[email protected]",
    "public": true
  }
}

test

json
{ "event": "test", "at": "2026-10-02T09:00:00.000Z", "url": "https://spotdowntime.com" }
We may add fields and event types over time. Ignore fields you don't know, and answer 2xx to events you don't handle.

Verifying signatures

X-SpotDowntime-Signature looks like t=1790932443,v1=5257a869…. v1 is the hex HMAC-SHA256 of <t>.<raw request body>, keyed with your channel's signing secret. To verify a request:

  1. Read the raw body before parsing it: re-encoding the JSON changes the bytes and breaks the signature.
  2. Compute HMAC-SHA256 of t + "." + body with the secret and compare it to v1 in constant time.
  3. Reject the request if t is more than 5 minutes from now, so a captured request can't be replayed later.

Node.js (Express)

javascript
import crypto from "node:crypto";
import express from "express";

const app = express();
const secret = process.env.SPOTDOWNTIME_WEBHOOK_SECRET; // whsec_…

app.post("/hooks/spotdowntime", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("X-SpotDowntime-Signature") ?? "";
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${req.body}`).digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  const valid =
    fresh &&
    typeof parts.v1 === "string" &&
    parts.v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  if (!valid) return res.status(401).end();

  const event = JSON.parse(req.body);
  // … handle event.event, e.g. "down" / "up"
  res.status(204).end();
});

Python (Flask)

python
import hashlib, hmac, os, time
from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["SPOTDOWNTIME_WEBHOOK_SECRET"].encode()

@app.post("/hooks/spotdowntime")
def spotdowntime():
    body = request.get_data()  # raw bytes
    parts = dict(p.split("=", 1) for p in request.headers.get("X-SpotDowntime-Signature", "").split(",") if "=" in p)
    expected = hmac.new(SECRET, f"{parts.get('t', '')}.".encode() + body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, parts.get("v1", "")) or abs(time.time() - int(parts.get("t", 0))) > 300:
        abort(401)
    event = request.get_json()
    # … handle event["event"]
    return "", 204

Go

go
func verify(r *http.Request, secret string) ([]byte, bool) {
	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil {
		return nil, false
	}
	var t, v1 string
	for _, part := range strings.Split(r.Header.Get("X-SpotDowntime-Signature"), ",") {
		k, v, _ := strings.Cut(part, "=")
		if k == "t" {
			t = v
		} else if k == "v1" {
			v1 = v
		}
	}
	ts, err := strconv.ParseInt(t, 10, 64)
	if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
		return nil, false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(t + "."))
	mac.Write(body)
	expected := hex.EncodeToString(mac.Sum(nil))
	return body, hmac.Equal([]byte(expected), []byte(v1))
}

PHP

php
$body = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_X_SPOTDOWNTIME_SIGNATURE'] ?? ''), $parts);
$expected = hash_hmac('sha256', ($parts['t'] ?? '') . '.' . $body, getenv('SPOTDOWNTIME_WEBHOOK_SECRET'));
if (!hash_equals($expected, $parts['v1'] ?? '') || abs(time() - (int) ($parts['t'] ?? 0)) > 300) {
    http_response_code(401);
    exit;
}
$event = json_decode($body, true);

Rotating the secret. Click the key icon on the channel, then Rotate secret. Deliveries are signed with the new secret right away, so update your receiver at the same time.

Responses, timeouts and retries

Answer with any 2xx status within 10 seconds. Do slow work after responding, e.g. in a queue.

If the request times out, can't connect, or gets a 429 or 5xx, we retry twice: after 5 seconds and after 30 more seconds. Other 4xx answers aren't retried: they usually mean the URL or receiver needs fixing. The channel shows the last error in Alerts.

A retry can arrive after your receiver already handled the first attempt (for example, if it timed out while processing). Use X-SpotDowntime-Delivery to ignore repeats. Events can also arrive slightly out of order, so compare at when order matters, e.g. a down and an up close together.

Security notes

  • Always verify the signature. Without it, anyone who learns your URL could send fake alerts.
  • Use an HTTPS URL, so alerts and their contents can't be read in transit.
  • Keep the signing secret on your server. We show it only to workspace owners and admins; the URL is masked for everyone else in the app.
  • Don't allowlist our IP addresses: they can change. Rely on the signature instead.

Next steps

Monitoring cron jobs? Pair webhooks with heartbeat monitors. Prefer a chat app? Slack, Teams, Discord, Telegram, Google Chat, Mattermost, Rocket.Chat, PagerDuty, Pushover and ntfy are built in under Alerts → Add channel.

Something missing or unclear? Tell us.