---
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",
        "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:

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