Webhooks are how Unif tells you something happened without you asking. Jobs finish and trackers
fire on their own schedule; both deliver here.
Registering an endpoint
secret is returned exactly once, at creation. Store it before you close the response — there
is no endpoint to read it back. If you lose it, delete the endpoint and create a new one.
The URL must be HTTPS.
Event types
entity.refreshed is high volume by design — it fires on every refresh of every entity in a
tracked list. Subscribe to it only if you are mirroring records into your own store; for
“tell me when something changed”, use tracker.triggered.
Envelope
Every delivery has the same shape. data varies by type.
Payloads carry identifiers and summaries, not full result sets. Fetch the data with the ID —
that keeps deliveries small and means a replayed event cannot hand you stale records.
Verifying a delivery
Every request carries a Unif-Signature header:
v1 is the hex HMAC-SHA256 of {timestamp}.{raw_body}, keyed with your endpoint secret.
Three things that are easy to get wrong:
- Sign the raw body. Verify before any JSON parsing or re-serialization. A framework that
re-encodes the body changes the bytes and breaks the signature.
- Compare in constant time.
hmac.compare_digest and timingSafeEqual, never ==.
- Enforce the timestamp window. Without it, a captured delivery stays valid forever.
Responding
Return a 2xx within 10 seconds. Anything else — including a timeout — counts as a failure.
Acknowledge first, process asynchronously. Doing real work inside the handler is how endpoints end
up timing out, which turns a delivered event into a retried one.
Retries and ordering
Failed deliveries retry with exponential backoff over 24 hours: after 10s, 1m, 5m, 30m, 2h, 6h and
12h. After that the event is dropped, and the endpoint is disabled automatically if every delivery
fails for 72 hours straight.
Retries mean at-least-once delivery, and events can arrive out of order. Deduplicate on
event.id, and use created_at to discard an event older than one you have already applied.
Without a public endpoint
If you cannot expose one — a local environment, or a network that will not allow inbound traffic —
poll instead. GET /jobs?status=completed is the supported fallback, and
trackers can be read with GET /trackers/{id} for last_run_at.
Polling costs more and tells you later, but nothing else changes.