Browse the docs

Guide 08

Webhooks

Receive signed events on your endpoint: verify, rotate, retry, redeliver and disable.

Last updated from API version 2026-10-06 at (live spec, refreshed hourly). openapi.json

A webhook endpoint receives the event feed as signed POST requests, retried for about 24 hours. Webhooks are a convenience: GET /api/v1/events stays the source of truth, so a missed delivery is never lost.

Create an endpoint

POST /webhook-endpoints (scope write) with a public https URL and the event types to receive (["*"] for all of them, also future ones). The answer carries the signing secret owl_whsec_... once: store it. At most 20 endpoints per workspace. You can also manage endpoints in the app, Settings > Developer.

bash
curl -X POST "https://app.owlbound.ai/api/v1/webhook-endpoints" \
  -H "Authorization: Bearer $OWLBOUND_API_KEY" \
  -H "Owlbound-Version: 2026-10-06" \
  -H "Content-Type: application/json" \
  -d '{
  "url": "https://hooks.example.com/owlbound",
  "events": ["action.completed", "approval.decided"],
  "description": "Production"
}'

Private, loopback, link-local and metadata addresses are refused (422 HOST_NOT_ALLOWED), at creation and on every delivery. Redirects are never followed: answer the POST directly.

What a delivery looks like

Each delivery is one event (the same JSON as a GET /events item) with these headers:

HeaderUse
Owlbound-Signaturet=<unix>,v1=<hex>: HMAC-SHA256 of t + "." + body with the secret. Two v1 values during a rotation.
Owlbound-Event-IdDedupe on it: an event can arrive more than once.
Owlbound-Delivery-IdThis attempt's delivery.

Verify the signature (Node)

Verify against the raw body, before parsing it, and reject a t more than 5 minutes old.

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

// rawBody: the request body exactly as received (a string, before JSON.parse).
export function verifyOwlboundWebhook(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = header.split(",").map((p) => p.trim().split("="));
  const t = Number(parts.find(([k]) => k === "t")?.[1]);
  const signatures = parts.filter(([k]) => k === "v1").map(([, v]) => v);
  if (!Number.isInteger(t) || signatures.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false; // replay window
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  return signatures.some((hex) => {
    const given = Buffer.from(hex, "hex");
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}
// Then dedupe on the Owlbound-Event-Id header: an event can arrive more than once.
Express
app.post("/owlbound", express.text({ type: "application/json" }), (req, res) => {
  const ok = verifyOwlboundWebhook(
    req.body,
    req.get("Owlbound-Signature") ?? "",
    process.env.OWLBOUND_WEBHOOK_SECRET,
  );
  if (!ok) return res.sendStatus(400);
  res.sendStatus(204); // answer fast, then work
  queue.add(JSON.parse(req.body), { jobId: req.get("Owlbound-Event-Id") });
});

Retries, disable and redeliver

Rotate the secret

POST /webhook-endpoints/{id}/rotate-secret returns a new secret once. The old one keeps signing for 24 hours, so during the overlap each delivery carries two v1 signatures and the verifier above accepts either. Deploy the new secret, then let the old one expire.