Skip to content
Last updated

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:

  • Vendorsvendor.created, vendor.updated (carries only what changed: data.changes maps each field to its previous and current value), vendor.archived, vendor.risk_rating_changed.
  • Assessmentsassessment.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).
  • Questionsquestion.marked_done (data.kind is question or followup).
  • Risksrisk.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).
  • Collaboratorscollaborator.added, collaborator.updated.
  • Monitoringmonitoring.run_started, monitoring.run_completed, monitoring.run_failed.
  • Filesdocument.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

{
  "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:

HeaderValue
X-Hyperproof-TimestampUnix seconds at send time.
X-Hyperproof-SignatureOne or more v1=<hex> values, comma-separated.
X-Hyperproof-EventThe event type.
X-Hyperproof-Event-Id / X-Hyperproof-Delivery-IdCorrelation 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).

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.