Incidents & alerts
Events
Everything that happens in a workspace is recorded as an event. Events are what channels, subscribers and webhooks deliver, and agents can read the same stream by polling.
Event types
| Type | When | data | Default channel | Subscribers |
|---|---|---|---|---|
monitor.down | A monitor went down (after confirmation). | monitor, check, incident {id,title,url}, ai (opening report or null) | Yes | — |
monitor.up | A down or degraded monitor recovered. | monitor, check, downtimeSeconds, incident {id,url,resolved} | null, ai (recovery report or null) | Yes | — |
monitor.degraded | A monitor became degraded (slow, a soft assertion failed, TLS expiring). | monitor, check | Yes | — |
monitor.reminder | A monitor is still down at one of its reminderMinutes. | monitor, downForMinutes, reminderAfterMinutes, incident {id,title} | Yes | — |
incident.created | An incident was opened, automatically or by a person or agent.Channels: Manual incidents. Automatic ones go to subscribers only (your team gets monitor.down). | incident, update | Yes | Yes |
incident.updated | A public update was posted, or an automatic incident grew or partially recovered.Channels: Manual updates | incident, update | Yes | Yes |
incident.resolved | An incident was resolved.Channels: Manual resolves. Automatic ones go to subscribers only (your team gets monitor.up). | incident, update, durationMinutes, durationText | Yes | Yes |
maintenance.scheduled | A maintenance window was scheduled. | incident (kind: maintenance), update | Yes | Yes |
maintenance.started | A window reached scheduledStart. | incident, update | Yes | Yes |
maintenance.updated | A public update was posted during maintenance. | incident, update | Yes | Yes |
maintenance.completed | A window reached scheduledEnd. | incident, update | Yes | Yes |
incident.acknowledged | Someone acknowledged an incident (API, MCP, dashboard or an alert link).Channels: The channels that were alerted about the incident | incident, ack {at, by, note?, via} | Yes | — |
incident.escalated | An escalation policy paged a level.Channels: Only that level's targets (channels, on-call people) | incident, monitor?, policy {id,name}, level, levels, round, unacknowledgedForMinutes, notified {channelIds, users} | — | — |
incident.unacknowledged | An acknowledgement was removed; reminders and escalation resume.Channels: Never (event stream only) | incident, by | — | — |
component.status_changed | A component's visible (effective) status changed, from any source or override. | component, from, to | — | Webhooks only |
monitor.created | A monitor was created.Channels: Never (event stream only) | monitor | — | — |
monitor.deleted | A monitor was deleted.Channels: Never (event stream only) | monitor {_id, name} | — | — |
deploy.created | A deploy marker was recorded.Channels: Channels that list it (or "*") | deploy | — | — |
digest.weekly | The weekly digest was built (Mondays 08:00 workspace time).Channels: Channels that list it ("digest.*" or "*") | the full digest (summary, monitors, incidents, trends, focus …) | — | — |
test.ping | You tested a channel with POST /channels/:id/test.Channels: Only the tested channel | message, channel {id,name} | — | — |
- Default channel: delivered to alert channels that have no explicit
eventslist. Others need to be listed explicitly or matched by a wildcard ("*","component.*"). - Subscribers: delivered to status page subscribers, for public incidents only, and filtered by their components.
- Monitor events only reach the channels attached to that monitor.
Payload envelopes for each delivery target are on the Webhooks page. In the event stream, data is the same object team webhooks receive.
Polling
GET /events (MCP: events_list) reads the event stream with an API key. It suits agents without a public webhook endpoint, and catching up after downtime.
curl "https://upbutler.com/api/v1/events?types=monitor.down,monitor.up&limit=2" \
-H "Authorization: Bearer $UPBUTLER_API_KEY"{
"data": [
{
"id": "evt_0n3qb7c21k4m6p8r0t2",
"type": "monitor.up",
"at": "2026-10-09T08:26:02.311Z",
"data": { "monitor": { "_id": "mon_0n3q8kz1m4hx7c2v9rt", "name": "Search API", ... }, "downtimeSeconds": 811, ... }
},
{
"id": "evt_0n3q9a12c4r7m1t8wxe",
"type": "monitor.down",
"at": "2026-10-09T08:12:31.880Z",
"data": { "monitor": { ... }, "check": { ... }, "incident": { ... }, "ai": { ... } }
}
],
"next": "evt_0n3q9a12c4r7m1t8wxe"
}| Query | Meaning |
|---|---|
after | Only events after this id, oldest first. Without it, the newest events come first |
types | Comma-separated filter, such as monitor.down,monitor.up (exact types, no wildcards) |
limit | 1–500, default 100 |
next is the id of the last event in data, or your after value when nothing new arrived. With after, feed next back in as the cursor. Ids are time-sortable, so “after” means “newer than”.
const API = "https://upbutler.com/api/v1";
const headers = { Authorization: `Bearer ${process.env.UPBUTLER_API_KEY}` };
// Start at "now": the newest event id (the list is newest-first without "after").
const first = await (await fetch(`${API}/events?limit=1`, { headers })).json();
let cursor: string | null = first.data[0]?.id ?? null;
while (true) {
const url = new URL(`${API}/events`);
if (cursor) url.searchParams.set("after", cursor); // oldest-first after the cursor
url.searchParams.set("limit", "500");
const page = await (await fetch(url, { headers })).json();
for (const event of page.data) await handle(event); // in order
cursor = page.next ?? cursor; // persist this somewhere durable
if (page.data.length < 500) await Bun.sleep(15_000); // caught up: wait a bit
}
async function handle(e: { id: string; type: string; data: any }) {
if (e.type === "monitor.down") console.log("DOWN", e.data.monitor.name, e.data.ai?.analysis ?? "");
}cursor=""
while true; do
resp=$(curl -fsS -G https://upbutler.com/api/v1/events \
-H "Authorization: Bearer $UPBUTLER_API_KEY" \
${cursor:+--data-urlencode "after=$cursor"} --data-urlencode "limit=100")
echo "$resp" | jq -c '.data[] | {id, type, at}'
next=$(echo "$resp" | jq -r '.next // empty')
# without a cursor the list is newest-first; start after the newest one
[ -z "$cursor" ] && next=$(echo "$resp" | jq -r '.data[0].id // empty')
cursor=${next:-$cursor}
sleep 15
doneLive stream (SSE)
GET /api/v1/stream pushes the same events as they happen, as Server-Sent Events. It's the push alternative to polling /events or hosting a webhook endpoint. Auth is the same as the REST API: Authorization: Bearer <key> or the dashboard session cookie.
curl -N "https://upbutler.com/api/v1/stream?types=monitor.*,incident.created" \
-H "Authorization: Bearer $UPBUTLER_KEY"id: evt_0n3q9a12c4r7m1t8wxe
data: {"id":"evt_0n3q9a12c4r7m1t8wxe","type":"monitor.down","at":"2026-10-09T09:12:19.000Z","data":{…}}
: ping| Option | Meaning |
|---|---|
types | Comma-separated filter. Prefix wildcards are allowed: monitor.*,incident.created |
after / Last-Event-ID | Resume after this event id. EventSource sends the header automatically on reconnect |
checks=1 | Also send live-only check.completed frames for every finished check. They have no id and can't be replayed |
- Each frame is a default
messageevent withid: <event id>anddata: {"id","type","at","data"}, the same shape asGET /events. - A
: pingcomment is sent every 15 seconds to keep proxies from closing the connection. - The server closes the stream after about 10 minutes. Reconnect with the last id you saw and nothing is lost.
// Reads the SSE stream with fetch so you can send the Authorization header.
let lastId: string | null = null;
while (true) {
const res = await fetch("https://upbutler.com/api/v1/stream?types=monitor.*", {
headers: { Authorization: `Bearer ${process.env.UPBUTLER_API_KEY}`, ...(lastId ? { "Last-Event-ID": lastId } : {}) },
});
const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader();
let buf = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break; // server closes after ~10 min: reconnect and resume
buf += value;
let i;
while ((i = buf.indexOf("\n\n")) >= 0) {
const frame = buf.slice(0, i); buf = buf.slice(i + 2);
const id = frame.match(/^id: (.*)$/m)?.[1];
const data = frame.match(/^data: (.*)$/m)?.[1];
if (id) lastId = id;
if (data) handle(JSON.parse(data)); // {id, type, at, data}
}
}
}