Docs

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

Sign in Start free
Developers/Recipes

A webhook consumer done right.

Four things make a webhook receiver production-shaped: verify the signature over the raw body, reject stale timestamps, store the event under its id, and only then answer 2xx. The real work runs from what you stored. This is the whole thing in Node; the webhooks guide has the same verification in Node, Python, Go, PHP and Ruby.

Subscribe

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": ["monitor.status_changed", "incident.opened", "incident.resolved"]
  }'

The response includes the subscription secret — store it next to your other secrets. Then send yourself a test delivery: POST /webhook-subscriptions/{id}/test.

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.

The receiver (Node)

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

const app = express();
// During a secret rotation both are set; see "Rotate the secret" below.
const SECRETS = [process.env.UP4_WEBHOOK_SECRET, process.env.UP4_WEBHOOK_SECRET_PREVIOUS].filter(Boolean);
const db = new pg.Pool(); // up4_events(event_id text primary key, delivery_id text, event text, body text)

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

// express.raw, NOT express.json: the signature covers the raw bytes.
app.post("/hooks/up4", express.raw({ type: "application/json" }), async (req, res) => {
  const signature = req.header("X-Up4-Signature") ?? "";
  if (!SECRETS.some((secret) => verifyUp4Signature(secret, signature, req.body))) {
    return res.status(401).end();
  }
  let payload;
  try { payload = JSON.parse(req.body); } catch { return res.status(400).end(); }

  try {
    // Store first. The event id is the same on every retry and on a manual
    // redelivery, so the primary key makes a repeat a no-op. (The delivery id
    // changes on a redelivery.)
    await db.query(
      "INSERT INTO up4_events (event_id, delivery_id, event, body) VALUES ($1, $2, $3, $4) ON CONFLICT (event_id) DO NOTHING",
      [payload.id, req.header("X-Up4-Delivery"), req.header("X-Up4-Event"), req.body.toString("utf8")],
    );
  } catch {
    return res.status(500).end(); // nothing stored: up4 retries
  }
  res.status(200).end(); // stored, safe to acknowledge
});

// A worker reads stored rows and does the real work.
async function handle({ event, body }) {
  const payload = JSON.parse(body);
  switch (event) {
    case "incident.opened":
      // page your own systems, open a ticket, dim the lights …
      break;
    case "monitor.status_changed":
      // payload.data.state is UP or DOWN
      break;
  }
}

app.listen(8787);

A 5xx, 408 or 429 (or a timeout) makes up4 try again, up to seven attempts with the same X-Up4-Delivery id. Any other 4xx counts as permanent and ends that delivery, so answer 4xx only for requests that should never be retried. Deduplicate on the body's id, not on X-Up4-Delivery: a manual redelivery is a new delivery with a new delivery id, but the event keeps its id.

Rotate the secret

The old secret stops signing the moment you rotate, with no grace period, and a retry that was queued earlier is signed with the new one. So deploy the receiver with UP4_WEBHOOK_SECRET_PREVIOUS first, rotate, then drop the old value. While both are set the receiver accepts a delivery signed with either, and never answers 4xx for one that is signed with the other: a 4xx ends that delivery for good. See Get the signing secret.

Test it end to end — safely

Webhooks deliver for real and there is no test environment, so a monitor pointed at a URL you control gives you the full loop: break the URL, watch incident.opened arrive, fix it, watch incident.resolved. Inspect and redeliver any delivery via GET /webhook-subscriptions/{id}/deliveries.

Notes

Destinations must be publicly reachable — deliveries refuse loopback, private ranges and metadata endpoints (SSRF protection). For local development, a tunnel (cloudflared, ngrok) works fine.