Webhooks
Webhooks push events to your server the moment they happen — a contact gets verified, a campaign reply arrives, a visitor is identified, your credit balance runs low — so you don't have to poll. You register an HTTPS endpoint, subscribe it to event types, and AI92 POSTs a signed JSON payload to it on each matching event.
Setting up an endpoint
Register an endpoint and the event types it should receive:
curl -X POST https://api.ai92.ai/v1/webhooks/endpoints \
-H "Authorization: Bearer ai92_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/webhooks/ai92",
"events": ["contact.verified", "campaign.reply", "credits.low"]
}'The response returns a signing secret — shown once. Store it securely; you'll use it to verify every delivery.
Event catalog
| Event | Fires when |
|---|---|
contact.verified | An email verification resolves to a definitive verdict. |
campaign.sent | A campaign message is dispatched. |
campaign.reply | A recipient replies to a campaign. |
campaign.open | A recipient opens a message. |
campaign.click | A recipient clicks a tracked link. |
campaign.bounce | A message bounces. |
visitor.identified | An anonymous visitor is identified (intent). |
review.new | A new review lands on a connected profile. |
seo.alert | An SEO alert triggers (ranking change, issue detected). |
credits.low | Your balance crosses the low-balance threshold. |
key.expiring | An API key is approaching expiry. |
account.inactivity_warning | The account is nearing the inactivity threshold. |
account.read_only | The account has been moved to read-only mode. |
account.reactivated | A read-only account is reactivated. |
sla.violation | An SLA threshold is breached. |
Only registered event types are accepted. Subscribing to an event name that is not in the catalog above returns
422 UNKNOWN_EVENT_TYPE, and the endpoint is not created — one bad name fails the whole request. In particular, async job and scan completions are not delivered via webhook: there is nojob.completedorscan.completedevent. Poll those results instead —GET /v1/jobs/{job_id}forf_*/strat-int-*jobs,GET /v1/seo/audit/{scan_id}andGET /v1/contacts/bulk/{scan_id}for the UUID-scan_idproducers (see Async Jobs).
The payload
Every delivery is a JSON body in this shape:
{
"id": "evt_8f2c1e9a-0b44-4c0d-9b2f-1a6d3e8f0011",
"type": "contact.verified",
"created": "2026-06-21T10:42:00Z",
"account_id": "9b1c...",
"trace_id": "7f3c...",
"data": {
"email": "[email protected]",
"status": "valid"
}
}And these headers:
| Header | Meaning |
|---|---|
X-AI92-Signature | HMAC signature: t=<timestamp>,v1=<hex> |
X-AI92-Event-Type | The event type, e.g. contact.verified |
Content-Type | application/json |
User-Agent | mod923-webhook/<version> |
Verifying the signature
Always verify. Without it, anyone who learns your URL could forge events. AI92 signs each delivery with HMAC-SHA256 over "{timestamp}.{raw_body}" using your endpoint's signing secret.
The X-AI92-Signature header looks like:
t=1718966520,v1=5257a869e7 ... 9f2cTo verify, step by step:
- Read the raw body exactly as received — do not re-serialize it. (AI92 signs a canonical, sorted-key JSON; your framework's re-encode would change the bytes and break verification. Verify against the raw bytes on the wire.)
- Parse
X-AI92-Signatureinto itst(timestamp) andv1(signature) parts. - Recompute
HMAC_SHA256(secret, f"{t}.{raw_body}")as hex. - Compare the recomputed value with
v1using a constant-time comparison. - Reject stale timestamps (e.g.
tolder than 5 minutes) to block replay attacks.
import hashlib, hmac, time
def verify_ai92_webhook(raw_body: bytes, signature_header: str, secret: str, tolerance=300) -> bool:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
ts, sig = parts.get("t"), parts.get("v1")
if not ts or not sig:
return False
if abs(time.time() - int(ts)) > tolerance: # replay protection
return False
signed = f"{ts}.{raw_body.decode('utf-8')}".encode()
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig) # constant-timeimport crypto from "node:crypto";
function verifyAi92Webhook(rawBody, signatureHeader, secret, tolerance = 300) {
const parts = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=")));
const { t, v1 } = parts;
if (!t || !v1) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > tolerance) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}# Flask handler — note request.get_data() for the RAW body
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/webhooks/ai92")
def handle():
raw = request.get_data() # raw bytes, not request.json
if not verify_ai92_webhook(raw, request.headers.get("X-AI92-Signature", ""), SECRET):
abort(400)
event = request.get_json()
# enqueue and return fast — see below
enqueue(event)
return "", 200Retries and the DLQ
If your endpoint doesn't return a 2xx, AI92 retries with a back-off schedule:
- Up to 5 attempts.
- Retry intervals (seconds after each failure): 120 → 600 → 3,600 → 14,400 → 64,800 (2 min, 10 min, 1 h, 4 h, 18 h).
- Each delivery attempt has a 10-second timeout.
- After the final failed attempt, the delivery moves to a dead-letter queue (DLQ), retained for 30 days, where you can inspect and replay it.
A 2xx at any attempt marks the delivery complete and stops retries.
Best practices
- Return
2xxfast. Acknowledge within the 10-second timeout, then do the real work asynchronously. Verify the signature, enqueue the event, and return200immediately — don't run slow logic inline. - Make your consumer idempotent. Retries (and rare duplicate deliveries) mean you may see the same
idmore than once. Dedupe onevent.idand process each exactly once. - Verify every payload with the HMAC check above, and reject stale timestamps.
- Subscribe narrowly. Only request the event types you handle.
- Watch the DLQ. A delivery in the DLQ means your endpoint was down or rejecting — replay it once you've fixed the cause.