Alerts as signed JSON, to your own address.
A webhook channel posts one JSON event to your https address each time an incident opens, repeats, is acknowledged or recovers. Every request is signed with a secret only you and we hold.
Adding a webhook
In Alerts, press Add a channel, choose Webhook and paste your address. It has to be https on port 443 or a port from 1024 to 65535, on a public address, with no user name or password in it. We send a test event at once and show the signing secret. The secret is shown this once; copy it into your receiver before you close the dialog.
Events
- incident.opened A monitor or a heartbeat went down and an incident opened. For a heartbeat,
source.kindisheartbeat. - incident.repeat The incident is still open and nobody has acknowledged it, sent on the workspace's repeat rule.
- incident.acknowledged Somebody acknowledged the incident. The body says when, never who.
- incident.recovered The incident closed. Sent when the workspace sends recovery alerts, which is the default.
- test Sent when the webhook is added and on every Send test. It carries the workspace only.
An incident.opened request, as it arrives:
POST /hooks/catch/1234/abcd Content-Type: application/json User-Agent: Upglory-Webhook/1 Upglory-Event-Id: evt_af5287e260ccc5b7dd86af3408 Upglory-Timestamp: 1792494120 Upglory-Signature: v1=5f2a9c...e81b
{
"version": 1,
"event": "incident.opened",
"event_id": "evt_af5287e260ccc5b7dd86af3408",
"sent_at": "2026-10-20T11:02:00Z",
"workspace": {
"id": "0199f3a2-7c41-7d1e-9a52-2f6b8c1d4e90",
"name": "Example Shop"
},
"source": {
"kind": "monitor",
"id": "0199f3a3-4d5e-7f60-8a1b-2c3d4e5f6a7b",
"name": "checkout",
"url": "https://example.shop/checkout"
},
"incident": {
"id": "0199f3a6-1b2c-7e3d-8f4a-5b6c7d8e9f01",
"number": 42,
"state": "open",
"opened_at": "2026-10-20T11:01:30Z",
"cause": "expected 200, got 502 from Frankfurt and Stockholm",
"regions": [
"Frankfurt",
"Stockholm"
],
"acknowledged": false,
"link": "https://upglory.io/app/incidents/0199f3a6-1b2c-7e3d-8f4a-5b6c7d8e9f01"
}
}The other incident events carry the same body with their own event, event_id and state. A test carries the workspace only:
{
"version": 1,
"event": "test",
"event_id": "evt_6c1f0e9b2d4a8f3c7e5b1a90d2",
"sent_at": "2026-10-20T11:02:00Z",
"workspace": {
"id": "0199f3a2-7c41-7d1e-9a52-2f6b8c1d4e90",
"name": "Example Shop"
}
}Payload reference
| Field | What it holds |
|---|---|
version | Always 1 for this body. A change that would break a receiver gets a new version. |
event | One of the events above. |
event_id | The delivery's id, the same as the Upglory-Event-Id header. |
sent_at | When the delivery was made, ISO 8601 in UTC. |
workspace.id, workspace.name | The workspace the alert is from. |
source.kind | monitor or heartbeat. |
source.id, source.name | What the incident is about. |
source.url | A monitor's address. Left out for a heartbeat, and for a monitor marked as carrying secrets in its address. |
incident.id, incident.number | The incident, and its number in the workspace. |
incident.state | open or resolved. |
incident.opened_at, incident.resolved_at | When it opened, and when it closed once it has. |
incident.cause | What failed, in words, with the locations that saw it. |
incident.regions | The locations that saw it fail, by city. |
incident.acknowledged, incident.acknowledged_at | Whether somebody acknowledged it, and when. |
incident.link | The incident in the dashboard. |
No body carries a person's name or email address, and none carries a check's headers or response body.
Headers
| Header | Value | What it is for |
|---|---|---|
Content-Type | application/json | The body is one JSON object. |
User-Agent | Upglory-Webhook/1 | The 1 is the body's version. |
Upglory-Event-Id | evt_... | The same on every try of one delivery. Keep the ids you have handled and drop a repeat. |
Upglory-Timestamp | 1760958120 | When the delivery was made, in Unix seconds. The signature covers it. |
Upglory-Signature | v1=5f2a9c... | The hex HMAC-SHA256 of the timestamp, a dot and the raw body, keyed with the signing secret. While a replaced secret is still valid there are two values, comma separated, the new secret's first. |
Verify a signature
Check every request before you act on it:
- Read the body exactly as it arrived, before any JSON parsing. A body parsed and written out again is not the one we signed.
- Refuse a timestamp more than five minutes away from your clock, so an old request sent again is turned away.
- Compute the HMAC-SHA256 of the timestamp, a dot and the raw body, keyed with your signing secret, as hex.
- Compare it with each
v1=value in constant time, and accept the request when one matches.
Node
import crypto from 'node:crypto';
// rawBody is the request body as it arrived, a Buffer, before any JSON parsing.
export function verifyUpglory(rawBody, headers, secret) {
const timestamp = headers['upglory-timestamp'] ?? '';
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!/^\d+$/.test(timestamp) || age > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(timestamp + '.')
.update(rawBody)
.digest();
return (headers['upglory-signature'] ?? '').split(',').some((part) => {
if (!part.startsWith('v1=')) return false;
const given = Buffer.from(part.slice(3), 'hex');
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}Python
import hashlib
import hmac
import time
def verify_upglory(raw_body: bytes, headers, secret: str) -> bool:
"""raw_body is the request body as it arrived, before any JSON parsing."""
timestamp = headers.get("Upglory-Timestamp", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
return False
expected = hmac.new(
secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
for part in headers.get("Upglory-Signature", "").split(","):
if part.startswith("v1=") and hmac.compare_digest(part[3:], expected):
return True
return FalseGo
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"strconv"
"strings"
"time"
)
// VerifyUpglory checks a webhook request. rawBody is the body as it
// arrived, read before any JSON decoding.
func VerifyUpglory(rawBody []byte, h http.Header, secret string) bool {
ts := h.Get("Upglory-Timestamp")
sent, err := strconv.ParseInt(ts, 10, 64)
if err != nil || time.Since(time.Unix(sent, 0)).Abs() > 5*time.Minute {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "."))
mac.Write(rawBody)
expected := mac.Sum(nil)
for _, part := range strings.Split(h.Get("Upglory-Signature"), ",") {
hexSig, ok := strings.CutPrefix(part, "v1=")
given, err := hex.DecodeString(hexSig)
if ok && err == nil && hmac.Equal(given, expected) {
return true
}
}
return false
}Replacing the secret
In the channel's menu, Replace the secret makes a new one and shows it once. The old one keeps working for 24 hours: during that day every request carries two values in Upglory-Signature, the new secret's first, so a receiver that checks either keeps working while you switch.
Retries and duplicates
Answer with any 2xx within ten seconds to take a delivery. A network error, a 5xx, a 408 or a 429 is tried again, up to three tries in all, with a growing wait; a Retry-After is honoured up to five minutes. Any other 4xx is final and is not tried again. Redirects are never followed: a 3xx is a failed delivery, so give us the final address.
Delivery is at least once, so the same delivery can arrive twice, for example when your answer is lost on its way back. Every try of one delivery carries the same Upglory-Event-Id; keep the ids you have handled and drop a repeat. When a delivery fails, the channel shows why on its row and its page, and your inbox says so once.
Limits
- Webhooks per workspace10
- Deliveries per webhook in an hour60
- Time to answer10 seconds
- Tries per delivery3
- Test sends per workspace in an hour10
- Replaced secret keeps working24 hours
Past the hourly figure, deliveries to that webhook are dropped for the rest of the hour and listed as dropped on its page; your inbox says so once, and every other channel still gets them.