Webhook payload reference
The exact JSON Uptimeify POSTs to a generic webhook channel, the request headers it sends, and how to verify the HMAC signature.
A Webhook notification channel delivers every alert, recovery, reminder and warning for a customer to an HTTP endpoint you control. This page documents the payload of the generic webhook channel: the JSON body, the request headers, and signature verification.
Chat and on-call channels (Slack, Discord, Microsoft Teams, PagerDuty, Opsgenie, …) send a payload in that provider's own format instead. The body documented here is what a Webhook channel sends when no custom body template is configured.
The request
| Property | Value |
|---|---|
| Method | POST (configurable per channel) |
| Content type | application/json |
| Timeout | 30 seconds (configurable per channel) |
An endpoint that repeatedly fails to accept a connection gets a shorter timeout per attempt. Delivery is not stopped by this; only the cost of a failing attempt goes down.
Request headers
| Header | Meaning |
|---|---|
User-Agent | Identifies the Uptimeify monitoring fleet |
X-Webhook-Timestamp | Time the request was built, ISO 8601 (UTC) |
X-Webhook-Attempt | Attempt counter for this notification, starting at 1 |
X-Webhook-Signature | HMAC signature of the body, only when a secret is configured |
X-Webhook-Signature-Algorithm | sha256, sent alongside the signature |
Custom headers configured on the channel are merged in and win over the defaults.
Verifying the signature
When you configure a secret on the channel, we sign the exact bytes of the request body with
HMAC-SHA256 and send the result hex-encoded in X-Webhook-Signature. Verify against the raw body,
before any JSON parsing and re-serialisation, otherwise whitespace differences will break the
comparison.
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')
// Length check first: timingSafeEqual throws on differing lengths.
return a.length === b.length && crypto.timingSafeEqual(a, b)
}Payload
{
"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
}
}Top-level fields
| Field | Type | Notes |
|---|---|---|
type | string | alert, recovery, reminder, warning, unstable or slow |
check_degraded | boolean | true when the check could not reach a verdict, as opposed to a confirmed outage |
incident_id | number | Stable per incident, the same across alert, reminder and recovery |
message | string | One human-readable line, ready to post |
expected_response_error | string | null | Convenience copy of incident.error_details.expected_response_error |
ssl | object | null | Certificate details, set only for SSL incidents |
website | object | id, name, url, custom_fields |
customer | object | id, name, email, custom_fields |
incident | object | See below |
incident
| Field | Type | Notes |
|---|---|---|
type | string | See incident types below |
started_at | string | DD.MM.YYYY, HH:MM:SS in Europe/Berlin, or "N/A" |
resolved_at | string | Same format, "N/A" while the incident is open |
status | string | Incident status as stored, for example open or resolved |
ssl | object | null | Same object as the top-level ssl |
error_details | object | See below |
locations | string[] | The monitoring locations this notification covers |
screenshot | string | null | Base64 JPEG, only when a screenshot was captured |
screenshot_url | string | null | Link to the same screenshot |
diagnostics | object | null | Raw diagnostics of the failing check; contents vary by failure |
incident.error_details
| Field | Type | Notes |
|---|---|---|
check_degraded | boolean | Same value as the top-level field |
status_code | number | null | HTTP status; 0 means no response was received at all |
error_message | string | null | The failure as recorded |
expected_response_error | string | null | Set when an "Expected Response" rule failed |
response_time_ms | number | null | Response time of the failing check |
blocking_response | object | null | Who rejected the request, see below |
incident.type
http_status, downtime (delivered as down), response_time, https_redirect,
keyword_check, page_size, ssl_handshake, ssl_expiry, ssl_warning, domain_expiry,
domain_expiry_warning, dns.
For a service monitor, downtime is delivered as the protocol instead: icmp, smtp, ssh,
ftp or imap-pop.
blocking_response: who rejected the request
A 403, 406, 429 or 503 says that something refused the request. It does not say whether
your site is down or whether a web application firewall sorted our check out before it reached
your server. When the response carries headers that identify the sender, we pass them through in
this field. It is null for every other status, and also when the response named nobody.
| Field | Type | Notes |
|---|---|---|
status_code | number | The status that triggered the capture |
vendor | string | null | Named provider, see below. null when no header names one; we do not guess |
vendor_kind | string | null | waf, cdn, platform or server. null exactly when vendor is null |
headers | object | Header name to value, lowercase names |
counter_probe | object | Present only for vendor_kind: "waf", see below. Absent otherwise |
counter_probe: your address or our request?
When a firewall rejects a check, we retry once from the same address with a different client identity, at most once per target per hour. The answer separates two very different problems: an address that is blocked, and a request signature that is blocked.
| Field | Type | Notes |
|---|---|---|
status | number | null | Status of the second request, null if it never arrived |
verdict | string | address, request_signature or inconclusive |
address: rejected the same way despite the different identity, so the block does not appear to depend on how we identify ourselves.request_signature: the second request got through, so the rule targets the request rather than our address.inconclusive: the second request did not arrive; nothing is claimed.
The retry never changes the check result, and it does not disguise itself as a browser: it sends
Mozilla/5.0 (compatible; UptimeifyDiagnostics/1.0; +https://uptimeify.io/robot).
What vendor_kind tells you
A response often carries several identities at once, Cloudflare in front of nginx, Sucuri in front of Apache. We report the most specific sender and say what kind it is, in this order:
| Kind | Meaning | Examples |
|---|---|---|
waf | Bot management or web application firewall. The request was deliberately rejected. | Cloudflare, Sucuri, Imperva, DataDome, Kasada, Wallarm, Imunify360, DDoS-Guard, Qrator, Barracuda, F5 BIG-IP, FortiWeb, AWS WAF |
cdn | An edge or cache in front of the origin. May be blocking, may just be passing through. | Cloudflare, Akamai, Amazon CloudFront, Fastly, Bunny, Azure Front Door, KeyCDN, CacheFly, Myra, Google |
platform | Hosting or application platform answering on the customer's behalf. | Vercel, Netlify, Fly.io, Render, GitHub Pages, Shopify, Kinsta, WP Engine, Wix, Heroku, AWS ELB |
server | Plain server software at the end of the chain. | nginx, Apache, LiteSpeed, OpenResty, Varnish, Envoy, Caddy, HAProxy, Microsoft IIS, Tomcat |
A waf result answers a different question than a server result: the first means something in
front of your site turned our check away, the second means your own machine produced the status.
Captured headers
A fixed allowlist: 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 and authentication headers are never captured and never sent, and each value is truncated at 200 characters.
Use it to tell two situations apart that look identical in the status code alone:
const blocked = payload.incident?.error_details?.blocking_response
if (blocked) {
// Our check was rejected by an edge, the origin may be perfectly healthy.
notifyOps(`${blocked.vendor ?? 'An edge'} rejected the check with ${blocked.status_code}`)
}Delivery conditions and body templates
A webhook channel can carry conditions, in which case a notification is only delivered when the payload matches them, and a custom JSON body template, in which case your template replaces the body documented above. Both are configured on the channel in the app.