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",
"headers": {
"server": "cloudflare",
"cf-ray": "a2daec8259183caa-CDG"
}
}
},
"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, for example Cloudflare, Sucuri, Imperva, DataDome, Akamai, Amazon CloudFront, Fastly, Bunny, Varnish, Google. null when no header names one; we do not guess |
headers | object | Header name to value, lowercase names |
The captured headers are a fixed allowlist: server, via, retry-after, x-cache,
x-served-by, cf-ray, cf-cache-status, cf-mitigated, x-sucuri-id, x-sucuri-block,
x-iinfo, x-cdn, x-akamai-transformed, akamai-grn, x-amz-cf-id, x-amz-cf-pop,
x-datadome, x-request-id, x-waf-event-id, cdn-pullzone, cdn-requestid. 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.