Docs

Type a word or a question. Esc closes the search.

Sign in Start free
Developers/Basics

Push, signed, verifiable.

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.

Set the destination and event types. An example subscription in the reference’s Try it panel. Screenshot · example data
Visual guide · API reference / Webhook subscriptions
  1. API keyUse a key with webhook.write and access to the intended organization.
  2. Destination and eventsReplace the example URL with your receiver and choose the events you need. Keep the returned subscription secret for signature verification.
  3. Create subscriptionSend 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, 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.

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

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

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

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

/**
 * $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

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 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-TypeAlways application/json.
User-Agentup4-webhooks/1.0.
X-Up4-EventThe event name, the same as type in the body.
X-Up4-DeliveryID 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-Signaturet=<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:

FieldTypeDescription
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.
dataobjectThe 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 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

Timeout10 seconds for one attempt in total, redirects included. Answer fast and do the work afterwards.
SuccessAny 2xx from the final URL. The response body is discarded; only the status is kept.
RedirectsFollowed, 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.
RetriedTimeouts, 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 retriedEvery other 4xx: the delivery ends as failed. A destination that resolves to a blocked address also fails at once.
ScheduleAt 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 offA 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 againPUT /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.
OrderNo 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.
DuplicatesAt 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.
TestPOST /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.
RedeliveryPOST /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.
HistoryGET /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.
DestinationsLoopback, 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.

{
  "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.

{
  "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 fieldTypeDescription
monitor_id string (uuid)
always
The monitor the event is about.
{
  "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 fieldTypeDescription
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.
{
  "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 fieldTypeDescription
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.
{
  "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 fieldTypeDescription
monitor_id string (uuid)
always
The monitor the event is about.
{
  "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 fieldTypeDescription
updated integer
always
Number of monitors the call changed.
{
  "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 fieldTypeDescription
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.
{
  "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 fieldTypeDescription
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.
{
  "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 fieldTypeDescription
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

{
  "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

{
  "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

{
  "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 fieldTypeDescription
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.
{
  "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 fieldTypeDescription
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.
{
  "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 fieldTypeDescription
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.
{
  "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 fieldTypeDescription
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.
{
  "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 fieldTypeDescription
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.
{
  "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 fieldTypeDescription
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.
{
  "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 fieldTypeDescription
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

{
  "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

{
  "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 fieldTypeDescription
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.
{
  "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 fieldTypeDescription
message string
always
Always Webhook test.
{
  "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:

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"));
}