Owlette API webhooks
webhooks are how roost tells your systems that something happened — a new version published, a deploy failed, a machine dropped offline, a quota alarm tripped. instead of polling the api on a timer, you subscribe a url once and roost posts a signed json event to it when a matching event is dispatched.
developer preview status: subscription management, delivery history, manual retry, and POST /api/webhooks/probe are public. automatic production event dispatch and SSE event fanout are still deferred; owlette listen is a scoped liveness stream for now. The cloud-function dispatcher that exists today still uses a legacy event catalog and is not fully aligned with public webhook records because its subscription schema expects legacy fields (secret/disabled) instead of public fields (signingSecret/paused). Receiver signature verification and probe flows are stable.
what webhooks are
a webhook is an http POST that roost sends to a url you control whenever a subscribed event is dispatched for your site. the request body is a json envelope describing the event; the headers carry a signature you verify before trusting the payload. your endpoint returns any 2xx status to acknowledge receipt. for dispatcher and manual retry records, anything else follows the retry model below; POST /api/webhooks/probe reports the result but does not retry.
the model is stripe/github shaped: one subscription per url, each scoped to a site and to an explicit list of event types. secrets are per-subscription, shown once at creation, and rotatable with a grace window.
current delivery envelopes
Owlette currently has two webhook delivery paths. They are documented separately because the public probe/subscription api and the production dispatcher are not aligned yet.
probe envelope
POST /api/webhooks/probe?siteId=<id> validates the requested event against the public developer-preview catalog and sends a signed, one-shot POST to the supplied url. The current probe body includes both a top-level siteId and an event id:
{
"id": "evt_01HYCAM5T4P9R1S3U7V8W0X2Y0",
"event": "version.published",
"occurredAt": "2026-04-22T15:30:00Z",
"siteId": "kiosk-fleet-01",
"data": { }
}id is unique to the probe event. event is the stable event-type string. occurredAt is rfc 3339 utc. siteId is the scoped site from the query string. data is event-specific and documented below.
production dispatcher envelope
The current Cloud Functions dispatcher accepts and stores a different envelope:
{
"event": "version.published",
"siteId": "kiosk-fleet-01",
"occurredAt": "2026-04-22T15:30:00Z",
"data": { }
}This dispatcher envelope has no top-level id. Delivery idempotency is carried by the Roost-Delivery header instead. The dispatcher now collection-group queries webhooks, while the public api creates subscriptions at sites/{siteId}/webhooks/{webhookId}; automatic production dispatch to public api-created subscriptions is therefore deferred until the dispatcher's legacy subscription schema (secret/disabled) is aligned with public webhook records (signingSecret/paused).
event catalog
The public subscription validator and probe endpoint currently accept these developer-preview event names:
version.published,version.rolled_backdeployment.started,deployment.completed,deployment.failedmachine.online,machine.offlinechunk.garbage_collected,chunk.verify_failedquota.warning,quota.exceededapi_key.used,api_key.expired
The production dispatcher currently validates this legacy catalog instead:
distribution.queued,distribution.started,distribution.succeeded,distribution.failedchunk.uploadedversion.publishedrollback.executed
Treat the dispatcher catalog as legacy until production dispatch is aligned with the public subscription api. The examples below show the public probe/subscription catalog and use the probe envelope shape.
roost lifecycle
These lifecycle events are planned and are not accepted by the current developer-preview subscription validator yet. The current accepted catalog begins at versions.
-
roost.created— a new roost was created viaPOST /api/roosts.{ "id": "evt_01HYCAM5T4P9R1S3U7V8W0X2Y0", "event": "roost.created", "occurredAt": "2026-04-22T15:30:00Z", "siteId": "kiosk-fleet-01", "data": { "roostId": "roost_lobby_td", "siteId": "kiosk-fleet-01", "name": "lobby-touchdesigner", "targets": ["machine-a7f3", "machine-b2c1"] } } -
roost.updated— a roost's name or target list changed viaPATCH /api/roosts/{id}.{ "id": "evt_01HYCAM5T4P9R1S3U7V8W0X2Y1", "event": "roost.updated", "occurredAt": "2026-04-22T15:30:00Z", "siteId": "kiosk-fleet-01", "data": { "roostId": "roost_lobby_td", "siteId": "kiosk-fleet-01", "changed": ["targets"], "targets": ["machine-a7f3", "machine-b2c1", "machine-c3d4"] } } -
roost.deleted— a roost was soft-deleted viaDELETE /api/roosts/{id}.{ "id": "evt_01HYCAM5T4P9R1S3U7V8W0X2Y2", "event": "roost.deleted", "occurredAt": "2026-04-22T15:30:00Z", "siteId": "kiosk-fleet-01", "data": { "roostId": "roost_lobby_td", "siteId": "kiosk-fleet-01", "deletedAt": "2026-04-22T15:30:00Z", "purgeAt": "2026-05-22T15:30:00Z" } }
versions
-
version.published— a new version was published viaPOST /api/roosts/{id}/versionsand the roost's current pointer moved to it.{ "id": "evt_01HYCAM5T4P9R1S3U7V8W0X2Y3", "event": "version.published", "occurredAt": "2026-04-22T15:30:00Z", "siteId": "kiosk-fleet-01", "data": { "roostId": "roost_lobby_td", "siteId": "kiosk-fleet-01", "versionId": "vrs_8d969eef6ecad3c29a3a629280e686cf", "versionNumber": 7, "description": "fixed broken lobby video", "totalFiles": 342, "totalSize": 2147483648, "createdBy": "user_01HYA7R3K2N9P1Q5S6T8V0W4X" } } -
version.rolled_back— a rollback flipped the roost's current version pointer to a prior version.{ "id": "evt_01HYCAM5T4P9R1S3U7V8W0X2Y4", "event": "version.rolled_back", "occurredAt": "2026-04-22T15:35:00Z", "siteId": "kiosk-fleet-01", "data": { "roostId": "roost_lobby_td", "siteId": "kiosk-fleet-01", "fromVersion": "vrs_8d969eef6ecad3c29a3a629280e686cf", "toVersion": "vrs_2c26b46b68ffc68ff99b453c1d304134", "triggeredBy": "user_01HYA7R3K2N9P1Q5S6T8V0W4X" } }
deployments
-
deployment.started— a rollout began fanning out to targets.{ "id": "evt_01HYCAM5T4P9R1S3U7V8W0X2Y5", "event": "deployment.started", "occurredAt": "2026-04-22T15:30:00Z", "siteId": "kiosk-fleet-01", "data": { "rolloutId": "rollout_01HYA8K3R2N7P9Q1S5T6U8V0W2", "roostId": "roost_lobby_td", "siteId": "kiosk-fleet-01", "stage": "started" } } -
deployment.completed— every machine in the rollout reachedsucceeded.{ "id": "evt_01HYCAM5T4P9R1S3U7V8W0X2Y6", "event": "deployment.completed", "occurredAt": "2026-04-22T15:34:12Z", "siteId": "kiosk-fleet-01", "data": { "rolloutId": "rollout_01HYA8K3R2N7P9Q1S5T6U8V0W2", "roostId": "roost_lobby_td", "siteId": "kiosk-fleet-01", "stage": "completed", "succeeded": 2, "failed": 0 } } -
deployment.failed— the rollout hit a terminal failure (canary failed, or per-machine retry budget exhausted).{ "id": "evt_01HYCAM5T4P9R1S3U7V8W0X2Y7", "event": "deployment.failed", "occurredAt": "2026-04-22T15:33:48Z", "siteId": "kiosk-fleet-01", "data": { "rolloutId": "rollout_01HYA8K3R2N7P9Q1S5T6U8V0W2", "roostId": "roost_lobby_td", "siteId": "kiosk-fleet-01", "stage": "failed", "abortReason": "chunk_verify_failed", "succeeded": 1, "failed": 1 } }
machines
-
machine.online— a machine resumed reporting heartbeats after being offline.{ "id": "evt_01HYCAM5T4P9R1S3U7V8W0X2Y8", "event": "machine.online", "occurredAt": "2026-04-22T15:30:00Z", "siteId": "kiosk-fleet-01", "data": { "machineId": "machine-a7f3", "siteId": "kiosk-fleet-01", "lastHeartbeat": "2026-04-22T15:29:58Z" } } -
machine.offline— a machine's heartbeat went stale past the threshold.{ "id": "evt_01HYCAM5T4P9R1S3U7V8W0X2Y9", "event": "machine.offline", "occurredAt": "2026-04-22T14:02:12Z", "siteId": "kiosk-fleet-01", "data": { "machineId": "machine-a7f3", "siteId": "kiosk-fleet-01", "lastHeartbeat": "2026-04-22T13:57:00Z" } }
chunks
-
chunk.garbage_collected— a chunk was reclaimed by the gc sweeper after losing all referrers and serving its 30-day grace period.{ "id": "evt_01HYCAM5T4P9R1S3U7V8W0X2Z0", "event": "chunk.garbage_collected", "occurredAt": "2026-04-22T03:00:00Z", "siteId": "kiosk-fleet-01", "data": { "siteId": "kiosk-fleet-01", "hash": "sha256:4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce", "sizeBytes": 4194304 } } -
chunk.verify_failed— a background verify pass found bytes at rest that do not match their claimed digest.{ "id": "evt_01HYCAM5T4P9R1S3U7V8W0X2Z1", "event": "chunk.verify_failed", "occurredAt": "2026-04-22T04:15:00Z", "siteId": "kiosk-fleet-01", "data": { "siteId": "kiosk-fleet-01", "hash": "sha256:4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce", "expectedDigest": "sha256:4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce", "actualDigest": "sha256:5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9" } }
quota
-
quota.warning— site storage usage crossed the 50% or 80% threshold. fired once per threshold per billing period.{ "id": "evt_01HYCAM5T4P9R1S3U7V8W0X2Z2", "event": "quota.warning", "occurredAt": "2026-04-22T10:00:00Z", "siteId": "kiosk-fleet-01", "data": { "siteId": "kiosk-fleet-01", "tier": "pro", "threshold": 0.8, "usedBytes": 85899345920, "limitBytes": 107374182400 } } -
quota.exceeded— site hit 100% of its storage or bandwidth limit; writes are rejected withquota_exceededuntil usage drops or the period resets.{ "id": "evt_01HYCAM5T4P9R1S3U7V8W0X2Z3", "event": "quota.exceeded", "occurredAt": "2026-04-22T11:30:00Z", "siteId": "kiosk-fleet-01", "data": { "siteId": "kiosk-fleet-01", "tier": "pro", "usedBytes": 107374182400, "limitBytes": 107374182400 } }
api keys
-
api_key.used— an api key authenticated a request (sampled; not fired for every call — intended for first-use-from-new-ip alerting).{ "id": "evt_01HYCAM5T4P9R1S3U7V8W0X2Z4", "event": "api_key.used", "occurredAt": "2026-04-22T15:30:00Z", "siteId": "kiosk-fleet-01", "data": { "keyId": "key_01HXYZA7F3B2C1D0E9F8G7H6J5", "keyPrefix": "owk_live_kB8n3p", "ip": "203.0.113.42", "userAgent": "owlette-cli/1.0.0-rc.0", "firstUseFromIp": true } } -
api_key.expired— an api key'sexpiresAthas passed; subsequent requests return401 token_expired.{ "id": "evt_01HYCAM5T4P9R1S3U7V8W0X2Z5", "event": "api_key.expired", "occurredAt": "2026-07-21T15:30:00Z", "siteId": "kiosk-fleet-01", "data": { "keyId": "key_01HXYZA7F3B2C1D0E9F8G7H6J5", "keyPrefix": "owk_live_kB8n3p", "name": "ci/cd — prod", "expiresAt": "2026-07-21T15:30:00Z" } }
signature format
every delivery carries a Roost-Signature header modeled on stripe's:
Roost-Signature: t=1745334602,v1=5f3e8a7c2b9d1e4f6a8c0e2d4f6a8c0e2d4f6a8c0e2d4f6a8c0e2d4f6a8c0e2dt— unix timestamp (seconds) when roost generated the signature.v1— hex-encodedhmac-sha256(signingSecret, "<t>.<raw_body>")where<raw_body>is the exact bytes of the request body (no whitespace normalization, no re-serialization).
verification has three steps, all mandatory:
- recompute
v1from the signing secret, thetvalue from the header, and the raw request body. compare using constant-time comparison (hmac.compare_digestin python,crypto.timingSafeEqualin node). - reject if
|now - t| > 300(5-minute replay tolerance). this is what stops a stolen payload from being replayed an hour later. - be idempotent on
Roost-Delivery— if you've already processed that delivery id, respond200without reprocessing.
the v1= prefix leaves room for a future v2= algorithm. verifiers should accept any matching v1= value and ignore unknown scheme prefixes until a new algorithm is explicitly supported.
signature verification
all three examples below implement the same three-step protocol: split the header, check the timestamp against a 5-minute window, and constant-time-compare the hmac. prefer the sdk helpers over hand-rolled code — they ship with the replay check wired in and use timing-safe comparison by default.
node
// npm install @owlette/sdk express
import express from 'express';
import { verifySignature } from '@owlette/sdk';
const app = express();
const SECRET = process.env.ROOST_WEBHOOK_SECRET!;
// webhooks need the raw body bytes — register the raw parser BEFORE
// json-parser on this route.
app.post(
'/webhooks/roost',
express.raw({ type: 'application/json' }),
async (req, res) => {
const verdict = verifySignature(
req.headers['roost-signature'] as string | undefined,
req.body as Buffer, // raw bytes, NOT the parsed json
SECRET,
// { toleranceSeconds: 300 } — default; override only if you must.
);
if (!verdict.ok) {
// possible reasons: missing_header | malformed_header | missing_timestamp | missing_v1 | timestamp_out_of_tolerance | bad_signature
return res.status(401).json({ error: verdict.reason });
}
const event = JSON.parse(req.body.toString('utf-8'));
// dedup on Roost-Delivery — a retry of the same attempt keeps the id.
const deliveryId = String(req.headers['roost-delivery']);
if (await isAlreadyProcessed(deliveryId)) return res.status(200).end();
await handle(event);
res.status(200).end();
},
);
app.listen(8080);python
# pip install owlette-sdk fastapi uvicorn
import os
from fastapi import FastAPI, Request, HTTPException
from roost import verify_signature
app = FastAPI()
SECRET = os.environ["ROOST_WEBHOOK_SECRET"]
@app.post("/webhooks/roost")
async def webhook(request: Request):
raw = await request.body() # raw bytes — never use request.json()
sig = request.headers.get("roost-signature")
verdict = verify_signature(sig, raw, secret=SECRET)
if not verdict.ok:
# reason: "missing_header" | "malformed_header" | "missing_timestamp" | "missing_v1" | "timestamp_out_of_tolerance" | "bad_signature"
raise HTTPException(status_code=401, detail=verdict.reason or "bad_signature")
delivery_id = request.headers.get("roost-delivery", "")
if await is_already_processed(delivery_id):
return {"ok": True, "deduped": True}
import json
event = json.loads(raw)
await handle(event)
return {"ok": True}bash
no sdk — pure openssl + jq. useful when your receiver is a shell pipeline or a cloud function where adding a sdk is overkill. run this as a cgi script or behind a tiny http wrapper.
#!/usr/bin/env bash
# verify-roost-webhook.sh — stdin is the raw request body; headers are in env vars
# HTTP_ROOST_SIGNATURE + HTTP_ROOST_DELIVERY (cgi style).
set -euo pipefail
: "${ROOST_WEBHOOK_SECRET:?set to the signing secret returned by POST /api/webhooks}"
TOLERANCE_SECONDS="${TOLERANCE_SECONDS:-300}"
body="$(cat)" # raw bytes from stdin
sig_header="${HTTP_ROOST_SIGNATURE:-}"
[[ -n "$sig_header" ]] || { echo "missing Roost-Signature" >&2; exit 1; }
# parse "t=<unix>,v1=<hex>" — tolerate other v*= schemes following it
t=""; v1=""
while IFS= read -r part; do
case "$part" in
t=*) t="${part#t=}" ;;
v1=*) v1="${part#v1=}" ;;
esac
done < <(tr ',' '\n' <<<"$sig_header")
[[ -n "$t" && -n "$v1" ]] || { echo "malformed signature header" >&2; exit 1; }
# replay window
now="$(date -u +%s)"
delta=$(( now > t ? now - t : t - now ))
(( delta <= TOLERANCE_SECONDS )) || { echo "outside_tolerance ($delta s)" >&2; exit 1; }
# recompute v1 = hmac_sha256(secret, "<t>.<body>")
expected="$(printf '%s.%s' "$t" "$body" \
| openssl dgst -sha256 -hmac "$ROOST_WEBHOOK_SECRET" -hex \
| awk '{print $NF}')"
# constant-time compare: use openssl's equal-length buffer diff via `cmp`
[[ "${#expected}" -eq "${#v1}" ]] || { echo "bad_signature" >&2; exit 1; }
if ! cmp --silent <(printf '%s' "$expected") <(printf '%s' "$v1"); then
echo "bad_signature" >&2; exit 1
fi
echo "ok delivery=${HTTP_ROOST_DELIVERY:-<none>} event=$(jq -r '.event // empty' <<<"$body")"note on bash timing safety —
cmp --silentis the closest posix has to a constant-time hex compare. the hash values are non-secret on one side (the computed$expected) and the attacker-controlled$v1has fixed length, so timing leaks here are not exploitable in practice. if you need strict constant-time comparison, run the python or node examples instead.
delivery headers
Current delivery paths set slightly different headers.
Probe deliveries include:
Content-Type: application/json— body is always a json envelope.Roost-Event: <event.name>— redundant with the body'seventfield; useful for routing without parsing the body.Roost-Delivery: <uuid>— random per probe firing. use this as your idempotency key on the receiver.Roost-Signature: t=<unix>,v1=<hex>— see signature format above.User-Agent: roost-probe/1.0— probe-specific user agent for receiver allowlisting.
Production dispatcher deliveries currently include Content-Type, Roost-Event, Roost-Delivery, and Roost-Signature. The dispatcher does not currently set a User-Agent header. Its Roost-Delivery value is stable for the same event/body across automatic retries and manual retry records.
delivery guarantees
The guarantees below describe the current Cloud Functions dispatcher and manual retry records. Automatic event dispatch to public api-created subscriptions is deferred; POST /api/webhooks/probe is a one-shot live POST and is not retried or stored in delivery history.
- at-least-once. a delivery may arrive more than once if your receiver times out after processing but before returning
2xx. dedup onRoost-Delivery. - response handling.
2xxsucceeds. network errors,5xx,408,425, and429are retried. other4xxresponses are treated as permanent receiver failures and are not retried. - retries. retried deliveries use exponential backoff with 5s base delay, factor 3, 1h maximum delay, 20% jitter, and 10 total attempts. without jitter the retry delays after failed attempts are roughly 5s, 15s, 45s, 2m15s, 6m45s, 20m15s, then 1h until the attempt budget is exhausted. each attempt gets a fresh
Roost-Signaturetimestamp but the sameRoost-Deliveryid. - dead letter. after the 10th failed attempt the delivery is marked
failed. the dispatcher does not currently auto-pause or auto-disable the subscription fromattemptDelivery; operators can still updatepausedexplicitly viaPATCH /api/webhooks/{id}on public webhook subscriptions. - retention.
GET /api/webhooks/{id}/deliverieslists 30 days of delivery summaries.GET /api/webhooks/{id}/deliveries/{deliveryId}returns stored request details plus the captured response status. - ordering. deliveries are not globally ordered. if you need to reason about order, use
occurredAtin the payload; do not rely on the order deliveries arrive.
subscription management
every operation on a webhook subscription - create, list, detail, update, delete, rotate-secret, delivery history, manual retry, and url probe - is a public api endpoint. the authoritative contract is web/openapi.yaml.
POST /api/webhooks?siteId=<id>- create a subscription (returnssigningSecretonce).GET /api/webhooks?siteId=<id>/GET /api/webhooks/{id}?siteId=<id>- list / detail.PATCH /api/webhooks/{id}?siteId=<id>- update url, events, description, or paused state.DELETE /api/webhooks/{id}?siteId=<id>- soft-delete and retain a 30-day tombstone.POST /api/webhooks/{id}/rotate-secret?siteId=<id>- mint a new signing secret with a 24h grace window.GET /api/webhooks/{id}/deliveries?siteId=<id>/GET /api/webhooks/{id}/deliveries/{deliveryId}?siteId=<id>- delivery history + single delivery detail.POST /api/webhooks/{id}/deliveries/{deliveryId}/retry?siteId=<id>- manually redeliver a past event.POST /api/webhooks/probe?siteId=<id>- fire a signed synthetic event at a url without creating a subscription.
scopes: list/detail/deliveries require site=<id>:read; create/update/delete/rotate/probe/retry require site=<id>:write.
local development
owlette listen opens the scoped server-sent-events transport at GET /api/events/stream?siteId=<id>. in the current developer preview the stream is a liveness channel: the server emits connected and keepalive events, but owlette listen currently consumes them internally and does not forward them to the receiver. production event fanout is deferred to a follow-up wave; do not rely on the stream for live webhook delivery yet.
# 1. log in once per machine (stores an API key in the OS keychain when available, otherwise ~/.config/owlette/credentials.json; profile metadata lives in ~/.config/owlette/config.toml)
owlette auth login
# 2. start your receiver on :8080, using a throwaway test secret
export ROOST_WEBHOOK_SECRET=whsec_local_dev_0000000000000000000000000000000000
node my-receiver.js &
# 3. open the scoped SSE transport
owlette listen \
--site kiosk-fleet-01 \
--forward-to http://localhost:8080/webhooks/roost \
--signing-secret "$ROOST_WEBHOOK_SECRET" \
--events version.published,deployment.failed
# currently consumes connected/keepalive internally. Ctrl-C to stop.a stream open currently logs liveness locally rather than forwarding it:
stream connected
keepalivecommon local-dev patterns:
- verifying a new integration without touching prod.
owlette trigger version.published --site kiosk-fleet-01 --to http://localhost:8080/webhooks/roost --signing-secret "$ROOST_WEBHOOK_SECRET"fires a single canned event locally. no tunnel, no subscription, no network. - smoke-testing a public receiver before subscribing.
POST /api/webhooks/probe?siteId=<id>(orowlette trigger <event> --site <id> --to <url> --via-api) fires a one-shot signed payload at any https url and returns the request body + signature so you can confirm your receiver accepts the signature before you create a real subscription. - debugging a stuck delivery.
GET /api/webhooks/{id}/deliverieslists the last 30 days of delivery summaries;GET /api/webhooks/{id}/deliveries/{deliveryId}returns stored request details plus the captured response status.POST /api/webhooks/{id}/deliveries/{deliveryId}/retryqueues the same payload for redelivery with a freshRoost-Signaturetimestamp so it slides back inside the 5-minute tolerance window.