> ## Documentation Index
> Fetch the complete documentation index at: https://unif.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Event delivery, signature verification and retries.

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

```bash theme={null}
curl -X POST https://api.unif.dev/v1/webhooks \
  -H "Authorization: Bearer $UNIF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/unif",
    "events": ["job.completed", "job.failed", "tracker.triggered"],
    "description": "Production ingestion"
  }'
```

```json theme={null}
{
  "id": "whk_01k3m9x7v2q8r4t6y0b1n5d7fa",
  "object": "webhook",
  "url": "https://hooks.example.com/unif",
  "events": ["job.completed", "job.failed", "tracker.triggered"],
  "enabled": true,
  "secret": "whsec_7f3c1a9e4b2d8065f1a3c7e9b5d2408f"
}
```

<Warning>
  `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.
</Warning>

The URL must be HTTPS.

## Event types

| Event               | Fires when                                                              |
| ------------------- | ----------------------------------------------------------------------- |
| `job.completed`     | A job reaches `completed` or `partially_completed`                      |
| `job.failed`        | A job reaches `failed`                                                  |
| `tracker.triggered` | A tracker cycle matched at least one condition                          |
| `entity.refreshed`  | A tracked entity's record was refreshed, regardless of whether it moved |

<Note>
  `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`.
</Note>

## Envelope

Every delivery has the same shape. `data` varies by `type`.

```json theme={null}
{
  "id": "evt_01k3m9x7v2q8r4t6y0b1n5d7fa",
  "object": "event",
  "type": "job.completed",
  "created_at": "2026-09-15T02:41:09Z",
  "data": {
    "job_id": "job_01k3m9x7v2q8r4t6y0b1n5d7fa",
    "job_type": "enrich",
    "status": "completed",
    "result_count": 19847
  }
}
```

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:

```
Unif-Signature: t=1789459269,v1=6f2a1c8e5b3d9047a2c6e8b4d1f70395ac82e6b04d19f7c3a5e82b6d0f419c7a
```

`v1` is the hex HMAC-SHA256 of `{timestamp}.{raw_body}`, keyed with your endpoint secret.

<CodeGroup>
  ```python Python theme={null}
  import hashlib, hmac, time

  TOLERANCE = 300  # seconds

  def verify(raw_body: bytes, header: str, secret: str) -> bool:
      parts = dict(p.split("=", 1) for p in header.split(","))
      timestamp, signature = parts.get("t", ""), parts.get("v1", "")

      if abs(time.time() - int(timestamp)) > TOLERANCE:
          return False   # too old — replay

      expected = hmac.new(
          secret.encode(),
          f"{timestamp}.".encode() + raw_body,
          hashlib.sha256,
      ).hexdigest()

      return hmac.compare_digest(expected, signature)
  ```

  ```javascript Node theme={null}
  import crypto from "node:crypto";

  const TOLERANCE = 300;

  export function verify(rawBody, header, secret) {
    const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
    const { t: timestamp, v1: signature } = parts;

    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE) return false;

    const expected = crypto
      .createHmac("sha256", secret)
      .update(`${timestamp}.`)
      .update(rawBody)
      .digest("hex");

    return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
  }
  ```
</CodeGroup>

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.

```python theme={null}
@app.post("/webhooks/unif")
def handle(request):
    if not verify(request.get_data(), request.headers["Unif-Signature"], SECRET):
        return "", 401

    queue.enqueue(process_event, request.get_json())   # do the work elsewhere
    return "", 200
```

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.

<Warning>
  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.
</Warning>

```python theme={null}
if redis.set(f"unif:evt:{event['id']}", 1, nx=True, ex=86400) is None:
    return "", 200   # already handled
```

## 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](/docs/workflows/trackers) can be read with `GET /trackers/{id}` for `last_run_at`.
Polling costs more and tells you later, but nothing else changes.
