Skip to content
Docs/Webhooks

Incidents & alerts

Webhooks

UpButler sends signed JSON webhooks in two places: your team's webhook alert channels, and webhook subscribers of a status page. Both follow the Standard Webhooks spec, so any compliant library can verify them.

Headers

Every delivery
POST /hooks/upbutler HTTP/1.1
Content-Type: application/json
User-Agent: UpButler-Webhooks/2.0 (+https://upbutler.com/docs/webhooks)
webhook-id: evt_0n3q9a12c4r7m1t8wxe
webhook-timestamp: 1791533551
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
HeaderValue
webhook-idThe event id (evt_…). It stays the same across retries, so use it to deduplicate
webhook-timestampUnix seconds when this attempt was sent
webhook-signaturev1,<base64 HMAC-SHA256> of ${webhook-id}.${webhook-timestamp}.${body}

Team webhook channels also send any custom headers you configured on the channel.

Team payloads (alert channels)

Webhook channels get every event they're subscribed to (see choosing events) in a thin envelope around the raw event data:

monitor.down
{
  "id": "evt_0n3q9a12c4r7m1t8wxe",
  "type": "monitor.down",
  "timestamp": "2026-10-09T08:12:31.880Z",
  "workspaceId": "ws_0n3q8jy8r5t1y3u7i9o",
  "data": {
    "monitor": {
      "_id": "mon_0n3q8kz1m4hx7c2v9rt",
      "name": "Search API",
      "kind": "http",
      "state": "down",
      "http": { "url": "https://api.example.com/health", "method": "GET", "expectedStatus": "200-299", ... },
      "tags": ["prod"],
      "url": "https://upbutler.com/app/monitors/mon_0n3q8kz1m4hx7c2v9rt",
      ...
    },
    "check": {
      "_id": "chk_0n3q9a10e9n3p5s7hz2",
      "at": "2026-10-09T08:12:30.911Z",
      "outcome": "down",
      "statusCode": 502,
      "latencyMs": 288,
      "error": "Expected status 200-299, got 502 Bad Gateway",
      "region": "eu-central",
      "hasEvidence": true
    },
    "incident": {
      "id": "inc_0n3q9a11v8k2h5n0qzc",
      "title": "Elevated errors on the Search API",
      "url": "https://upbutler.com/app/incidents/inc_0n3q9a11v8k2h5n0qzc"
    },
    "ai": {
      "analysis": "Since 08:10:44 every check returns HTTP 502 from Cloudflare within ~300ms…",
      "likelyCauses": ["Origin process crashed after deploy"],
      "suggestedActions": ["Check origin process status and recent deploys"],
      "publicSummary": "Some search requests are failing. We're investigating.",
      "severity": "critical",
      ...
    }
  }
}

Envelope: { id, type, timestamp, workspaceId, data }. data depends on the type. Monitor events carry the full monitor plus check, incident and ai (or null). monitor.up adds downtimeSeconds, and monitor.reminder adds downForMinutes. Incident and maintenance events carry the full incident (including internal notes and AI reports) and the latest update. See Events for every type.

Subscriber payloads (status pages)

Webhook subscribers of a status page (often agents at other companies) get a public-safe payload. It never includes internal notes or engineering analysis, and it always carries the page's overall status and a link to manage the subscription.

{
  "id": "evt_0n3q9a19k2m4p6r8t0v",
  "type": "incident.created",
  "timestamp": "2026-10-09T08:12:33.104Z",
  "page": {
    "id": "pg_0n3q8kz0b2fd81mka5e",
    "slug": "acme",
    "name": "Acme Status",
    "url": "https://status.acme.com",
    "status": "major_outage",
    "statusText": "Major outage",
    "api": "https://upbutler.com/api/v1/public/pages/acme"
  },
  "data": {
    "incident": {
      "id": "inc_0n3q9a11v8k2h5n0qzc",
      "kind": "incident",
      "title": "Elevated errors on the Search API",
      "status": "investigating",
      "impact": "critical",
      "startedAt": "2026-10-09T08:12:31.402Z",
      "resolvedAt": null,
      "scheduledStart": null,
      "scheduledEnd": null,
      "components": [{ "componentId": "cmp_0n3q8kz2p7wd4yx0s3a", "status": "major_outage" }],
      "updates": [
        {
          "id": "upd_0n3q9a14w1x3z5b7d9f",
          "status": "investigating",
          "body": "Some search requests are failing. We're investigating and will post an update shortly.",
          "at": "2026-10-09T08:12:31.402Z",
          "ai": true
        }
      ],
      "aiSummary": "Some search requests are failing. We're investigating and will post an update shortly.",
      "url": "https://status.acme.com/incidents/inc_0n3q9a11v8k2h5n0qzc"
    }
  },
  "subscription": {
    "id": "sub_0n3q9b40t2m8w6k1c5v",
    "components": [],
    "manage": "https://upbutler.com/api/v1/public/subscribers/sub_0n3q9b40t2m8w6k1c5v?token=3f9a1c7e2b8d4f6a0c5e9b1d7f3a8c2e6b4d0f9a"
  }
}

Envelope: { id, type, timestamp, page, data, subscription }. data.incident is present for incident.* and maintenance.*. data.component, from and to are present for component.status_changed.

Verifying signatures

The secret looks like whsec_<base64>. Strip the whsec_ prefix and base64-decode the rest: that is the HMAC key. Sign ${id}.${timestamp}.${rawBody} with HMAC-SHA256 and compare (in constant time) against each v1, entry in the header. Reject timestamps more than 5 minutes off.

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

/**
 * Verify a Standard Webhooks signature.
 * rawBody must be the exact bytes received. Don't re-serialize parsed JSON.
 */
export function verifyUpButler(secret: string, headers: Headers, rawBody: string, toleranceSec = 300): boolean {
  const id = headers.get("webhook-id");
  const ts = headers.get("webhook-timestamp");
  const sigHeader = headers.get("webhook-signature");
  if (!id || !ts || !sigHeader) return false;
  if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSec) return false; // replay protection

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest("base64");

  // The header may carry several space-separated signatures ("v1,abc v1,def").
  return sigHeader.split(" ").some((part) => {
    const [version, sig] = part.split(",");
    if (version !== "v1" || !sig) return false;
    const a = Buffer.from(sig);
    const b = Buffer.from(expected);
    return a.length === b.length && timingSafeEqual(a, b);
  });
}

