Webhooks

CRELYTIC iQ Intel delivers Credit Drift Alerts to your endpoint as signed JSON POSTs. Every webhook carries an X-Intel-Signature header so you can verify the payload came from us — reject any request that doesn't verify.

Event format

POST body is JSON. Current event type: drift_alert.

{
  "event": "drift_alert",
  "alert": {
    "id": 12345,
    "company_name": "ExampleCorp Inc.",
    "normalized_name": "examplecorp",
    "score_before": 78,
    "score_after": 63,
    "delta": -15,
    "threshold_pts": 10,
    "window_days": 90,
    "window_start": "2026-01-20",
    "window_end": "2026-04-20",
    "detected_at": "2026-04-20T14:22:00Z"
  }
}

Signature header

Every POST includes an X-Intel-Signature header in Stripe-compatible format:

X-Intel-Signature: t=1713714720,v1=a8b1c…
  • t= — Unix timestamp (seconds since epoch) when we signed
  • v1= — HMAC-SHA256 hex digest over {t}.{raw_body}

Verification — Node.js

import crypto from 'node:crypto'

function verifyIntelWebhook(
  rawBody,           // the exact bytes we POSTed — NOT parsed JSON
  headerSig,         // req.headers['x-intel-signature']
  secret             // shared secret from your Drift dashboard
) {
  const parts = Object.fromEntries(
    headerSig.split(',').map(p => p.split('='))
  )
  const t = parts.t
  const v1 = parts.v1
  if (!t || !v1) return false

  // Reject if timestamp is older than 5 minutes (replay protection)
  const ageSec = Math.floor(Date.now() / 1000) - Number(t)
  if (Math.abs(ageSec) > 300) return false

  const signed = `${t}.${rawBody}`
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signed)
    .digest('hex')

  // Constant-time comparison
  return crypto.timingSafeEqual(
    Buffer.from(v1, 'hex'),
    Buffer.from(expected, 'hex')
  )
}

Verification — Python

import hmac
import hashlib
import time

def verify_intel_webhook(raw_body: bytes, header_sig: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header_sig.split(","))
    t = parts.get("t")
    v1 = parts.get("v1")
    if not t or not v1:
        return False

    # Replay protection — reject if more than 5 minutes old
    if abs(int(time.time()) - int(t)) > 300:
        return False

    signed = f"{t}.{raw_body.decode('utf-8')}".encode()
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(v1, expected)

Important verification rules

  • Sign the raw body bytes, not the re-serialized JSON. Your framework may re-order keys when it parses and re-stringifies — that breaks the signature.
  • Reject requests older than 5 minutes. This prevents replay attacks if the original TLS session was intercepted.
  • Use constant-time comparison(timingSafeEqual / hmac.compare_digest). A naive === leaks signature bytes through timing.
  • Keep the secret out of logs and error pages. It's a shared secret — treat it like your DB password.

Retries + delivery guarantees

If your endpoint returns anything other than 2xx, we retry up to 5 times with exponential backoff. After 5 failures we stop retrying and mark the alert as undeliverable — the row stays in your Alert history for manual review.

Idempotency: alerts carry a uniquealert.id. Treat it as the dedup key. If a retry arrives after your endpoint already processed it, return 200 without side effects.

Your webhook secret

The signing secret is per-environment, not per-watchlist-entry. View and rotate it from your Drift Alerts dashboard (coming soon — contact support in the interim).