Webhooks
Push audit events to your own endpoint as they happen. Deliveries are signed with HMAC-SHA-256 (Stripe-style) and retried on failure with exponential backoff.
A webhook is a URL Velora POSTs to whenever an audit event matching your filter is written. The payload is a JSON event envelope; the request carries an X-Velora-Signature header you verify with your endpoint's secret. Deliveries retry on any non-2xx response. After ten consecutive failures we mark the endpoint inactive; you re-create it after fixing.
Register an endpoint
POST /api/v2/public/audit/webhooks
Content-Type: application/json
Authorization: Bearer vlk_live_...
{
"url": "https://your-app.example.com/velora-webhook",
"event_filter": ["phi.", "admin."],
"description": "Production SIEM"
}Required scope: webhooks:write. The response includes the endpoint's id and a one-time secret:
{
"endpoint": {
"id": "wh_8b2e5c11",
"url": "https://your-app.example.com/velora-webhook",
"event_filter": ["phi.", "admin."],
"description": "Production SIEM",
"active": true,
"created_at": "2026-05-08T14:22:01+00:00"
},
"secret": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
}secret is shown exactly once. Capture it now — we cannot recover it later. If you lose the secret, delete the endpoint and re-register.Event filter
Each entry in event_filter is a prefix. An empty array subscribes to every event your tenant emits.
| Filter | Matches |
|---|---|
| [] | All events. |
| ["phi."] | phi.read, phi.query, phi.export, phi.decrypt. |
| ["phi.read"] | phi.read only. |
| ["phi.", "admin."] | All PHI + admin events. |
Delivery payload
Every delivery POSTs a JSON envelope with the event data plus a monotonically-versioned schema marker. Tolerate unknown fields — we may add fields without bumping schema_version. Removal or rename is a breaking change and bumps the version.
{
"type": "phi.read",
"id": "8b2e5c11-1234-4abc-89de-0123456789ab",
"timestamp": "2026-05-03T14:22:01+00:00",
"tenant_id": "acme",
"actor": { "user_id": "u-1234", "user_role": "data_analyst" },
"resource": { "type": "claim", "id": "C-7890" },
"phi_involved": true,
"success": true,
"details": { "workflow": "renewal" },
"schema_version": "1"
}Verify the signature
Every delivery includes an X-Velora-Signature header. The format is:
X-Velora-Signature: t=1717423320,v1=8e4f...c1a9To verify a delivery:
- Parse
t(unix timestamp, seconds) andv1(hex HMAC) from the header. - Reject if
|now - t| > 300seconds — protects against replay attacks. - Compute
expected = HMAC_SHA256(secret, "t=" + t + "." + raw_body)against the raw bytes of the request body (do not re-serialize the JSON). - Constant-time compare
expectedagainstv1. Reject on mismatch.
express.raw(); in FastAPI, that's await request.body().Reference verifiers
import hashlib
import hmac
import time
def verify_webhook(secret: str, body: bytes, signature_header: str) -> bool:
"""Verify a Velora webhook delivery.
Args:
secret: the endpoint secret returned at registration time.
body: the raw request body bytes (do NOT re-serialize the JSON).
signature_header: the value of the X-Velora-Signature header.
Returns:
True on a valid signature within the 5-minute replay window.
"""
parts = dict(p.split("=", 1) for p in signature_header.split(","))
t = int(parts["t"])
if abs(int(time.time()) - t) > 300:
return False
expected = hmac.new(
secret.encode(),
f"t={t}.".encode() + body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, parts["v1"])Retries
Velora retries non-2xx responses with exponential backoff and jitter. A delivery is considered terminal after the budget is exhausted.
- Budget: 5 attempts.
- Schedule: 1m → 5m → 30m → 2h → 12h, each shifted by
±20%jitter. - Considered success: any 2xx response within 10s.
- Considered failure: non-2xx, connection error, TLS error, or timeout.
Auto-disable
An endpoint is auto-disabled after 10 consecutive failed deliveries (across all events, not per-event). Disabled endpoints stop receiving deliveries; the row stays in your list so you can inspect failure history. To resume, register a fresh endpoint with the same URL.
id. Your handler should be idempotent on (id, type) so a re-delivered event doesn't double-count in your SIEM. Storing the last 5,000 ids in a LRUCache is usually enough.List, disable, delete
GET /api/v2/public/audit/webhooks
POST /api/v2/public/audit/webhooks/{id}/disable
DELETE /api/v2/public/audit/webhooks/{id}DELETE cascades to delivery history. POST /disable preserves history but stops dispatching new deliveries — the right call for short-term maintenance windows where you want the audit trail intact.
List endpoints
curl -H "Authorization: Bearer $VELORA_API_KEY" \
https://api.velora.health/api/v2/public/audit/webhooks{
"endpoints": [
{
"id": "wh_8b2e5c11",
"url": "https://your-app.example.com/velora-webhook",
"event_filter": ["phi.", "admin."],
"description": "Production SIEM",
"active": true,
"consecutive_failures": 0,
"last_delivery_at": "2026-05-08T14:22:01+00:00",
"created_at": "2026-05-08T14:22:01+00:00"
}
]
}Disable
curl -X POST -H "Authorization: Bearer $VELORA_API_KEY" \
https://api.velora.health/api/v2/public/audit/webhooks/wh_8b2e5c11/disableDelete
curl -X DELETE -H "Authorization: Bearer $VELORA_API_KEY" \
https://api.velora.health/api/v2/public/audit/webhooks/wh_8b2e5c11Operational checklist
Before pointing a production webhook at Velora:
- Capture the secret in a real secrets manager. Slack DMs and issue trackers are not real secrets managers.
- Wire signature verification before any business logic. Reject unsigned or stale-timestamp deliveries.
- Make your handler idempotent on
(id, type). - Acknowledge with a 2xx in under 10s. Defer slow work to a background queue — long-running handlers will time out and trigger retries.
- Alert on
consecutive_failures > 3from a poll ofGET /webhooks— that gives you a window to fix before auto-disable trips.
Next
For runnable end-to-end webhook scripts (register, capture a delivery, verify, re-derive expected signature), see Code samples.