# Webhooks

Webhooks push hiring events to your server as they happen, so you don't have to poll.

## Subscribing

Subscribe one HTTPS endpoint per event type (scope `webhooks:write`):

```bash
curl -X POST https://api.aiviue.com/v1/webhooks/subscriptions \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Tenant-ID: $TENANT_ID" \
  -H "X-Idempotency-Key: sub-screening-completed" \
  -H "Content-Type: application/json" \
  -d '{"event_type": "screening.completed", "url": "https://example.com/aiviue/webhooks"}'
```

- `GET /v1/webhooks/subscriptions` lists your subscriptions (`webhooks:read`).
- `DELETE /v1/webhooks/subscriptions/{subscription_id}` removes one.
- `GET /v1/reference-data` returns the full event catalog, including when each event fires.

## Event types

`creative.ready`, `campaign.live`, `screening.ready`, `voice.ready`, `distribution.started`,
`candidate.created`, `candidate.updated`, `screening.started`, `screening.completed`, `interview.scheduled`,
`interview.completed`, `grading.completed`, `campaign.analytics.updated`, `campaign.paused`, `campaign.closed`.

The [API reference](/developers/api-reference) describes each event's payload.

## The request we send

A `POST` with a JSON body:

```json
{
  "eventId": "...",
  "eventType": "screening.completed",
  "eventVersion": "...",
  "occurredAt": "2026-11-02T10:00:00Z",
  "sourceSystem": "AIVIUE",
  "targetSystem": "YOUR_PARTNER_SLUG",
  "idempotencyKey": "...",
  "payload": { }
}
```

Optional fields: `correlationId`, `parentEventId`, `applicationId`, `sequenceNumber`.

Headers:

| Header | Meaning |
| --- | --- |
| `X-Aiviue-Signature` | `sha256=<hex HMAC>` of the raw body — verify it |
| `X-Aiviue-Event` | The event type |
| `X-Aiviue-Delivery` | Unique ID of this delivery attempt |
| `X-Aiviue-Sequence` | Event sequence number, when present |
| `X-Correlation-Id` | Correlation ID, when present |

## Verifying the signature

Your webhook secret is issued with your partner account. Compute an HMAC-SHA256 of the **raw request body bytes**
with that secret and compare it to the header in constant time. Don't parse and re-serialize the JSON first.

```python
import hashlib, hmac

def is_valid(secret: str, raw_body: bytes, header: str) -> bool:
    expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header or "")
```

```javascript
import crypto from "node:crypto"

function isValid(secret, rawBody, header) {
  const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex")
  const a = Buffer.from(expected)
  const b = Buffer.from(header ?? "")
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}
```

The signature does not include a timestamp, so de-duplicate on `eventId` (or `idempotencyKey`) to ignore
replays and retries you have already processed.

## Responding and retries

- Return any `2xx` status within **30 seconds** to acknowledge. Do slow work after responding.
- Anything else, or a timeout, is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 8 hours —
  **6 attempts** in total.
- After the last failed attempt the delivery is marked dead and our team is alerted.
- Deliveries can arrive more than once and out of order. Use `eventId` for de-duplication and
  `sequenceNumber`/`occurredAt` for ordering.

## Delivery log and replay

- `GET /v1/webhooks/deliveries` shows recent deliveries and their status.
- `POST /v1/webhooks/deliveries/{delivery_id}/replay` (`webhooks:write`) sends a delivery again with the same
  `idempotencyKey`. Deliveries older than 30 days can't be replayed (`403 REPLAY_EXPIRED`), a pending delivery
  returns `409 CONFLICT`, and replays are rate limited (`429 RATE_LIMITED`).
