Zaroz
Units API guide

Webhooks

A webhook is the same event stream as events, pushed to your server. Webhook routes are for people only (a signed-in user or a personal token with manage access); a service principal cannot call them.

A Workload can have up to 5 webhooks. Each has an https URL on a public host, an event_types filter (a list, or all) and a state: active, failing or disabled. Creating one returns {webhook, secret}: the signing secret, whsec_ and 43 characters, is shown once (and again only by rotate-secret). A new webhook starts with the events after the moment you create it. disabled_reason is failing_72h when it was disabled by repeated failure, otherwise null.

RouteDoes
GET, POST .../units/webhookslist, create
GET, PATCH, DELETE .../units/webhooks/{webhook_id}read, change, delete
POST .../units/webhooks/{webhook_id}/rotate-secretnew secret; the old one keeps signing for 24 hours
POST .../units/webhooks/{webhook_id}/testsends a webhook.ping
POST .../units/webhooks/{webhook_id}/enablere-enable a disabled webhook
GET .../units/webhooks/{webhook_id}/deliveriesattempts, newest first: time, event range (from_seq, to_seq), status code, latency, error, first 4 KiB of your answer, next attempt (kept 30 days). limit 1 to 100 (default 50), before pages back
GET .../units/webhooks/metricsper webhook over the last window_hours (1 to 720, default 24): succeeded, failed, success_rate, average, p95 and max latency

test answers with the attempt itself (status, latency, error) and is limited to 6 a minute per webhook. It works on a disabled webhook too and does not move the cursor. Changing the url or event_types with PATCH keeps delivering from the same cursor; event_types: [] means every type.

curl

TerminalCode
W="$ZAROZ_API/projects/$PROJECT/service-instances/$SERVICE/units/webhooks" curl -sS -X POST "$W" -H "$AUTH" -H "Content-Type: application/json" \ -d '{"url": "https://hooks.example.com/zaroz", "event_types": ["unit.exited", "unit.failed"]}' curl -sS -X POST "$W/<webhook.id from the create response>/test" -H "$AUTH" # sends webhook.ping

TypeScript

TypeScriptCode
const W = `${UNITS}/webhooks`; const hook = await call("POST", W, { url: "https://hooks.example.com/zaroz", event_types: ["unit.exited", "unit.failed"] }); console.log(hook.body.secret); // whsec_...: shown once await call("POST", `${W}/${hook.body.webhook.id}/test`); // sends webhook.ping

Python

Code
W = f"{UNITS}/webhooks" _, hook = call("POST", W, {"url": "https://hooks.example.com/zaroz", "event_types": ["unit.exited", "unit.failed"]}) print(hook["secret"]) # whsec_...: shown once call("POST", f"{W}/{hook['webhook']['id']}/test") # sends webhook.ping

What you receive

A POST of {webhook_id, delivery_id, events: [...]}: at most 50 events or 256 KiB, in seq order, at least once. The next batch is sent only after your 2xx (within 10 seconds), so order is the promise. Deduplicate on seq.

Headers: Zaroz-Signature: v1=<hex>, Zaroz-Timestamp (Unix seconds), Zaroz-Webhook-Id, Zaroz-Delivery-Id.

Verify the signature

The signature is HMAC-SHA256(secret, "{timestamp}.{raw body}") in hex. During a rotation the header holds two v1= values, comma separated: accept either. Refuse a timestamp more than 5 minutes away. Verify the raw bytes you received, not a re-serialised body.

curl

TerminalCode
# the signed string is "<Zaroz-Timestamp>.<raw body>"; compare against each v1= value printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$WHSEC" -hex

TypeScript

TypeScriptCode
import { createHmac, timingSafeEqual } from "node:crypto"; export function verify(secret: string, timestamp: string, rawBody: string, header: string): boolean { if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // refuse > 5 min off const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex"); return header.split(",").some((part) => { const sig = part.trim().replace(/^v1=/, ""); return sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected)); }); }

Python

Code
import hashlib, hmac, time def verify(secret: str, timestamp: str, raw_body: bytes, header: str) -> bool: if abs(time.time() - int(timestamp)) > 300: # refuse more than 5 minutes off return False signed = timestamp.encode() + b"." + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return any(hmac.compare_digest(p.strip().removeprefix("v1="), expected) for p in header.split(","))

Retries and disabling

Failed deliveries retry after 5 s, 30 s, 2 min, 10 min, 30 min, 1 h, then hourly. A webhook failing for 72 hours becomes disabled (failing_72h) and the organization owners are notified; its cursor stays. After enable, delivery resumes from the cursor if the events are still kept, else from the oldest kept event, preceded by an events.gap {from_seq, to_seq} event so you know you missed some.

What the platform refuses to call

The target is resolved once per attempt and every address is checked: loopback, private, link-local, carrier-grade NAT, multicast, unique-local IPv6 and the platform's own ranges are refused. On create or update the answer is an invalid-field-value refusal with address_not_allowed: ... in details; later, at delivery time, the delivery log shows address_not_allowed as the error. The other error values are dns, connect, timeout, http_status, redirect_not_followed, invalid_url and transport. A sixth webhook is refused with 409 business-rule-violation (too_many_webhooks in details). Redirects are not followed. Only the first 4 KiB of your response is read.

Last modified on