Webhooks
Add a webhook
- In the app, open Alerts → Add channel → Webhook and enter your URL (HTTPS recommended).
- Click the key icon next to the channel to copy its signing secret (
whsec_…) into your receiver's configuration. - Click Send test. Your endpoint gets a
testevent; answer with any2xxstatus.
The request
Each alert is one POST with a JSON body and these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | SpotDowntime/1.0 (+alerts) |
X-SpotDowntime-Event | The event type, e.g. down. The same as event in the body. |
X-SpotDowntime-Delivery | A unique id for this alert, e.g. dlv_4f9a…. It stays the same when a delivery is retried. |
X-SpotDowntime-Signature | t=<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.
| event | Sent when | Extra fields |
|---|---|---|
down | A check fails twice in a row (retried after 2 seconds), or a heartbeat misses its deadline or reports /fail. | reason |
up | The monitor recovers. | down_for_seconds |
ssl_expiring | An HTTPS certificate expires within 14 days. Once per certificate. | ssl_expires_at |
incident_update | Someone on your team posts an incident update. | incident_id, update |
test | You click Send test. | none |
down
{
"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
{
"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
{
"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.
{
"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
{ "event": "test", "at": "2026-10-02T09:00:00.000Z", "url": "https://spotdowntime.com" }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:
- Read the raw body before parsing it: re-encoding the JSON changes the bytes and breaks the signature.
- Compute HMAC-SHA256 of
t + "." + bodywith the secret and compare it tov1in constant time. - Reject the request if
tis more than 5 minutes from now, so a captured request can't be replayed later.
Node.js (Express)
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)
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 "", 204Go
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
$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.