Or use an official Standard Webhooks library:

// npm i standardwebhooks
import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.UPBUTLER_WEBHOOK_SECRET!); // the whsec_... value
const event = wh.verify(rawBody, Object.fromEntries(request.headers)); // throws if invalid

Retries

A delivery succeeds on any 2xx within 10 seconds. Redirects aren't followed. Anything else is retried with exponential backoff, up to 8 attempts in total over about 11 hours:

AttemptSent
1Immediately
230 seconds after attempt 1 failed
3+2 minutes
4+10 minutes
5+30 minutes
6+1 hour
7+3 hours
8+6 hours, then the delivery is marked dead
  • Permanent failures stop immediately: any 4xx except 408 and 429, an invalid or private URL, or a removed or disabled channel.
  • 410 Gone from a subscriber endpoint disables that subscription. Use it to unsubscribe from the receiving side.
  • A webhook, Slack or Discord subscriber whose deliveries die 10 times is disabled automatically.
  • Deliveries are independent, so ordering is not guaranteed. Order by timestamp, or re-fetch the incident when order matters.

Idempotency

Retries carry the same webhook-id (the event id) and body, with a fresh webhook-timestamp and signature. Store processed ids for at least a day and return 2xx for duplicates without processing them again. Event ids are time-sortable, so they double as a cursor for polling.

Subscriber handshake

Before a webhook subscription to a status page becomes active, UpButler proves that you control the endpoint. During the subscribe call, it POSTs a signed challenge:

POST to your endpoint (webhook-id: msg_verify_sub_…)
{
  "type": "subscription.verify",
  "timestamp": "2026-10-09T09:00:00.000Z",
  "challenge": "7m2kq9x4c1v8b3n6z0p5w2r",
  "page": { "id": "pg_0n3q8kz0b2fd81mka5e", "name": "Acme Status", "url": "https://status.acme.com" },
  "instructions": "Respond 2xx with the challenge value in the body (plain text or {\"challenge\": \"...\"}) to activate this subscription."
}

Reply 2xx with the challenge in the body, either plain text (7m2kq9x4c1v8b3n6z0p5w2r) or JSON ({"challenge": "7m2kq9x4c1v8b3n6z0p5w2r"}). The subscribe call then returns 201 with your signingSecret and manageToken. If the echo is missing, it fails with 422 and nothing is stored. The signing secret only exists once the subscription does, so don't require a valid signature for subscription.verify.

Webhook alert channels in your own workspace need no handshake. Use POST /channels/:id/test instead.

A complete receiver
// Works in Bun, Deno, Cloudflare Workers, Next.js route handlers… (Request → Response)
import { verifyUpButler } from "./verify";

const seen = new Set<string>(); // use your database or Redis in production

export async function handleUpButler(req: Request): Promise<Response> {
  const raw = await req.text();
  const body = JSON.parse(raw);

  // 1. Subscription handshake: echo the challenge. You don't have the secret yet,
  //    because it is returned by the subscribe call after this succeeds.
  if (body.type === "subscription.verify") {
    return Response.json({ challenge: body.challenge });
  }

  // 2. Everything else must be signed with your subscription's / channel's secret.
  if (!verifyUpButler(process.env.UPBUTLER_WEBHOOK_SECRET!, req.headers, raw)) {
    return new Response("bad signature", { status: 401 });
  }

  // 3. Deduplicate: retries reuse the same webhook-id.
  const id = req.headers.get("webhook-id")!;
  if (seen.has(id)) return new Response(null, { status: 204 });
  seen.add(id);

  // 4. Acknowledge fast, work async (10 s timeout).
  queueMicrotask(() => handleEvent(body));
  return new Response(null, { status: 204 });
}

async function handleEvent(e: any) {
  switch (e.type) {
    case "incident.created":
      console.log(`${e.page.name}: ${e.data.incident.title}`);
      break;
    case "component.status_changed":
      console.log(`${e.data.component.name}: ${e.data.from} → ${e.data.to}`);
      break;
  }
}