# Recipe: webhook consumer

> A webhook receiver in Node: raw-body handling, signature verification, replay window, store first and answer 2xx after, dedupe by delivery id.

Source: https://upfour.io/docs/api/recipes/webhook-consumer

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](https://upfour.io/docs/api/webhooks#verify) has the same verification in Node, Python, Go, PHP and Ruby.

## Subscribe

```bash
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`.

![A webhook-subscription request panel with an empty key field and example JSON containing a destination URL and three event types.](https://upfour.io/docs/images/webhook-subscription.webp)

**Set the destination and event types.** An example subscription in the reference’s Try it panel.

1. **API key** Use a key with webhook.write and access to the intended organization.
2. **Destination and events** Replace the example URL with your receiver and choose the events you need. Keep the returned subscription secret for signature verification.
3. **Create subscription** Send request creates the subscription. The response is shown below the button; no request was sent for this screenshot.

## The receiver (Node)

```javascript
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](https://upfour.io/docs/api/webhooks#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.
