# Webhooks

> Outbound, signed webhooks: get the signing secret, verify X-Up4-Signature in Node, Python, Go, PHP and Ruby with test vectors, delivery and retry rules, and every event with its payload.

Source: https://upfour.io/docs/api/webhooks

Subscribe once and the events you pick come to you, signed, so nobody can forge an “all clear”. This page is the whole contract: how you get the secret, how you verify a delivery, what up4 does when your endpoint fails, and what every event contains. Manage subscriptions under [/webhook-subscriptions](https://upfour.io/docs/api/webhook-subscriptions.md).

![A webhook-subscription request panel with an empty key field and example JSON containing a destination URL and three event types.](https://upfour.io/docs/images/webhook-subscription.webp)

**Set the destination and event types.** An example subscription in the reference’s Try it panel.

1. **API key** Use a key with webhook.write and access to the intended organization.
2. **Destination and events** Replace the example URL with your receiver and choose the events you need. Keep the returned subscription secret for signature verification.
3. **Create subscription** Send request creates the subscription. The response is shown below the button; no request was sent for this screenshot.

## Get the signing secret

Every subscription has its own secret, `whsec_` followed by 64 hex characters. `POST /v1/webhook-subscriptions` returns it as `data.secret`, **once**. List, update and every other read leave it out, and up4 cannot show it again, so store it with your other secrets right away. Creating a subscription sends nothing to the URL: up4 only checks the address (below) and stores the subscription. Use `POST /v1/webhook-subscriptions/{id}/test` to see a delivery arrive.

A URL that is not `http` or `https`, whose host does not resolve, or that resolves to a loopback, private or otherwise blocked address is refused with `400 bad_request`; the message says which (`webhook url resolves to a blocked address range` for a private or loopback address). Nothing is created.

With an [Idempotency-Key](https://upfour.io/docs/api/idempotency.md), a retry of the same create returns the stored response, **secret included**, for 24 hours: a repeat does not make a second subscription and does not make a second secret. That also holds after you rotated the secret in between, when the replay shows the earlier secret, which no longer signs. Keep the secret from the first answer, and use a new key for every subscription you really want to create.

```bash
curl -s -X POST https://app.upfour.io/v1/webhook-subscriptions \
  -H "Authorization: Bearer $UP4_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://ops.example.com/hooks/up4",
    "events": ["incident.opened", "incident.resolved"]
  }'

# 201
{
  "data": {
    "id": "…",
    "url": "https://ops.example.com/hooks/up4",
    "events": ["incident.opened", "incident.resolved"],
    "active": true,
    "failure_count": 0,
    "secret": "whsec_9f2c…",
    …
  }
}
```

Lost it, or leaked it? `POST /v1/webhook-subscriptions/{id}/rotate-secret` (same permission as updating a subscription, `webhook.write`) makes a new secret and returns it the same way, once. The URL, events and history stay.

**The old secret stops signing at once, there is no grace period.** The secret is read for every attempt, so a retry that was queued before the rotation is signed with the new one as well. To rotate without losing a delivery, first deploy a receiver that accepts either secret (call the verifier below with the new secret, then with the old one), then rotate, then remove the old one. Do not answer an unverifiable delivery with a 4xx while you switch: it ends that delivery for good (see [delivery and retries](https://upfour.io/docs/api/webhooks#delivery)). A `5xx` is retried.

## Verify a delivery

Every subscription delivery carries `X-Up4-Signature: t=<unix seconds>,v1=<hex>`. `v1` is the HMAC-SHA256, in lower-case hex, of the decimal `t`, a dot and the raw request body, keyed with the whole secret string, `whsec_` prefix included (do not hex-decode it). This is the format of webhook **subscriptions**; the [Webhook alert channel](https://upfour.io/docs/api/webhooks#alert-channel-webhooks) signs differently (`sha256=<hex>` over the body). A receiver has to:

1. Read the **raw body** exactly as it arrived. A body that was parsed and serialised again has other bytes and never verifies.
2. Rebuild the signature from `t` and the raw body and compare it in constant time with the `v1` value. A header with several `v1` values is valid if any of them matches. up4 sends one today; the snippets accept any so a change would not break you.
3. Reject a `t` more than 300 seconds away from your clock, in either direction. up4 does not enforce the window, it is your protection against replays. `t` is the time of that attempt, so a retry is signed anew and passes.

The snippets below return true or false and take the secret, the header, the raw body and, optionally, your clock. Each of them is run against every test vector (below) with the real interpreter before this page is published.

### Node verify.mjs

```javascript
import { createHmac, timingSafeEqual } from "node:crypto";

// secret:  the whsec_… string, whole, as the create or rotate call returned it
// header:  the X-Up4-Signature header, "t=<unix>,v1=<hex>"
// rawBody: the request body exactly as received (a Buffer), before JSON.parse
export function verifyUp4Signature(secret, header, rawBody, now = Math.floor(Date.now() / 1000), tolerance = 300) {
  let timestamp = null;
  const signatures = [];
  for (const part of String(header ?? "").split(",")) {
    const at = part.indexOf("=");
    const key = at < 0 ? part : part.slice(0, at);
    const value = at < 0 ? "" : part.slice(at + 1);
    if (key === "t") timestamp = value;
    if (key === "v1") signatures.push(value);
  }
  if (timestamp === null || !/^[0-9]+$/.test(timestamp) || signatures.length === 0) return false;
  if (Math.abs(now - Number(timestamp)) > tolerance) return false;

  const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
  return signatures.some(
    (signature) => /^[0-9a-f]{64}$/i.test(signature) && timingSafeEqual(expected, Buffer.from(signature, "hex")),
  );
}
```

### Python verify.py

```python
import hashlib
import hmac
import re
import time

def verify_up4_signature(secret, header, raw_body, now=None, tolerance=300):
    """Check X-Up4-Signature.

    secret:   the whsec_... string, whole, as the create or rotate call returned it
    header:   the X-Up4-Signature header, "t=<unix>,v1=<hex>"
    raw_body: the request body exactly as received (bytes), before json.loads
    """
    timestamp, signatures = None, []
    for part in (header or "").split(","):
        key, _, value = part.partition("=")
        if key == "t":
            timestamp = value
        elif key == "v1":
            signatures.append(value)
    if timestamp is None or not re.fullmatch(r"[0-9]+", timestamp) or not signatures:
        return False
    if abs((time.time() if now is None else now) - int(timestamp)) > tolerance:
        return False

    message = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest().encode()
    return any(hmac.compare_digest(expected, signature.lower().encode()) for signature in signatures)
```

### Go verify.go

```go
package up4webhook

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"strings"
	"time"
)

// Verify reports whether header, the X-Up4-Signature value "t=<unix>,v1=<hex>",
// matches rawBody. secret is the whole whsec_... string as the create or rotate
// call returned it; rawBody is the request body exactly as received, before any
// JSON decoding.
func Verify(secret, header string, rawBody []byte, now time.Time) bool {
	const tolerance = 300 * time.Second
	var timestamp string
	var signatures []string
	for _, part := range strings.Split(header, ",") {
		key, value, _ := strings.Cut(part, "=")
		switch key {
		case "t":
			timestamp = value
		case "v1":
			signatures = append(signatures, value)
		}
	}
	if timestamp == "" || strings.Trim(timestamp, "0123456789") != "" || len(signatures) == 0 {
		return false
	}
	var seconds int64
	for _, digit := range timestamp {
		if seconds > (1<<62)/10 {
			return false
		}
		seconds = seconds*10 + int64(digit-'0')
	}
	if age := now.Sub(time.Unix(seconds, 0)); age > tolerance || age < -tolerance {
		return false
	}

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(timestamp + "."))
	mac.Write(rawBody)
	expected := mac.Sum(nil)
	for _, signature := range signatures {
		if got, err := hex.DecodeString(signature); err == nil && hmac.Equal(got, expected) {
			return true
		}
	}
	return false
}
```

### PHP verify.php

```php
<?php

/**
 * $secret:  the whsec_... string, whole, as the create or rotate call returned it
 * $header:  the X-Up4-Signature header, "t=<unix>,v1=<hex>"
 * $rawBody: the request body exactly as received: file_get_contents('php://input')
 */
function verify_up4_signature(string $secret, string $header, string $rawBody, ?int $now = null, int $tolerance = 300): bool
{
    $timestamp = null;
    $signatures = [];
    foreach (explode(',', $header) as $part) {
        [$key, $value] = array_pad(explode('=', $part, 2), 2, '');
        if ($key === 't') {
            $timestamp = $value;
        } elseif ($key === 'v1') {
            $signatures[] = $value;
        }
    }
    if ($timestamp === null || !ctype_digit($timestamp) || $signatures === []) {
        return false;
    }
    if (abs(($now ?? time()) - (int) $timestamp) > $tolerance) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    foreach ($signatures as $signature) {
        if (hash_equals($expected, strtolower($signature))) {
            return true;
        }
    }
    return false;
}
```

### Ruby verify.rb

```ruby
require "openssl"

# secret:   the whsec_... string, whole, as the create or rotate call returned it
# header:   the X-Up4-Signature header, "t=<unix>,v1=<hex>"
# raw_body: the request body exactly as received (request.body.read), before JSON.parse
def verify_up4_signature(secret, header, raw_body, now = Time.now.to_i, tolerance = 300)
  timestamp = nil
  signatures = []
  header.to_s.split(",").each do |part|
    key, value = part.split("=", 2)
    timestamp = value.to_s if key == "t"
    signatures << value.to_s if key == "v1"
  end
  return false if timestamp.nil? || timestamp !~ /\A[0-9]+\z/ || signatures.empty?
  return false if (now - timestamp.to_i).abs > tolerance

  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.".b + raw_body.b)
  signatures.any? { |signature| secure_compare(expected, signature.downcase) }
end

# Constant-time comparison (OpenSSL.fixed_length_secure_compare on Ruby 3.0+).
def secure_compare(a, b)
  return false unless a.bytesize == b.bytesize
  a.bytes.zip(b.bytes).reduce(0) { |acc, (x, y)| acc | (x ^ y) }.zero?
end
```

### Test vectors

[webhook-test-vectors.json](https://upfour.io/api/webhook-test-vectors.json) holds a fixed secret, timestamp and raw body, the header up4 would send, and cases that a correct verifier answers as listed: valid (also with non-ASCII bytes and with a second `v1` value), wrong secret, the secret without its prefix, a body changed by one bit, the same JSON re-serialised, a timestamp 301 seconds old or ahead, a replaced timestamp, and malformed headers. Each case has its own `secret`, `header`, `body`, `now` (the receiver's clock, Unix seconds) and the expected `valid`. The signatures come from the code that signs real deliveries. Run your verifier over every case before you point it at a live endpoint.

The file is one JSON object. At the top level, `description` says how it was made and `algorithm` repeats the signing rule in one sentence. `tolerance_seconds` is the replay window (300). `secret`, `timestamp` (Unix seconds), `body` (the raw body as one JSON string), `signature` (the lower-case hex `v1`) and `header` (the complete `X-Up4-Signature` value) are one signed delivery you can check by hand. `cases` is an array of objects with `name`, `description`, `secret`, `header`, `body`, `now` and `valid`. A case's `body` is a JSON string that holds the request body exactly as it would arrive: the string value, UTF-8 encoded, is the byte sequence to hash. Do not parse it as JSON and serialise it again; that is what the case `invalid_body_reserialised` tests. Run `verify(secret, header, body, now)` for each case and expect `valid`.

## What up4 sends

Each delivery is a `POST` with a JSON body (payload version 2) and these headers:

| `Content-Type` | Always application/json. |
| --- | --- |
| `User-Agent` | up4-webhooks/1.0. |
| `X-Up4-Event` | The event name, the same as type in the body. |
| `X-Up4-Delivery` | ID of the delivery (a UUID). The same on every retry of one delivery; a manual redelivery is a new delivery with a new ID. |
| `X-Up4-Signature` | t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>." followed by the raw body, keyed with the subscription secret>. Built again for every attempt, so t is the time of that attempt. |

The body is a short summary, never the whole resource. Around `data` it has these fields. `payload_version`, `type` and `app_url` are in every delivery; `id` and `created_at` are set on every event up4 publishes today and are left out only when the event it was built from carried none:

| Field | Type | Description |
| --- | --- | --- |
| `app_url` | string (uri) | Link to the resource in the up4 app (signing in is required to open it). Just the application's base URL when the event names no resource. |
| `created_at` | string (date-time) | When up4 published the event, RFC 3339 in UTC. Left out when empty. |
| `id` | string (uuid) | ID of the event, a UUID. It is the same on every attempt, retry and manual redelivery of an event, so it is the key for dropping duplicates. The delivery ID is the X-Up4-Delivery header. Left out when empty. |
| `payload_version` | integer | Version of the body layout, always 2. |
| `type` | string | The event name, the same as the X-Up4-Event header. |
| `data` | object | The fields of the event, listed per event below. An allowlist: resource ids, names and titles up to 256 characters, states, timestamps, a safe cause, your own metadata, and on status-page events the text you published. Nothing else is sent. |

Use `app_url` or the API for anything the event does not carry, such as a monitor's name. Your [custom metadata](https://upfour.io/docs/watch-a-website.md) travels with `incident.*`, `heartbeat.*` and `monitor.status_changed` as the string object `data.metadata`. An incident's metadata is copied from its monitor or job when it opens.

## Delivery and retries

| Timeout | 10 seconds for one attempt in total, redirects included. Answer fast and do the work afterwards. |
| --- | --- |
| Success | Any `2xx` from the final URL. The response body is discarded; only the status is kept. |
| Redirects | Followed, up to 4, and every target is checked like the subscription URL. A `301`, `302` or `303` turns the POST into a GET without body, so answer at the registered URL. |
| Retried | Timeouts, network errors, `5xx`, `408`, `429` and any other status that is not `2xx` or `4xx`. For `429` and `503` a `Retry-After` header (seconds or HTTP date) postpones the next attempt, never earlier than the schedule, at most 24 hours. |
| Not retried | Every other `4xx`: the delivery ends as `failed`. A destination that resolves to a blocked address also fails at once. |
| Schedule | At most 7 attempts per delivery: the first one right away, then after 30 seconds, 5 minutes, 30 minutes, 2 hours, 12 hours, 24 hours, each counted from the previous attempt. That is about 39 hours from first to last. A retry starts at its time or up to a few seconds later; up4 checks for due retries every 15 seconds. An attempt is counted before it is sent, so a delivery cut short by a restart is retried after about two minutes and still never goes past 7 attempts. |
| Switched off | A subscription gets `active: false` and `disabled_at` when a delivery fails its last attempt, when your endpoint answers `410 Gone`, when the URL resolves to a blocked address, or when a `4xx` arrives while `failure_count` is already 6 or more. `failure_count` counts failed attempts since the last successful delivery and goes back to 0 on the next `2xx`. |
| Switched on again | `PUT /v1/webhook-subscriptions/{id}` with `active: true`, which is also the default when the body leaves `active` out. That clears `disabled_at`; it does not reset `failure_count`. Retries queued while it was off run then, up to their attempt limit. **Events that happened while it was off are not sent afterwards.** |
| Order | No guarantee. Deliveries are independent, and a retry of an older event can arrive after a newer event. Use `created_at` in the body to order them. |
| Duplicates | At least once. `X-Up4-Delivery` is the same on every retry of one delivery. A manual redelivery is a new delivery with a new `X-Up4-Delivery` but the same body `id`, and an event can also reach you twice if up4 has to hand it out again, so deduplicate on the body's `id`. |
| Test | `POST /v1/webhook-subscriptions/{id}/test` sends one `webhook.test` to that subscription, whatever its `events` say. It must be active. The call answers `202` with the delivery record; limited to 100 per Company and day. |
| Redelivery | `POST /v1/webhook-subscriptions/{id}/redeliver/{deliveryID}` sends the stored body of an earlier delivery again, right away, as a new delivery with the usual retries. The subscription must be active. 100 per Company and day. |
| History | `GET /v1/webhook-subscriptions/{id}/deliveries`, newest first, `limit` up to 250 (default 50). Each record has `status` (`pending`, `retrying`, `delivered`, `failed`), the `attempt` number, `response_code` and `next_retry_at`. Delivered and failed records are deleted after 30 days. |
| Destinations | Loopback, link-local, multicast, unspecified and private (RFC 1918) addresses are refused when you save the URL, before every attempt and when connecting. A URL needs `http` or `https`. Use `POST …/test` to try a receiver end to end. |

## Choose events

A subscription's `events` is a list of event names from the catalogue below, at least one. The service does not check the names in events. A name that no event carries is stored as given and never matches, and the request still succeeds. Names are compared exactly (case-sensitive, no wildcards); blanks and duplicates are dropped. A typo therefore creates a subscription that never fires, so check the names against this list:

`monitor.created` `monitor.updated` `monitor.deleted` `monitor.status_changed` `monitor.degraded_changed` `monitor.unstable` `monitors.bulk_updated` `incident.opened` `incident.acknowledged` `incident.resolved` `incident.reopened` `incident.escalated` `incident.propagated` `incident.partially_recovered` `heartbeat.missed` `heartbeat.recovered` `status_page.update_published` `status_page.report_published` `webhook.test`

`webhook.test` is never published on its own; it exists for the test call. A draft status-page post sends nothing; the event goes out when you publish it.

## Event catalogue

19 events. Every example is the body up4 sends for a representative event, produced by the same code that builds real deliveries. **always** marks a field that is in every delivery of the event; **when set** fields are left out when there is nothing to send. The shared fields (`payload_version`, `type`, `app_url`, `id`, `created_at`) are not repeated.

### `monitor.created`

A monitor is created with POST /v1/monitors or in the app. Monitors made by a batch import send no event.

`data` is an empty object. The event says that something happened and not to which monitor; read the monitor list with the API if you need it.

```json
{
  "payload_version": 2,
  "type": "monitor.created",
  "app_url": "https://app.upfour.io",
  "id": "9d1b2c3e-4f5a-4b6c-8d7e-0a1b2c3d4e5f",
  "created_at": "2026-10-24T09:00:00Z",
  "data": {}
}
```

### `monitor.updated`

A monitor is changed with PUT or PATCH, or paused or resumed.

`data` is an empty object. The event says that something happened and not to which monitor; read the monitor list with the API if you need it.

```json
{
  "payload_version": 2,
  "type": "monitor.updated",
  "app_url": "https://app.upfour.io",
  "id": "9d1b2c3e-4f5a-4b6c-8d7e-0a1b2c3d4e5f",
  "created_at": "2026-10-24T09:00:00Z",
  "data": {}
}
```

### `monitor.deleted`

A monitor is deleted.

| data field | Type | Description |
| --- | --- | --- |
| `monitor_id` | string (uuid) always | The monitor the event is about. |

```json
{
  "payload_version": 2,
  "type": "monitor.deleted",
  "app_url": "https://app.upfour.io/monitors/11111111-1111-4111-8111-111111111111",
  "id": "9d1b2c3e-4f5a-4b6c-8d7e-0a1b2c3d4e5f",
  "created_at": "2026-10-24T09:00:00Z",
  "data": {
    "monitor_id": "11111111-1111-4111-8111-111111111111"
  }
}
```

### `monitor.status_changed`

A monitor changes between UP and DOWN once three consistent observations confirm it (the first differing check plus two instant re-checks, and the confirmation or recovery period if one is set).

| data field | Type | Description |
| --- | --- | --- |
| `metadata` | object of strings when set | Custom key/value pairs of the monitor, scheduled job or incident, as set with the metadata field or PUT /v1/metadata. Left out when there are none. |
| `monitor_id` | string (uuid) always | The monitor the event is about. |
| `state` | string always | The state the monitor changed to. One of `UP`, `DOWN`. |

```json
{
  "payload_version": 2,
  "type": "monitor.status_changed",
  "app_url": "https://app.upfour.io/monitors/11111111-1111-4111-8111-111111111111",
  "id": "9d1b2c3e-4f5a-4b6c-8d7e-0a1b2c3d4e5f",
  "created_at": "2026-10-24T09:00:00Z",
  "data": {
    "metadata": {
      "customer_no": "K-4711"
    },
    "monitor_id": "11111111-1111-4111-8111-111111111111",
    "state": "DOWN"
  }
}
```

### `monitor.degraded_changed`

A monitor with a slow-response alert starts answering slowly (is_degraded true) or is fast again (false), after three consecutive slow or fast checks.

| data field | Type | Description |
| --- | --- | --- |
| `is_degraded` | boolean always | true when the monitor now answers slowly, false when it is fast again. |
| `monitor_id` | string (uuid) always | The monitor the event is about. |

```json
{
  "payload_version": 2,
  "type": "monitor.degraded_changed",
  "app_url": "https://app.upfour.io/monitors/11111111-1111-4111-8111-111111111111",
  "id": "9d1b2c3e-4f5a-4b6c-8d7e-0a1b2c3d4e5f",
  "created_at": "2026-10-24T09:00:00Z",
  "data": {
    "is_degraded": true,
    "monitor_id": "11111111-1111-4111-8111-111111111111"
  }
}
```

### `monitor.unstable`

A monitor flaps often enough that up4 stops alerting on every change and treats it as unstable until it has stayed healthy for the recovery window.

| data field | Type | Description |
| --- | --- | --- |
| `monitor_id` | string (uuid) always | The monitor the event is about. |

```json
{
  "payload_version": 2,
  "type": "monitor.unstable",
  "app_url": "https://app.upfour.io/monitors/11111111-1111-4111-8111-111111111111",
  "id": "9d1b2c3e-4f5a-4b6c-8d7e-0a1b2c3d4e5f",
  "created_at": "2026-10-24T09:00:00Z",
  "data": {
    "monitor_id": "11111111-1111-4111-8111-111111111111"
  }
}
```

### `monitors.bulk_updated`

A bulk change (POST /v1/monitors/bulk) changed at least one monitor. One event for the whole call, with the number of monitors changed.

| data field | Type | Description |
| --- | --- | --- |
| `updated` | integer always | Number of monitors the call changed. |

```json
{
  "payload_version": 2,
  "type": "monitors.bulk_updated",
  "app_url": "https://app.upfour.io",
  "id": "9d1b2c3e-4f5a-4b6c-8d7e-0a1b2c3d4e5f",
  "created_at": "2026-10-24T09:00:00Z",
  "data": {
    "updated": 12
  }
}
```

### `incident.opened`

An incident opens: a monitor goes DOWN, a scheduled job misses a check-in or reports a failed run, or someone creates one with POST /v1/incidents.

| data field | Type | Description |
| --- | --- | --- |
| `cause` | string always | Why the incident opened, a short diagnostic such as HTTP 503\. May be an empty string. |
| `incident_id` | string (uuid) always | The incident the event is about. |
| `metadata` | object of strings when set | Custom key/value pairs of the monitor, scheduled job or incident, as set with the metadata field or PUT /v1/metadata. Left out when there are none. |
| `severity` | string when set | Severity of the incident. Left out for a warning incident: warning is not part of the allowlist, so a receiver cannot tell it from an incident without a severity. One of `critical`, `info`. |
| `status` | string always | Status of the incident after the event. One of `open`, `acknowledged`, `resolved`. |
| `title` | string always | Title of the incident, at most 256 characters. |

```json
{
  "payload_version": 2,
  "type": "incident.opened",
  "app_url": "https://app.upfour.io/incidents/33333333-3333-4333-8333-333333333333",
  "id": "9d1b2c3e-4f5a-4b6c-8d7e-0a1b2c3d4e5f",
  "created_at": "2026-10-24T09:00:00Z",
  "data": {
    "cause": "HTTP 503",
    "incident_id": "33333333-3333-4333-8333-333333333333",
    "metadata": {
      "customer_no": "K-4711"
    },
    "severity": "critical",
    "status": "open",
    "title": "Checkout is down"
  }
}
```

### `incident.acknowledged`

Someone acknowledges an incident (POST /v1/incidents/{id}/acknowledge).

| data field | Type | Description |
| --- | --- | --- |
| `cause` | string always | Why the incident opened, a short diagnostic such as HTTP 503\. May be an empty string. |
| `incident_id` | string (uuid) always | The incident the event is about. |
| `metadata` | object of strings when set | Custom key/value pairs of the monitor, scheduled job or incident, as set with the metadata field or PUT /v1/metadata. Left out when there are none. |
| `severity` | string when set | Severity of the incident. Left out for a warning incident: warning is not part of the allowlist, so a receiver cannot tell it from an incident without a severity. One of `critical`, `info`. |
| `status` | string always | Status of the incident after the event. One of `open`, `acknowledged`, `resolved`. |
| `title` | string always | Title of the incident, at most 256 characters. |

```json
{
  "payload_version": 2,
  "type": "incident.acknowledged",
  "app_url": "https://app.upfour.io/incidents/33333333-3333-4333-8333-333333333333",
  "id": "9d1b2c3e-4f5a-4b6c-8d7e-0a1b2c3d4e5f",
  "created_at": "2026-10-24T09:00:00Z",
  "data": {
    "cause": "HTTP 503",
    "incident_id": "33333333-3333-4333-8333-333333333333",
    "severity": "critical",
    "status": "acknowledged",
    "title": "Checkout is down"
  }
}
```

### `incident.resolved`

An incident is closed: its monitor or scheduled job recovered, someone resolved it with POST /v1/incidents/{id}/resolve, or monitoring resumed after a billing pause (then recovered is false).

| data field | Type | Description |
| --- | --- | --- |
| `cause` | string when set | Why the incident opened. Only on a resolve that sends the incident's own fields. |
| `heartbeat_id` | string (uuid) when set | The scheduled job whose check-in closed the incident, or whose incident was closed when monitoring resumed. |
| `incident_id` | string (uuid) always | The incident the event is about. |
| `metadata` | object of strings when set | Custom key/value pairs of the monitor, scheduled job or incident, as set with the metadata field or PUT /v1/metadata. Left out when there are none. |
| `monitor_id` | string (uuid) when set | The monitor whose recovery closed the incident, or whose incident was closed when monitoring resumed. |
| `recovered` | boolean when set | false when monitoring resumed after a billing pause closed the incident: nothing was measured in between, so the end of the outage is unknown. Absent on every other resolve. |
| `resolution` | string when set | Only when monitoring resumed after a billing pause closed the incident. One of `monitoring_resumed`. |
| `resolved_at` | string (date-time) when set | When the incident was closed, RFC 3339 in UTC. Only on a resolve that sends the incident's own fields. |
| `resolved_by_type` | string when set | What closed the incident: monitor or heartbeat (the source recovered), user (someone resolved it), billing_resume (monitoring resumed after a billing pause) or system. Only on a resolve that sends the incident's own fields. One of `billing_resume`, `monitor`, `heartbeat`, `user`, `system`. |
| `severity` | string when set | Severity of the incident. Left out for a warning incident: warning is not part of the allowlist, so a receiver cannot tell it from an incident without a severity. One of `critical`, `info`. |
| `status` | string when set | Status of the incident, resolved. Only on a resolve that sends the incident's own fields. One of `open`, `acknowledged`, `resolved`. |
| `title` | string when set | Title of the incident, at most 256 characters. Only on a resolve that sends the incident's own fields. |

**Monitor recovered**

```json
{
  "payload_version": 2,
  "type": "incident.resolved",
  "app_url": "https://app.upfour.io/incidents/33333333-3333-4333-8333-333333333333",
  "id": "9d1b2c3e-4f5a-4b6c-8d7e-0a1b2c3d4e5f",
  "created_at": "2026-10-24T09:00:00Z",
  "data": {
    "incident_id": "33333333-3333-4333-8333-333333333333",
    "monitor_id": "11111111-1111-4111-8111-111111111111"
  }
}
```

**Resolved through the API**

```json
{
  "payload_version": 2,
  "type": "incident.resolved",
  "app_url": "https://app.upfour.io/incidents/33333333-3333-4333-8333-333333333333",
  "id": "9d1b2c3e-4f5a-4b6c-8d7e-0a1b2c3d4e5f",
  "created_at": "2026-10-24T09:00:00Z",
  "data": {
    "cause": "HTTP 503",
    "incident_id": "33333333-3333-4333-8333-333333333333",
    "resolved_at": "2026-10-24T09:00:00Z",
    "resolved_by_type": "user",
    "severity": "critical",
    "status": "resolved",
    "title": "Checkout is down"
  }
}
```

**Closed when monitoring resumed after a billing pause**

```json
{
  "payload_version": 2,
  "type": "incident.resolved",
  "app_url": "https://app.upfour.io/incidents/33333333-3333-4333-8333-333333333333",
  "id": "9d1b2c3e-4f5a-4b6c-8d7e-0a1b2c3d4e5f",
  "created_at": "2026-10-24T09:00:00Z",
  "data": {
    "cause": "HTTP 503",
    "incident_id": "33333333-3333-4333-8333-333333333333",
    "monitor_id": "11111111-1111-4111-8111-111111111111",
    "recovered": false,
    "resolution": "monitoring_resumed",
    "resolved_at": "2026-10-24T09:00:00Z",
    "resolved_by_type": "billing_resume",
    "severity": "critical",
    "status": "resolved",
    "title": "Checkout is down"
  }
}
```

### `incident.reopened`

Someone reopens a resolved incident (POST /v1/incidents/{id}/reopen).

| data field | Type | Description |
| --- | --- | --- |
| `cause` | string always | Why the incident opened, a short diagnostic such as HTTP 503\. May be an empty string. |
| `incident_id` | string (uuid) always | The incident the event is about. |
| `metadata` | object of strings when set | Custom key/value pairs of the monitor, scheduled job or incident, as set with the metadata field or PUT /v1/metadata. Left out when there are none. |
| `severity` | string when set | Severity of the incident. Left out for a warning incident: warning is not part of the allowlist, so a receiver cannot tell it from an incident without a severity. One of `critical`, `info`. |
| `status` | string always | Status of the incident after the event. One of `open`, `acknowledged`, `resolved`. |
| `title` | string always | Title of the incident, at most 256 characters. |

```json
{
  "payload_version": 2,
  "type": "incident.reopened",
  "app_url": "https://app.upfour.io/incidents/33333333-3333-4333-8333-333333333333",
  "id": "9d1b2c3e-4f5a-4b6c-8d7e-0a1b2c3d4e5f",
  "created_at": "2026-10-24T09:00:00Z",
  "data": {
    "cause": "HTTP 503",
    "incident_id": "33333333-3333-4333-8333-333333333333",
    "severity": "critical",
    "status": "open",
    "title": "Checkout is down"
  }
}
```

### `incident.escalated`

Someone escalates an incident by hand (POST /v1/incidents/{id}/escalate).

| data field | Type | Description |
| --- | --- | --- |
| `cause` | string always | Why the incident opened, a short diagnostic such as HTTP 503\. May be an empty string. |
| `incident_id` | string (uuid) always | The incident the event is about. |
| `metadata` | object of strings when set | Custom key/value pairs of the monitor, scheduled job or incident, as set with the metadata field or PUT /v1/metadata. Left out when there are none. |
| `severity` | string when set | Severity of the incident. Left out for a warning incident: warning is not part of the allowlist, so a receiver cannot tell it from an incident without a severity. One of `critical`, `info`. |
| `status` | string always | Status of the incident after the event. One of `open`, `acknowledged`, `resolved`. |
| `title` | string always | Title of the incident, at most 256 characters. |

```json
{
  "payload_version": 2,
  "type": "incident.escalated",
  "app_url": "https://app.upfour.io/incidents/33333333-3333-4333-8333-333333333333",
  "id": "9d1b2c3e-4f5a-4b6c-8d7e-0a1b2c3d4e5f",
  "created_at": "2026-10-24T09:00:00Z",
  "data": {
    "cause": "HTTP 503",
    "incident_id": "33333333-3333-4333-8333-333333333333",
    "severity": "critical",
    "status": "open",
    "title": "Checkout is down"
  }
}
```

### `incident.propagated`

The incident correlation engine adds an incident to a group of correlated incidents, so two or more incidents are now handled as one wider outage. Sent for the incident that joins.

| data field | Type | Description |
| --- | --- | --- |
| `incident_id` | string (uuid) always | The incident the event is about. |
| `metadata` | object of strings when set | Custom key/value pairs of the monitor, scheduled job or incident, as set with the metadata field or PUT /v1/metadata. Left out when there are none. |

```json
{
  "payload_version": 2,
  "type": "incident.propagated",
  "app_url": "https://app.upfour.io/incidents/33333333-3333-4333-8333-333333333333",
  "id": "9d1b2c3e-4f5a-4b6c-8d7e-0a1b2c3d4e5f",
  "created_at": "2026-10-24T09:00:00Z",
  "data": {
    "incident_id": "33333333-3333-4333-8333-333333333333"
  }
}
```

### `incident.partially_recovered`

Some, but not all, incidents of a correlated incident group have recovered.

| data field | Type | Description |
| --- | --- | --- |
| `incident_id` | string (uuid) always | The incident the event is about. |
| `metadata` | object of strings when set | Custom key/value pairs of the monitor, scheduled job or incident, as set with the metadata field or PUT /v1/metadata. Left out when there are none. |

```json
{
  "payload_version": 2,
  "type": "incident.partially_recovered",
  "app_url": "https://app.upfour.io/incidents/33333333-3333-4333-8333-333333333333",
  "id": "9d1b2c3e-4f5a-4b6c-8d7e-0a1b2c3d4e5f",
  "created_at": "2026-10-24T09:00:00Z",
  "data": {
    "incident_id": "33333333-3333-4333-8333-333333333333"
  }
}
```

### `heartbeat.missed`

A scheduled job misses its check-in (expected interval plus grace period) or reports a failed run. Sent once when the job turns down, not for every missed interval.

| data field | Type | Description |
| --- | --- | --- |
| `heartbeat_id` | string (uuid) always | The scheduled job the event is about. |
| `metadata` | object of strings when set | Custom key/value pairs of the monitor, scheduled job or incident, as set with the metadata field or PUT /v1/metadata. Left out when there are none. |
| `name` | string always | Name of the scheduled job, at most 256 characters. |
| `status` | string always | down for heartbeat.missed, up for heartbeat.recovered. One of `down`, `up`. |

```json
{
  "payload_version": 2,
  "type": "heartbeat.missed",
  "app_url": "https://app.upfour.io/heartbeats/22222222-2222-4222-8222-222222222222",
  "id": "9d1b2c3e-4f5a-4b6c-8d7e-0a1b2c3d4e5f",
  "created_at": "2026-10-24T09:00:00Z",
  "data": {
    "heartbeat_id": "22222222-2222-4222-8222-222222222222",
    "metadata": {
      "customer_no": "K-4711"
    },
    "name": "Nightly backup",
    "status": "down"
  }
}
```

### `heartbeat.recovered`

A scheduled job that was down checks in successfully again.

| data field | Type | Description |
| --- | --- | --- |
| `heartbeat_id` | string (uuid) always | The scheduled job the event is about. |
| `metadata` | object of strings when set | Custom key/value pairs of the monitor, scheduled job or incident, as set with the metadata field or PUT /v1/metadata. Left out when there are none. |
| `name` | string always | Name of the scheduled job, at most 256 characters. |
| `status` | string always | down for heartbeat.missed, up for heartbeat.recovered. One of `down`, `up`. |

```json
{
  "payload_version": 2,
  "type": "heartbeat.recovered",
  "app_url": "https://app.upfour.io/heartbeats/22222222-2222-4222-8222-222222222222",
  "id": "9d1b2c3e-4f5a-4b6c-8d7e-0a1b2c3d4e5f",
  "created_at": "2026-10-24T09:00:00Z",
  "data": {
    "heartbeat_id": "22222222-2222-4222-8222-222222222222",
    "name": "Nightly backup",
    "status": "up"
  }
}
```

### `status_page.update_published`

An incident or maintenance thread goes public on a status page, or a follow-up entry is added to a public thread. A draft sends nothing until it is published.

| data field | Type | Description |
| --- | --- | --- |
| `ends_at` | string (date-time) when set | End of the maintenance window. Only on a maintenance thread that just went public. |
| `entry_id` | string (uuid) when set | The entry this event is about: the latest one of a thread that just went public, or the one just added. Left out when a thread has no entry. |
| `message` | string always | The text as visitors read it on the status page. It is what you wrote and can contain anything you typed. |
| `published_at` | string (date-time) always | When the post went public, RFC 3339 in UTC. |
| `starts_at` | string (date-time) when set | Start of the maintenance window. Only on a maintenance thread that just went public. |
| `status` | string when set | Status of that entry, such as investigating, identified, monitoring or resolved for an incident, or scheduled, in_progress or completed for a maintenance. Left out when the status is none of the known ones. |
| `status_page_id` | string (uuid) always | The status page the post was published on. |
| `title` | string always | Title of the thread or report, at most 256 characters. |
| `update_id` | string (uuid) always | The incident or maintenance thread. |
| `update_type` | string always | Whether the thread is an incident or a maintenance. One of `incident`, `maintenance`. |

**New maintenance thread**

```json
{
  "payload_version": 2,
  "type": "status_page.update_published",
  "app_url": "https://app.upfour.io/status-pages/44444444-4444-4444-8444-444444444444",
  "id": "9d1b2c3e-4f5a-4b6c-8d7e-0a1b2c3d4e5f",
  "created_at": "2026-10-24T09:00:00Z",
  "data": {
    "ends_at": "2026-10-25T23:00:00Z",
    "entry_id": "66666666-6666-4666-8666-666666666666",
    "message": "We upgrade the database.",
    "published_at": "2026-10-24T09:00:00Z",
    "starts_at": "2026-10-25T22:00:00Z",
    "status": "scheduled",
    "status_page_id": "44444444-4444-4444-8444-444444444444",
    "title": "Database maintenance",
    "update_id": "55555555-5555-4555-8555-555555555555",
    "update_type": "maintenance"
  }
}
```

**Follow-up entry**

```json
{
  "payload_version": 2,
  "type": "status_page.update_published",
  "app_url": "https://app.upfour.io/status-pages/44444444-4444-4444-8444-444444444444",
  "id": "9d1b2c3e-4f5a-4b6c-8d7e-0a1b2c3d4e5f",
  "created_at": "2026-10-24T09:00:00Z",
  "data": {
    "entry_id": "66666666-6666-4666-8666-666666666666",
    "message": "A fix is deployed, we are watching.",
    "published_at": "2026-10-24T09:30:00Z",
    "status": "monitoring",
    "status_page_id": "44444444-4444-4444-8444-444444444444",
    "title": "Checkout is down",
    "update_id": "55555555-5555-4555-8555-555555555555",
    "update_type": "incident"
  }
}
```

### `status_page.report_published`

A report goes public on a status page, when it is created as public or when a draft is published.

| data field | Type | Description |
| --- | --- | --- |
| `message` | string always | The text as visitors read it on the status page. It is what you wrote and can contain anything you typed. |
| `published_at` | string (date-time) always | When the post went public, RFC 3339 in UTC. |
| `report_id` | string (uuid) always | The report. |
| `status` | string when set | Status of the report, published unless it was created with another one. |
| `status_page_id` | string (uuid) always | The status page the post was published on. |
| `title` | string always | Title of the thread or report, at most 256 characters. |
| `update_type` | string always | Always report. One of `report`. |

```json
{
  "payload_version": 2,
  "type": "status_page.report_published",
  "app_url": "https://app.upfour.io/status-pages/44444444-4444-4444-8444-444444444444",
  "id": "9d1b2c3e-4f5a-4b6c-8d7e-0a1b2c3d4e5f",
  "created_at": "2026-10-24T09:00:00Z",
  "data": {
    "message": "99.98 percent across all services.",
    "published_at": "2026-10-01T08:00:00Z",
    "report_id": "77777777-7777-4777-8777-777777777777",
    "status": "published",
    "status_page_id": "44444444-4444-4444-8444-444444444444",
    "title": "September uptime",
    "update_type": "report"
  }
}
```

### `webhook.test`

You call POST /v1/webhook-subscriptions/{id}/test. It goes to that subscription whatever its events list says, and is never published otherwise.

| data field | Type | Description |
| --- | --- | --- |
| `message` | string always | Always Webhook test. |

```json
{
  "payload_version": 2,
  "type": "webhook.test",
  "app_url": "https://app.upfour.io",
  "id": "9d1b2c3e-4f5a-4b6c-8d7e-0a1b2c3d4e5f",
  "created_at": "2026-10-24T09:00:00Z",
  "data": {
    "message": "Webhook test"
  }
}
```

### Incidents closed when monitoring resumes

`incident.resolved` normally means the monitor or scheduled job recovered. When a Company's billing pause ends while a source is still failing, up4 closes its open automatic incident and starts the source afresh: `recovered` is `false`, `resolution` is `monitoring_resumed`, `resolved_by_type` is `billing_resume`. Nothing was measured during the pause, so treat the incident as closed, not as recovered. No `monitor.status_changed` or `heartbeat.recovered` follows; if the source is still failing, the first check after the resume opens a new incident.

## Alert-channel webhooks

A **webhook alert channel** is a different thing from a subscription: it receives the alerts of the monitors it is attached to, its URL must be `https`, and it signs only if you set its optional secret. The header is `X-Up4-Signature: sha256=<hex>`, an HMAC-SHA256 over the raw body with the channel secret and no timestamp. Without a secret there is no header. The body is the alert notification (`event_type`, `monitor`, `details` and so on, with `is_test: true` on a channel test), not one of the events above. A `2xx` is delivered, any other `4xx` is permanent, everything else is treated as transient. Do not reuse the subscription verifier for it:

```javascript
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyChannel(secret, header, rawBody) {
  if (!header?.startsWith("sha256=")) return false;
  const supplied = header.slice(7);
  if (!/^[0-9a-f]{64}$/i.test(supplied)) return false;
  const expected = createHmac("sha256", secret).update(rawBody).digest();
  return timingSafeEqual(expected, Buffer.from(supplied, "hex"));
}
```
