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.
Visual guide · API reference / Webhook subscriptions
- API keyUse a key with webhook.write and access to the intended organization.
- Destination and eventsReplace the example URL with your receiver and choose the events you need. Keep the returned subscription secret for signature verification.
- 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:
- Read the raw body exactly as it arrived. A body that was parsed and serialised again has other bytes and never verifies.
- Rebuild the signature from
tand the raw body and compare it in constant time with thev1value. A header with severalv1values is valid if any of them matches. up4 sends one today; the snippets accept any so a change would not break you. - Reject a
tmore than 300 seconds away from your clock, in either direction. up4 does not enforce the window, it is your protection against replays.tis 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?
endTest 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-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
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.
{
"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 field | Type | Description |
|---|---|---|
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 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. |
{
"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. |
{
"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. |
{
"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. |
{
"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. |
{
"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. |
{
"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
{
"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 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. |
{
"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. |
{
"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. |
{
"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. |
{
"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. |
{
"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. |
{
"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
{
"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 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. |
{
"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. |
{
"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"));
}Was this page helpful?