Recipe · integrations

A webhook consumer done right.

Four things make a webhook receiver production-shaped: verify the signature over the raw body, reject stale timestamps, dedupe by delivery id, and answer 2xx fast — do the real work after responding. This is the whole thing in Node; the signature math in Go lives in the webhooks guide.

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.

The receiver (Node)

import express from "express";
import crypto from "node:crypto";

const app = express();
const SECRET = process.env.UP4_WEBHOOK_SECRET;
const seen = new Set(); // use your store in production

function verify(header, rawBody, now = Math.floor(Date.now() / 1000)) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const ts = Number(parts.t);
  if (!ts || Math.abs(now - ts) > 300) return false; // replay window
  const expected = crypto.createHmac("sha256", SECRET)
    .update(`${ts}.`).update(rawBody).digest("hex");
  try {
    return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 || ""));
  } catch { return false; }
}

// express.raw, NOT express.json — the signature covers the raw bytes.
app.post("/hooks/up4", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.header("X-Up4-Signature") ?? "", req.body)) {
    return res.status(401).end();
  }
  const deliveryId = req.header("X-Up4-Delivery");
  if (seen.has(deliveryId)) return res.status(200).end(); // dedupe redeliveries
  seen.add(deliveryId);

  res.status(200).end(); // answer fast; work after

  const event = req.header("X-Up4-Event");
  const payload = JSON.parse(req.body);
  switch (event) {
    case "incident.opened":
      // page your own systems, open a ticket, dim the lights …
      break;
    case "monitor.status_changed":
      // update your own dashboard …
      break;
  }
});

app.listen(8787);

Test it end to end — safely

Sandbox webhooks still deliver for real (only notifications are suppressed), so a pls_test_ key plus a sandbox 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.