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 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_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.