Documentation
Webhooks
Webhooks deliver watchlist alerts (rating changes, grade transitions, new ratings) to a URL you control. Pro tier and above. HMAC-SHA256 signed; retried with exponential backoff on non-2xx responses.
Configuring a webhook
Webhooks are configured per-watchlist on /dashboard/watchlist. Each watchlist can have one webhook destination URL plus a signing key that Verdict generates for you.
Coming soon
Payload format
POST request, JSON body, content-type application/json:
{
"event": "rating.changed",
"timestamp": "2026-04-28T14:23:11.000Z",
"delivery_id": "del_abc123",
"watchlist_id": "wl_xyz789",
"data": {
"entity_type": "protocol",
"entity_slug": "aave",
"previous": { "composite_score": 88.5, "letter_grade": "AA" },
"current": { "composite_score": 84.2, "letter_grade": "A" },
"domain_changes": [
{ "domain": "security", "previous": 0.92, "current": 0.85 }
]
}
}Signature verification
Every webhook includes an X-Verdict-Signature header containing an HMAC-SHA256 hex digest of the raw request body using your watchlist's signing key. Always verify before trusting the payload, and use a constant-time comparison so you don't leak signature bytes via timing.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody: Buffer, signatureHeader: string, signingKey: string) {
const expected = createHmac("sha256", signingKey).update(rawBody).digest();
const provided = Buffer.from(signatureHeader, "hex");
return (
provided.length === expected.length &&
timingSafeEqual(provided, expected)
);
}import hmac
import hashlib
def verify(raw_body: bytes, signature_header: str, signing_key: str) -> bool:
expected = hmac.new(
signing_key.encode(),
raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature_header)Always verify against the raw request body — re-serializing the parsed JSON produces a different byte string and the signature will fail.
Retry behaviour
On non-2xx response (or no response within 10 seconds), Verdict retries with exponential backoff:
- Retry 1: 30 seconds after the initial attempt
- Retry 2: 5 minutes
- Retry 3: 30 minutes
- Retry 4: 2 hours
- Retry 5: 12 hours (final attempt)
After 5 failed attempts the delivery is marked failed and surfaced on /dashboard/watchlist. Failed deliveries don't replay automatically — you can manually re-fire from the dashboard or via API.
Idempotency
Each delivery has a unique delivery_id. Your endpoint must be idempotent — the same delivery may arrive more than once if a 2xx response was lost in transit. Store the latest delivery_id per watchlist and ignore duplicates.
Testing locally
Use a tunnelling service (ngrok, Cloudflare Tunnel) to expose localhost for development. Verdict fires a test event when you save a webhook URL — the test event has event: "webhook.test" and a synthetic payload, so handle it the same way as a real delivery.