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.
Visual guide · API reference / Webhook subscriptions
- API keyUse a key with webhook.write and access to the intended organization.
- Destination and eventsReplace the example URL with your receiver and choose the events you need. Keep the returned subscription secret for signature verification.
- 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.
Was this page helpful?