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.
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:
| Header | Use |
|---|---|
Owlbound-Signature | t=<unix>,v1=<hex>: HMAC-SHA256 of t + "." + body with the secret. Two v1 values during a rotation. |
Owlbound-Event-Id | Dedupe on it: an event can arrive more than once. |
Owlbound-Delivery-Id | This attempt's delivery. |
Verify the signature (Node)
Verify against the raw body, before parsing it, and reject a t more than 5 minutes old.
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.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
- A
2xxanswer within 10 seconds is delivered. Anything else (including any3xx) is retried with backoff: 1 min, 5 min, 30 min, 2 h, 5 h, 8 h, 8 h, 8 attempts over about 24 hours, then the delivery isfailed. - An endpoint that fails every delivery for 3 days is disabled and its owner is emailed. Enable it again with PATCH /webhook-endpoints/{id} (
status: "enabled");status: "disabled"pauses it yourself. - GET /webhook-endpoints/{id}/deliveries shows each attempt's status and response code (kept 7 days); POST /webhook-endpoints/{id}/deliveries/{deliveryId}/redeliver sends an event again, with a fresh signature and the same event id.
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.