---
title: "Webhook payload reference"
description: "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.

<Callout type="info">
  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.
</Callout>

## 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.

```js
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

```json
{
  "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`, `rate_limited` (a notice that your server rate-limits our checks, never an outage; see [Incidents](/incidents)).

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:

```js
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.
