Webhooks
The webhook deliverer streams audit events from the gateway to a destination URL of your choice. It is the integration surface for SIEM tooling (Datadog, Splunk, Sumo), incident pipelines (PagerDuty, Opsgenie), chat (Slack, Microsoft Teams via webhook bridges), and any internal service that wants to react to gateway state changes in real time.
The deliverer never blocks the originating request. The audit row writes synchronously to Postgres; the deliverer enqueue runs through a background-task pattern. A dead destination cannot stall the gateway's hot path.
Event types
| Event | Fires when | Payload contains |
|---|---|---|
| api_key_revealed | Vault reveal endpoint returns plaintext | actor email, key id, key name, step-up method |
| api_key_rotated | A key is rotated to new plaintext | actor email, old key id, new key id |
| api_key_revoked | A key is marked revoked | actor email, key id, reason |
| compression_threshold_breached | Optimizer floor breach (token_optimizer_floor_breach) | request id, mode, observed compression ratio, fail-open reason |
| pat_issued | A personal access token is issued | actor email, token id, scopes |
| pat_revoked | A personal access token is revoked | actor email, token id, reason |
| provider_credential_added | A new upstream provider key lands | actor email, provider, alias |
| provider_credential_rotated | A provider key rotates | actor email, provider, alias, old key id |
Each event carries the org id, the trigger timestamp, and a deterministic event id (UUIDv7) for idempotency on the receiver side.
Setting up a destination
Settings → Webhooks → "Add destination". Required fields:
- URL. HTTPS only; the deliverer rejects HTTP destinations at create
time. The URL must resolve at create time to a non-private IP (no
127.0.0.1,10.*,192.168.*,169.254.*); the deliverer re-resolves at delivery time and rejects on a private-IP DNS rebind. - Secret. A 32-byte random string (the dashboard generates one for you). The deliverer signs every payload with this secret and HMAC- SHA256; the receiver verifies the signature.
- Events. Subset of the event-type list above. An empty subset means the destination receives every event; explicit subsets are recommended.
The dashboard surface lives at /settings#webhooks.
Wire format
A delivery is a single HTTPS POST with a JSON body and four headers:
POST /your/endpoint HTTP/1.1
Content-Type: application/json
X-IQ-Event: api_key_revealed
X-IQ-Timestamp: 2026-05-06T18:24:11Z
X-IQ-Signature: sha256=4c91...e2a3
X-IQ-Delivery: 01H8Z4P5Q6R7S8T9V0W1X2Y3Z4
{
"event": "api_key_revealed",
"id": "01H8Z4P5Q6R7S8T9V0W1X2Y3Z4",
"occurred_at": "2026-05-06T18:24:11.482Z",
"org_id": "org_2bX3...",
"data": {
"actor_email": "alice@example.com",
"key_id": "key_5fK1...",
"key_name": "Production Sonnet",
"step_up_method": "webauthn"
}
}
The body is JSON-canonicalised before signing (sorted keys, no extraneous whitespace) so a receiver that re-canonicalises and re-signs gets the same bytes.
Verifying the signature
The signature uses the timestamped construction:
HMAC-SHA256(secret, "{X-IQ-Timestamp}.{request_body}")
A receiver that does not check the timestamp is vulnerable to replay. Reject any delivery whose timestamp is more than five minutes off your clock.
A reference Python verifier:
import hmac
import hashlib
from datetime import datetime, timezone
def verify(secret: bytes, body: bytes, headers: dict[str, str]) -> bool:
timestamp = headers.get("X-IQ-Timestamp", "")
signature = headers.get("X-IQ-Signature", "")
if not signature.startswith("sha256="):
return False
sent = signature[len("sha256="):]
signed_payload = f"{timestamp}.".encode() + body
expected = hmac.new(secret, signed_payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sent, expected):
return False
skew = abs(
(datetime.now(timezone.utc) - datetime.fromisoformat(timestamp.replace("Z", "+00:00")))
.total_seconds()
)
return skew <= 300
Use hmac.compare_digest (or your stack's equivalent constant-time
compare) so a timing side-channel cannot leak the signature byte by
byte.
Retry semantics
| Receiver response | Deliverer action |
|---|---|
| 2xx | Mark delivery delivered. |
| 408, 429, 5xx | Mark pending. Exponential backoff: 30s, 2m, 8m, 32m, 2h. |
| Any other 4xx | Mark dead_letter. The receiver's misconfiguration cannot self-resolve. |
| Network error / TLS failure | Mark pending. Same backoff schedule. |
| 5 retries elapsed without success | Mark dead_letter. |
A delivery in dead_letter shows up in the deliveries modal with a
"Retry" button that resets the row to pending. Use it after fixing the
receiver. The deliverer does not retry dead-lettered rows automatically;
the flap protection is intentional.
Payload size cap
Payloads cap at 4 KiB. A larger payload truncates with "truncated": true in the body and a X-IQ-Truncated: true header so the receiver
can react. Truncation only ever drops fields; it never reorders or
re-encodes the surviving fields.
The cap is generous for the current event types (the largest payload
today is api_key_revealed at ~280 bytes). Future events that need
more headroom will lift the cap; the cap is not a soft limit on the
event-type surface.
Test fire
Settings → Webhooks → row → "Test fire". The deliverer sends a synthetic
test_fire event to the destination and writes the result to the
delivery log immediately. The test fire counts against the destination's
rate budget but does not affect downstream consumers (the
test_fire event type is filtered out of pat_issued /
api_key_revealed / etc receivers by event-type subset).
Use it after any receiver-side change to confirm the signing and verification path still works end-to-end.
Delivery log
Settings → Webhooks → "View deliveries" opens a modal with the last 100 deliveries for that destination. Each row carries:
- The event type and the truncated event id.
- The destination response code (or "network error").
- The attempt count and the time of the last attempt.
- A "Retry" button on dead-lettered rows.
The full delivery history is retained on the gateway side. The modal's last-100 cap is a UI choice; the data is not pruned.
Rate limits
- Per-destination. 60 deliveries per minute. Bursts above the rate cap queue with the same exponential backoff as a 5xx; the deliverer drains the queue when the rate window opens.
- Per-org. 600 deliveries per minute across all destinations. The per-org cap protects the deliverer pool from a runaway audit-event source (a misbehaving SDK, a CI run that issues 10k keys).
A delivery that hits the rate cap surfaces in the deliveries modal as
pending with the next-attempt timestamp.
Deduplication
The deliverer's at-least-once contract means a 5xx-then-recovered receiver may see the same event id more than once. The worker-pool plus the lease-based dequeue path tighten the single-process duplicate window, but the contract stays at-least-once because a network partition between the gateway's commit and the receiver's response is indistinguishable from a 5xx.
Use X-IQ-Delivery-Id as the canonical idempotency key. Receivers
should keep a short-window dedup table (Redis SETNX with a TTL of
roughly 30 minutes is enough; the worker's max retry window is
~2.5 minutes) and drop a delivery whose id already lands.
The retry path bumps attempt and writes a new row with the same
event payload, so a receiver that wants to count attempts can read
the X-IQ-Attempt header. The header is metadata only; the dedup
key remains X-IQ-Delivery-Id.
Rollback
The deliverer can be disabled gateway-wide. When it is off, the worker loop short-circuits, no new deliveries enqueue, and the existing delivery history stays where it is. The dashboard's delivery-log modal still renders the historical rows. Re-enabling resumes from where the worker left off.
See also
- /docs/vault -- the
api_key_revealedevent source surface. - /docs/optimizer -- the
compression_threshold_breachedevent source surface. - /audit -- the audit log shows the same events the webhook destinations receive, plus the deliveries-attempted column for cross-reference.