Request
POST to your URL, content-type: application/json, 10-second timeout. Any non-2xx response is
recorded as a failed delivery; the incident and the monitor are unaffected.
Schema
{
"event": "opened" | "resolved",
"status": "down" | "up",
"attempt": 0, // 0 = transition alert, 1..n = reminder
"monitor": {
"id": "string",
"name": "string",
"type": "http" | "tcp" | "push" | "ssl" | "domain" | "dns" | "email-auth",
"target": "string" // URL, host:port, ping cadence, or domain
},
"incident": {
"id": "string",
"openedAt": 1754212345678, // epoch milliseconds
"resolvedAt": null, // epoch milliseconds, or null while open
"reason": "string",
"durationMs": null, // set on resolved, null while open
"externalCheck": null // "pending" | "agreed" | "disagreed"
// | "unavailable" | "skipped" | null
},
"text": "string",
"content": "string" // identical to text โ Discord compatibility
}
text and content are the same string. Slack renders text, Discord renders content, and
having both is what makes one endpoint work for either without configuration.
Example โ incident opened
{
"event": "opened",
"status": "down",
"attempt": 0,
"monitor": {
"id": "m97aqc31pvs1hhfvj5x9j4p5018bsct1",
"name": "api",
"type": "http",
"target": "https://api.example.com/health"
},
"incident": {
"id": "i97bm5dm7mzyvt92npa0n724918brspd",
"openedAt": 1754212345678,
"resolvedAt": null,
"reason": "connect failed: Connection refused (os error 111)",
"durationMs": null,
"externalCheck": "pending"
},
"text": "๐ด DOWN โ api (https://api.example.com/health): connect failed: Connection refused (os error 111)",
"content": "๐ด DOWN โ api (https://api.example.com/health): connect failed: Connection refused (os error 111)"
}
externalCheck is "pending" here because the second
opinion has not finished when the first alert is sent. That is
expected.
Example โ reminder
{
"event": "opened",
"status": "down",
"attempt": 2,
"monitor": {
"id": "m97aqc31pvs1hhfvj5x9j4p5018bsct1",
"name": "api",
"type": "http",
"target": "https://api.example.com/health"
},
"incident": {
"id": "i97bm5dm7mzyvt92npa0n724918brspd",
"openedAt": 1754212345678,
"resolvedAt": null,
"reason": "connect failed: Connection refused (os error 111)",
"durationMs": null,
"externalCheck": "agreed"
},
"text": "๐ด STILL DOWN (1h 5m) โ api (https://api.example.com/health): connect failed: Connection refused (os error 111) ยท confirmed unreachable from a second network",
"content": "๐ด STILL DOWN (1h 5m) โ api (https://api.example.com/health): connect failed: Connection refused (os error 111) ยท confirmed unreachable from a second network"
}
Note event is still "opened" โ the incident has not changed state. attempt is what
distinguishes a reminder, and it is the field to branch on.
Example โ resolved
{
"event": "resolved",
"status": "up",
"attempt": 0,
"monitor": {
"id": "m97aqc31pvs1hhfvj5x9j4p5018bsct1",
"name": "api",
"type": "http",
"target": "https://api.example.com/health"
},
"incident": {
"id": "i97bm5dm7mzyvt92npa0n724918brspd",
"openedAt": 1754212345678,
"resolvedAt": 1754216012345,
"reason": "connect failed: Connection refused (os error 111)",
"durationMs": 3666667,
"externalCheck": "agreed"
},
"text": "๐ข RECOVERED โ api (https://api.example.com/health) after 3667s",
"content": "๐ข RECOVERED โ api (https://api.example.com/health) after 3667s"
}
reason on a resolved incident is the reason it opened, retained for context.
The target field by monitor type
| Type | Format | Example |
|---|---|---|
http |
full URL | https://api.example.com/health |
tcp |
host:port |
db.example.com:5432 |
push |
ping cadence | push every 86400s |
ssl, domain, dns, email-auth |
domain | example.com |
Building a receiver
Deduplicate on incident.id + attempt. Together they are unique for a given
notification. WhatPing does not retry a delivery within the same attempt, but building on this
pair makes your receiver safe regardless.
Branch on attempt, not event, to distinguish a reminder from a first alert.
Do not treat externalCheck: "pending" as a verdict. It means the check has not finished.
Re-read the field on the next reminder if you need it.
Timestamps are epoch milliseconds, UTC.
Minimal receiver:
from flask import Flask, request
app = Flask(__name__)
@app.post("/whatping")
def hook():
p = request.get_json()
key = (p["incident"]["id"], p["attempt"])
if seen(key):
return "", 200
if p["status"] == "down":
page_someone(p["text"], first=p["attempt"] == 0)
else:
resolve(p["incident"]["id"])
return "", 200