Webhook-Payload-Referenz
Das exakte JSON, das Uptimeify an einen generischen Webhook-Kanal sendet, die mitgeschickten Request-Header und die Prüfung der HMAC-Signatur.
Ein Webhook-Benachrichtigungskanal stellt jeden Alarm, jede Entwarnung, jede Erinnerung und jede Warnung eines Kunden an einen HTTP-Endpoint zu, den du kontrollierst. Diese Seite beschreibt die Nutzlast des generischen Webhook-Kanals: den JSON-Body, die Request-Header und die Signaturprüfung.
Chat- und On-Call-Kanäle (Slack, Discord, Microsoft Teams, PagerDuty, Opsgenie, …) senden stattdessen ein Format, das der jeweilige Anbieter erwartet. Der hier dokumentierte Body ist das, was ein Webhook-Kanal ohne eigenes Body-Template sendet.
Der Request
| Eigenschaft | Wert |
|---|---|
| Methode | POST (je Kanal einstellbar) |
| Content-Type | application/json |
| Zeitlimit | 30 Sekunden (je Kanal einstellbar) |
Ein Endpoint, der wiederholt nicht einmal eine Verbindung annimmt, bekommt pro Versuch ein kürzeres Zeitlimit. Die Zustellung wird dadurch nicht eingestellt, nur der Preis eines scheiternden Versuchs sinkt.
Request-Header
| Header | Bedeutung |
|---|---|
User-Agent | Weist die Anfrage als Uptimeify-Monitoring aus |
X-Webhook-Timestamp | Zeitpunkt, zu dem der Request gebaut wurde, ISO 8601 (UTC) |
X-Webhook-Attempt | Versuchszähler dieser Benachrichtigung, beginnend bei 1 |
X-Webhook-Signature | HMAC-Signatur des Bodys, nur wenn ein Secret hinterlegt ist |
X-Webhook-Signature-Algorithm | sha256, wird zusammen mit der Signatur gesendet |
Eigene Header, die am Kanal konfiguriert sind, werden ergänzt und stechen die Vorgaben.
Signatur prüfen
Hinterlegst du am Kanal ein Secret, signieren wir die exakten Bytes des Request-Bodys mit
HMAC-SHA256 und schicken das Ergebnis hex-kodiert in X-Webhook-Signature. Prüfe gegen den rohen
Body, bevor du ihn parst und neu serialisierst, sonst kippt der Vergleich schon an einem
Leerzeichen.
import crypto from 'node:crypto'
function isValidSignature(rawBody, headerValue, secret) {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex')
const a = Buffer.from(expected, 'utf8')
const b = Buffer.from(String(headerValue ?? ''), 'utf8')
// Längenprüfung zuerst: timingSafeEqual wirft bei ungleicher Länge.
return a.length === b.length && crypto.timingSafeEqual(a, b)
}Nutzlast
{
"type": "alert",
"check_degraded": false,
"incident_id": 8094,
"message": "Website Acme Shop is DOWN",
"expected_response_error": null,
"ssl": null,
"website": {
"id": 10597,
"name": "Acme Shop",
"url": "https://shop.example.com",
"custom_fields": {}
},
"customer": {
"id": 42,
"name": "Acme GmbH",
"email": "ops@example.com",
"custom_fields": {}
},
"incident": {
"type": "http_status",
"started_at": "19.08.2026, 17:32:04",
"resolved_at": "N/A",
"status": "open",
"ssl": null,
"error_details": {
"check_degraded": false,
"status_code": 503,
"error_message": "Unexpected status code (503) (expected: 200)",
"expected_response_error": null,
"response_time_ms": 412,
"blocking_response": {
"status_code": 503,
"vendor": "Cloudflare",
"vendor_kind": "cdn",
"headers": {
"server": "cloudflare",
"cf-ray": "a2daec8259183caa-CDG"
},
"counter_probe": {
"status": 200,
"verdict": "request_signature"
}
}
},
"locations": ["de-fra", "fi-hel", "pl-waw"],
"screenshot": null,
"screenshot_url": "https://…",
"diagnostics": null
}
}Felder auf oberster Ebene
| Feld | Typ | Anmerkung |
|---|---|---|
type | string | alert, recovery, reminder, warning, unstable oder slow |
check_degraded | boolean | true, wenn die Prüfung zu keinem Urteil kam, im Unterschied zu einem bestätigten Ausfall |
incident_id | number | Bleibt über Alarm, Erinnerung und Entwarnung hinweg gleich |
message | string | Eine lesbare Zeile, direkt weiterverwendbar |
expected_response_error | string | null | Bequeme Kopie von incident.error_details.expected_response_error |
ssl | object | null | Zertifikatsdetails, nur bei SSL-Vorfällen gesetzt |
website | object | id, name, url, custom_fields |
customer | object | id, name, email, custom_fields |
incident | object | Siehe unten |
incident
| Feld | Typ | Anmerkung |
|---|---|---|
type | string | Siehe Vorfalltypen unten |
started_at | string | TT.MM.JJJJ, HH:MM:SS in Europe/Berlin, sonst "N/A" |
resolved_at | string | Gleiches Format, "N/A" solange der Vorfall offen ist |
status | string | Vorfallstatus wie gespeichert, etwa open oder resolved |
ssl | object | null | Dasselbe Objekt wie ssl auf oberster Ebene |
error_details | object | Siehe unten |
locations | string[] | Die Standorte, die diese Benachrichtigung abdeckt |
screenshot | string | null | Base64-JPEG, nur wenn ein Screenshot entstanden ist |
screenshot_url | string | null | Link auf denselben Screenshot |
diagnostics | object | null | Rohdiagnose der fehlgeschlagenen Prüfung, Inhalt je nach Fehlerbild |
incident.error_details
| Feld | Typ | Anmerkung |
|---|---|---|
check_degraded | boolean | Derselbe Wert wie oben |
status_code | number | null | HTTP-Status; 0 heißt, es kam gar keine Antwort |
error_message | string | null | Der Fehlschlag, wie er aufgezeichnet wurde |
expected_response_error | string | null | Gesetzt, wenn eine „Erwartete Antwort"-Regel gerissen ist |
response_time_ms | number | null | Antwortzeit der fehlgeschlagenen Prüfung |
blocking_response | object | null | Wer die Anfrage abgewiesen hat, siehe unten |
incident.type
http_status, downtime (wird als down ausgeliefert), response_time, https_redirect,
keyword_check, page_size, ssl_handshake, ssl_expiry, ssl_warning, domain_expiry,
domain_expiry_warning, dns, rate_limited (Hinweis, dass Ihr Server unsere Prüfungen drosselt, nie ein Ausfall; siehe Vorfälle).
Bei einem Dienst-Monitor wird downtime stattdessen als Protokoll ausgeliefert: icmp, smtp,
ssh, ftp oder imap-pop.
blocking_response: wer die Anfrage abgewiesen hat
Ein 403, 406, 429 oder 503 sagt, dass etwas die Anfrage abgelehnt hat. Es sagt nicht, ob
deine Seite aus ist oder ob eine Web Application Firewall unsere Prüfung aussortiert hat, bevor sie
deinen Server erreichte. Trägt die Antwort Header, die den Absender benennen, reichen wir sie in
diesem Feld durch. Bei jedem anderen Status ist es null, ebenso wenn die Antwort niemanden
benannt hat.
| Feld | Typ | Anmerkung |
|---|---|---|
status_code | number | Der Status, der die Aufzeichnung ausgelöst hat |
vendor | string | null | Benannter Anbieter, siehe unten. null, wenn kein Header einen nennt, wir raten nicht |
vendor_kind | string | null | waf, cdn, platform oder server. null genau dann, wenn auch vendor null ist |
headers | object | Headername auf Wert, Namen kleingeschrieben |
counter_probe | object | Nur bei vendor_kind: "waf", siehe unten. Sonst nicht vorhanden |
counter_probe: deine Adresse oder unsere Anfrage?
Weist eine Firewall eine Prüfung ab, fassen wir einmal von derselben Adresse mit anderer Kennung nach, höchstens einmal je Ziel und Stunde. Die Antwort trennt zwei sehr verschiedene Probleme: eine gesperrte Adresse und eine gesperrte Anfragesignatur.
| Feld | Typ | Anmerkung |
|---|---|---|
status | number | null | Status des zweiten Versuchs, null wenn er nicht ankam |
verdict | string | address, request_signature oder inconclusive |
address: trotz anderer Kennung genauso abgewiesen, die Sperre hängt offenbar nicht daran, wie wir uns ausweisen.request_signature: der zweite Versuch kam durch, die Regel zielt auf die Anfrage statt auf unsere Adresse.inconclusive: der zweite Versuch kam nicht an, es wird nichts behauptet.
Der zweite Versuch ändert nie das Prüfergebnis, und er gibt sich nicht als Browser aus: er sendet
Mozilla/5.0 (compatible; UptimeifyDiagnostics/1.0; +https://uptimeify.io/robot).
Was vendor_kind beantwortet
Eine Antwort trägt oft mehrere Kennungen gleichzeitig, Cloudflare vor nginx, Sucuri vor Apache. Wir melden den spezifischsten Absender und dazu seine Art, in dieser Rangfolge:
| Art | Bedeutung | Beispiele |
|---|---|---|
waf | Bot-Management oder Web Application Firewall. Die Anfrage wurde bewusst abgelehnt. | Cloudflare, Sucuri, Imperva, DataDome, Kasada, Wallarm, Imunify360, DDoS-Guard, Qrator, Barracuda, F5 BIG-IP, FortiWeb, AWS WAF |
cdn | Ein Edge oder Cache vor dem Ursprung. Kann sperren, kann auch nur weiterreichen. | Cloudflare, Akamai, Amazon CloudFront, Fastly, Bunny, Azure Front Door, KeyCDN, CacheFly, Myra, Google |
platform | Hoster oder Anwendungsplattform, die für den Kunden antwortet. | Vercel, Netlify, Fly.io, Render, GitHub Pages, Shopify, Kinsta, WP Engine, Wix, Heroku, AWS ELB |
server | Reine Server-Software am Ende der Kette. | nginx, Apache, LiteSpeed, OpenResty, Varnish, Envoy, Caddy, HAProxy, Microsoft IIS, Tomcat |
Ein waf-Treffer beantwortet eine andere Frage als ein server-Treffer: Beim ersten hat etwas
VOR deiner Seite unsere Prüfung abgewiesen, beim zweiten hat deine eigene Maschine den Status
erzeugt.
Aufgezeichnete Header
Eine feste Liste: server, via, x-powered-by, retry-after, x-cache, x-cache-status,
x-served-by, x-cdn, x-request-id, cf-ray, cf-cache-status, cf-mitigated,
x-sucuri-id, x-sucuri-block, x-sucuri-cache, x-iinfo, x-cdn-forward,
x-akamai-transformed, x-akamai-request-id, akamai-grn, x-amz-cf-id, x-amz-cf-pop,
x-amzn-requestid, x-amzn-errortype, x-azure-ref, x-msedge-ref, x-fastly-request-id,
x-varnish, cdn-pullzone, cdn-requestid, cdn-cache, x-datadome, x-waf-event-id,
x-kasada-classification, x-wallarm-request-id, x-sophos-message, x-vercel-id,
x-vercel-cache, x-nf-request-id, fly-request-id, x-render-origin-server,
x-github-request-id, x-shopid, x-shopify-stage, x-kinsta-cache, x-litespeed-cache,
x-turbo-charged-by, x-envoy-upstream-service-time.
Cookies sowie Anmeldeheader werden nie aufgezeichnet und nie versendet, und jeder Wert wird bei 200 Zeichen gekappt.
Damit lassen sich zwei Lagen unterscheiden, die am Statuscode allein gleich aussehen:
const blocked = payload.incident?.error_details?.blocking_response
if (blocked) {
// Unsere Prüfung wurde am Rand abgewiesen, der Ursprung kann völlig gesund sein.
notifyOps(`${blocked.vendor ?? 'Ein Edge'} hat die Prüfung mit ${blocked.status_code} abgewiesen`)
}Bedingungen und Body-Templates
Ein Webhook-Kanal kann Bedingungen tragen, dann wird nur zugestellt, wenn die Nutzlast dazu passt, und ein eigenes JSON-Body-Template, dann ersetzt dein Template den oben beschriebenen Body. Beides stellst du am Kanal in der App ein.