# Webhooks

Instead of polling, subscribe and let changes reach you.

Register an endpoint on the `webhook_endpoints` resource — it needs `webhook_endpoints.write`; the exact routes are in the <a href="../../api/index.html">API Reference</a>. Creation returns a signing **secret** (`whsec_…`) **once**. Store it immediately; it cannot be recovered.

## Events are thin

The body is deliberately small:

```json
{
  "id": "evt_0194d1c0-…",
  "object": "event",
  "type": "shift.published",
  "created": "2026-07-22T10:04:11Z",
  "related_object": {
    "id": "0194d1c0-…",
    "url": "https://app.shiftavo.com/api/public/v1/shifts/0194d1c0-…/"
  }
}
```

Fetch the current state from `related_object.url` rather than trusting the event to carry it. That keeps you correct when several changes land close together.

Delivery is **at-least-once and possibly out of order**, so your handler must be **idempotent** — dedupe on the event `id`.

## Verify every delivery

Each `POST` carries a `Webhook-Signature` header (plus `Webhook-Id`):

```
Webhook-Signature: t=1753178651,v1=5f3a…
```

To verify:

1. Recompute `HMAC-SHA256(secret, "{t}.{raw_body}")` — over the **raw** body bytes, before any JSON parsing or re-serialization.
2. Constant-time-compare it against a `v1=` value.
3. Reject if `t` is outside a **~5-minute** tolerance.

```python
import hmac, hashlib, time

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    if abs(time.time() - int(parts["t"])) > 300:
        return False
    expected = hmac.new(
        secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    # A rotation sends several v1= values — accept a match against any of them.
    signatures = [v for k, v in (p.split("=", 1) for p in header.split(",")) if k == "v1"]
    return any(hmac.compare_digest(expected, sig) for sig in signatures)
```

```{important}
During a secret **rotation** the header carries **multiple** `v1=` signatures — old and new — for an overlap window. Accept a match against any of them, or rotations will drop deliveries.
```

## Catching up after an outage

Failed deliveries are retried with exponential backoff, and the dispatched-event history is retained. If your endpoint was down you can reconcile from that history instead of re-polling the domain resources.

## Subscribe to the whole lifecycle

The commonest integration bug here is subscribing only to the happy path and leaving a dead posting live in your copy.

Marketplace events come in `open_shift.*` / `swap.*` pairs — one offer is published as two resources (`open_shifts/` for vacancies, `swaps/` for give-aways and swaps), and the event type tells you which collection to fetch. Both carry the terminal transitions as well as the awards:

- `open_shift.claimed`, `open_shift.awarded`, `swap.approved`
- `open_shift.rejected`, `swap.rejected`, `*.withdrawn`, `*.auto_rejected`, `*.expired`

Subscribe to the terminal transitions too, not just the awards.

The full list of event types you can subscribe to is on the `webhook_endpoints` resource in the <a href="../../api/index.html">API Reference</a>.
