Guides

Receive and verify webhooks

Build an endpoint that verifies the Standard Webhooks signature of an Outqube event, answers fast and handles retries and duplicates.

This guide builds a receiver for event webhooks. It verifies the signature, answers within 10 seconds and ignores duplicates.

Add an endpoint

An organisation admin opens Settings, Event webhooks, adds your HTTPS URL and picks the event types, or all of them. Outqube shows the signing secret, which starts with whsec_, once. Store it as OUTQUBE_WEBHOOK_SECRET.

You can also create endpoints with a manage key: POST /webhooks. The URL must be https:// on port 443 with a public host name.

Verify the signature

Every message carries three headers:

HeaderValue
webhook-idThe event id. It is the same on every retry of a message.
webhook-timestampUnix seconds of this attempt.
webhook-signaturev1,<base64>. Two signatures, separated by a space, during the 24 hours after you rotate a secret.

The signature is the base64 of an HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{raw body}. The key is the secret without its whsec_ prefix, base64-decoded. Verify the raw bytes you received, not JSON you parsed and wrote out again. Reject a timestamp more than five minutes off.

Any Standard Webhooks library does all of this:

import express from "express";
import { Webhook } from "standardwebhooks";

const wh = new Webhook(process.env.OUTQUBE_WEBHOOK_SECRET!); // whsec_...
const app = express();

app.post("/outqube/events", express.raw({ type: "*/*" }), (req, res) => {
  let event;
  try {
    event = wh.verify(req.body.toString("utf8"), req.headers as Record<string, string>);
  } catch {
    return res.sendStatus(400);
  }
  // Return quickly; do slow work after you answer.
  console.log(event.type, event.data.run.id);
  res.sendStatus(204);
});

app.listen(3000);

Without a library, the same check is short:

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

export function verify(
  rawBody: string,
  headers: Record<string, string | undefined>,
  secret: string,
): boolean {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signature = headers["webhook-signature"];
  if (!id || !timestamp || !signature) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();

  return signature.split(" ").some((part) => {
    const [version, encoded] = part.split(",");
    if (version !== "v1" || !encoded) return false;
    const received = Buffer.from(encoded, "base64");
    return received.length === expected.length && timingSafeEqual(received, expected);
  });
}

Answer fast and handle retries

Answer any 2xx within 10 seconds. Redirects are not followed and count as a failure. A failed attempt (no 2xx, a timeout or a connection error) is retried after about 5 seconds, 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 3 hours and 6 hours, with up to 20% jitter. That is up to nine attempts over about ten hours.

Delivery is at least once and not ordered. Use webhook-id to ignore an event you have already handled:

const seen = new Set<string>(); // use a database or cache with an expiry in production

function handle(id: string, event: { type: string; data: { run: { api_url: string } } }) {
  if (seen.has(id)) return;
  seen.add(id);
  // Fetch details from event.data.run.api_url with your API key.
}

The payload is thin on purpose: no steps, reasoning, screenshots or logs. Fetch those from data.run.api_url with an API key. When order matters, compare the payload timestamp and status, or fetch the run.

Test it

In Settings, Event webhooks, select Send test event, or call POST /webhooks/{webhookId}/test. Outqube sends a ping. Test events never count towards switching an endpoint off.

An endpoint can be switched off

After 5 messages in a row use up all their retries, Outqube switches the endpoint off, shows a banner in settings and writes a row to the audit log. Fix the receiver, then turn the endpoint on again. Messages sent while it was off are dropped.

Next steps

On this page