Octopus
Octopus Developersgateway.octopusoperations.co.za/v1

Tutorial · about 20 minutes

Verify a webhook

A receiver that proves each delivery came from Octopus, answers fast enough not to be retried, and survives the duplicate that arrives when it is not.

Step 1: the one thing that breaks everybody

You must verify against the raw request body, byte for byte. If your framework parses JSON before you see it, the body you hash is a re-serialisation with different key order and whitespace, and the signature will never match.

So the very first thing to get right is keeping the raw bytes:

server.js, Express
import express from "express";

const app = express();

// Raw body ONLY on the webhook route. Everything else can parse normally.
app.post(
  "/webhooks/octopus",
  express.raw({ type: "application/json" }),
  handleOctopusWebhook
);

app.use(express.json()); // the rest of your app, as usual

Order matters

Mount the raw route before the global JSON parser. Put express.json() first and it consumes the stream, and your raw handler receives an empty buffer, which fails verification in a way that looks like a wrong secret.

Step 2: verify

verify.js
import crypto from "node:crypto";

const TOLERANCE_SECONDS = 300;

export function verify(rawBody, signatureHeader, secret) {
  if (!signatureHeader) return false;

  const parts = Object.fromEntries(
    signatureHeader.split(",").map((piece) => piece.split("="))
  );
  const timestamp = Number(parts.t);
  if (!Number.isFinite(timestamp) || !parts.v1) return false;

  // Two-sided: rejects an old replay AND a clock that is ahead of ours.
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(parts.v1, "hex");

  // Lengths must match before timingSafeEqual, which throws otherwise — and
  // that throw is itself a timing signal, so check it explicitly.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Step 3: answer fast, work later

Octopus retries anything that does not answer 2xx quickly. Verify, queue, respond, then do the real work outside the request.

handler.js
const seen = new Set(); // in production: a shared cache with a TTL

export function handleOctopusWebhook(req, res) {
  const raw = req.body.toString("utf8");

  if (!verify(raw, req.get("x-octopus-signature"), process.env.OCTOPUS_WEBHOOK_SECRET)) {
    return res.status(400).send("bad signature");
  }

  const delivery = req.get("x-octopus-delivery");

  // Stable across retries, so it is the right idempotency key.
  if (seen.has(delivery)) return res.status(200).send("already handled");
  seen.add(delivery);

  queue.push({ event: req.get("x-octopus-event"), payload: JSON.parse(raw) });

  // Answer BEFORE processing. A slow endpoint looks like a failing one.
  res.status(200).send("ok");
}

Step 4: register and prove it

In Octopus, open Settings → Integrations, add your HTTPS URL, and copy the signing secret. Like an API key, it is shown once.

Then use ping. It sends a synthetic event so you can prove the endpoint before anything real depends on it. The delivery log beside it shows each attempt and the response it got, which is the only way to debug a receiver that is silently refusing.

HTTPS, and not an internal address

Octopus resolves your hostname before every delivery and refuses private and loopback addresses. For local development, use a tunnel that gives you a public HTTPS URL.

Step 5: the failure modes, on purpose

  • Wrong secret → your 400. Check you copied the secret for this endpoint.
  • Parsed body → your 400, with a correct secret. Step 1.
  • Slow handler → a retry, and a second delivery with the same id. Step 3 handles it.
  • Clock drift → rejected as stale. Run NTP; the window is five minutes either way.