# Webhooks

Webhooks push events from Hyperproof TPRM Core to an HTTPS endpoint you register,
so you do not have to poll. Endpoints are registered from the Hyperproof app
(ask your organization admin), and each endpoint subscribes to a list of event types.

## What triggers an event

Webhooks announce changes made **inside Hyperproof** — by users in the app, or
by Hyperproof's own systems (for example, a completed monitoring run or a
finished export). Changes you make **through this API are not echoed back** as
webhooks: your integration already knows about its own writes, and an echo
would create a loop. If you run several integrations against one organization,
let them share state with each other directly instead of listening for each
other's API writes.

## Event catalog

Subscribe by exact event name — the live list is always at
`GET /manage/webhooks/events`. The catalog by family:

- **Vendors** — `vendor.created`, `vendor.updated` (carries only what changed:
`data.changes` maps each field to its `previous` and `current` value),
`vendor.archived`, `vendor.risk_rating_changed`.
- **Assessments** — `assessment.created` (fires when the creation wizard is
finalized, never for drafts), `assessment.status_changed` (any status move —
completion arrives here as `data.status: "Completed"`, reopening as
`"InProgress"`), `assessment.ai_started` (AI analysis began processing the
assessment), and `assessment.submitted` (an external collaborator submitted
a module; `data.module` is `questionnaire`, `followup`, or `documents`).
- **Questions** — `question.marked_done` (`data.kind` is `question` or
`followup`).
- **Risks** — `risk.created` (a risk was added to the register from an
assessment question; `data.source` is `ai_suggested` or `manual`, and
`data.risk` carries the created risk's fields).
- **Collaborators** — `collaborator.added`, `collaborator.updated`.
- **Monitoring** — `monitoring.run_started`, `monitoring.run_completed`,
`monitoring.run_failed`.
- **Files** — `document.uploaded` (ids are ready for
`GET /downloads/document/{id}`) and `export.completed`.


## Delivery semantics

- **Process deliveries idempotently.** In normal operation, every event is
sent exactly once. Only a failure recovery on our side can cause the same
delivery to be sent again, and it always carries the **same** event `id` and
`X-Hyperproof-Delivery-Id`. If you key your processing on either id, a
re-send becomes a harmless no-op.
- **Best-effort ordering.** Events usually arrive in order, but retries and
concurrent workers can reorder them. Sort by the `occurred_at` field in the
envelope, never by arrival time.
- **Timeout.** Your endpoint has **30 seconds** to respond. Respond with any
`2xx` as quickly as you can; if your processing is slow, queue the work
internally and respond first.


## Retries

We retry a delivery after a `5xx` response, a `429`, or a network failure — up
to **5 attempts**, with backoff of **0s, 30s, 2m, 10m, 1h**. Any other `4xx`
response is treated as permanent and is not retried. After **10 consecutive**
permanent failures, the endpoint is deactivated automatically. Once your URL
is healthy again, re-enable the endpoint from the Hyperproof app; a live
verification ping is required.

## The envelope

```json
{
  "id": "evt_…",
  "type": "vendor.updated",
  "api_version": "v1",
  "occurred_at": "2026-07-12T09:30:00+00:00",
  "source": "ui",
  "data": { "…": "…" },
  "delivery_id": "whd_…"
}
```

Entity ids inside `data` are the same opaque ids the REST API returns, so you
can feed them straight back into API calls.

## Verifying signatures

Every delivery is signed, so you can prove it came from Hyperproof:

| Header | Value |
|  --- | --- |
| `X-Hyperproof-Timestamp` | Unix seconds at send time. |
| `X-Hyperproof-Signature` | One or more `v1=<hex>` values, comma-separated. |
| `X-Hyperproof-Event` | The event type. |
| `X-Hyperproof-Event-Id` / `X-Hyperproof-Delivery-Id` | Correlation ids. |


Each signature is `HMAC-SHA256(secret, "{timestamp}.{raw_body}")`. Compute it
with your endpoint's signing secret, and accept the delivery only if it
matches **any** of the presented signatures (use a constant-time comparison)
and the timestamp is within your tolerance (we recommend 5 minutes).

```python
import hashlib, hmac

def verify(secret: str, timestamp: str, raw_body: bytes, header: str) -> bool:
    expected = hmac.new(
        secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return any(
        hmac.compare_digest(sig.partition("=")[2], expected)
        for sig in header.split(",")
    )
```

**Why can the header carry two signatures?** After you rotate an endpoint's
signing secret, deliveries are co-signed with the old **and** the new secret
for 24 hours, so you can switch keys on your side without dropping events.
During that window the header looks like this:

```
X-Hyperproof-Signature: v1=5257a869e7ec…08d8bd,v1=9f31c04ab27e…d341aa
```

The **first** value is signed with the current (new) secret; the **second**
with the secret being rotated out. You never need to work out which is which:
compute your HMAC with the one secret you hold and accept the delivery if it
equals **any** of the presented values — before you switch keys, yours matches
the second; after you switch, it matches the first. Your verification code
never changes. Once the 24-hour overlap ends, the header carries a single
signature again.