Skip to content
IQ Routing

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_revealed event source surface.
  • /docs/optimizer -- the compression_threshold_breached event source surface.
  • /audit -- the audit log shows the same events the webhook destinations receive, plus the deliveries-attempted column for cross-reference.