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 is committed to durable storage as part of the request, and the delivery itself is enqueued and dispatched in the background. A dead destination cannot stall the gateway's hot path.
Event types
| Event | Fires when |
|---|---|
| api_key_revealed | The vault reveal endpoint returns plaintext |
| compression_threshold_breached | The optimizer falls below its configured compression floor |
Those two fire in production today. The subscription surface also accepts
request_completed, request_failed, quota_exceeded, key_revoked,
focus_mode_changed, and alias_overridden, which are reserved for
sources that are not wired to the deliverer yet; a destination
subscribed only to those will sit idle.
Every delivery carries the same envelope: the event type, the org id,
the target type and target id the event is about, and a per-event
metadata object. The delivery id travels in the X-IQ-Delivery-Id
header rather than in the body, and it identifies one delivery attempt
rather than one event, which matters for how you deduplicate; the
Deduplication section below covers it.
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. 32 random bytes rendered as a 64-character hex string (the dashboard generates one for you). The deliverer signs every payload with this secret and HMAC-SHA256; the receiver verifies the signature. Sign with the hex string exactly as issued, encoded as ASCII, rather than with the 32 bytes it decodes to.
- Events. A non-empty subset of the event-type list above. An empty subset is rejected at create time, so name every event you want; a destination never receives an event it did not subscribe to.
The dashboard surface lives at /settings#webhooks.
Wire format
A delivery is a single HTTPS POST with a JSON body and five X-IQ-
headers. X-IQ-Timestamp is Unix epoch seconds, not an ISO-8601 string:
POST /your/endpoint HTTP/1.1
Content-Type: application/json
X-IQ-Event-Type: api_key_revealed
X-IQ-Delivery-Id: 8f14e45f-ceea-467a-9c33-1a2b3c4d5e6f
X-IQ-Attempt: 1
X-IQ-Timestamp: 1778437451
X-IQ-Signature: sha256=4c91...e2a3
{
"event_type": "api_key_revealed",
"org_id": "org_2bX3...",
"target_type": "api_key",
"target_id": "key_5fK1...",
"metadata": {
"prefix": "gw_live_9f2a1c4b",
"revealed_by": "user_a1b2...",
"encryption_version": 1,
"step_up": "webauthn"
}
}
The actor's email stays on the audit row; it is not part of the delivery body.
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
import time
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
try:
skew = abs(time.time() - int(timestamp))
except ValueError:
return False
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 the delivery success. |
| 5xx | Reschedule: the attempt lands failed and a fresh pending row queues at the backoff. Backoff: 1s, 5s, 25s, then 125s for each remaining attempt. |
| Network error / TLS failure / timeout | Reschedule on the same backoff schedule. |
| Any 3xx or 4xx, 408 and 429 included | Mark failed with no follow-up attempt. The receiver's misconfiguration cannot self-resolve. |
| Attempt 5 still failing on a 5xx, a timeout, or a network error | Mark dead_lettered. |
A delivery in failed or dead_lettered shows up in the deliveries
modal with a "Retry" button. Retry enqueues a fresh attempt-1 delivery
carrying the same payload and leaves the original row where it is, so
the log shows both the failure and the manual replay; the destination
has to be active for the retry to be accepted. Use it after fixing the
receiver. The deliverer never replays a failed or dead-lettered row on
its own; the flap protection is intentional.
Payload size
The envelope carries metadata rather than prompt or response content, so
payloads stay small: the largest event today, api_key_revealed, is a
few hundred bytes. Nothing is truncated on the way out and there is no
truncation flag on the wire, so a receiver does not need to branch on
one.
On the reply side, the deliverer keeps the first 4 KiB of your response body in the delivery log so a pathological receiver cannot blow up the deliveries view. That excerpt exists for your own debugging; it has no bearing on the delivery's status.
Test fire
Settings → Webhooks → row → "Test fire". The deliverer sends a synthetic
test_fire event to that one destination and writes the result to the
delivery log immediately. The test fire reaches only the destination you
fired it from, it does not depend on that destination's event subset, and
the destination has to be active rather than paused.
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 delivery id.
- The destination response code (or "network error").
- The attempt number and the time of that attempt.
- A "Retry" button on failed and dead-lettered rows.
Each attempt is its own row, so a retried event appears once per attempt rather than as a single row with a rising counter.
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.
Limits
There is no per-destination or per-org cap on delivery volume today, so a burst of events produces a burst of deliveries. What bounds the dispatch rate is the deliverer itself: it leases the deliveries that are due and dispatches them through a bounded worker pool that IQ Routing manages. There is no knob for it on your side.
An org can hold up to 25 destinations. A create past that returns 409.
Deduplication
The deliverer's at-least-once contract means a receiver that 5xx'd and then recovered may see the same event more than once. Deliveries are leased before dispatch, which keeps the duplicate window narrow, 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.
Read X-IQ-Delivery-Id as an attempt identifier rather than an event
identifier. Each attempt is a separate delivery row, so a retry arrives
with a new id and the same body; X-IQ-Attempt tells you which attempt
you are looking at. There is no stable per-event id on the wire today.
That shapes how you dedup. Derive your own key from the fields that
identify the occurrence, meaning the event type plus the target id plus
whichever metadata field pins the specific event, and keep a
short-window record of it in whatever cache or key-value store your
receiver already runs, written with a set-if-absent operation and a TTL
of roughly 30 minutes. Thirty minutes comfortably covers the retry
schedule above. Reject a delivery whose key you have already handled.
Rollback
The deliverer can be disabled gateway-wide. When it is off, dispatch stops, 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.