Webhooks
Receive signed HTTPS POSTs when AI coding tools ship releases, breaking changes or security fixes. Events, payloads, HMAC-SHA256 signature verification in Node and Python, retries and endpoint rules.
Webhooks need an API key and run on api.havoptic.com. Havoptic POSTs a signed JSON event to your HTTPS endpoint shortly after a release is detected and enriched.
Events
| Event | Fires when |
|---|---|
release.created | A new release is detected and enriched. data.release carries id, version, abstract, counts and an api link. |
change.breaking | A release has a breaking change item with confidence ≥ 0.85. |
change.security | A release has a security change item with confidence ≥ 0.85. |
change.* events carry data.release plus data.changes, the matching items. release.updated and product.added can be subscribed to already but are not emitted yet. POST /v1/webhooks/{id}/test sends a ping event.
Register an endpoint
curl -X POST https://api.havoptic.com/v1/webhooks \
-H "Authorization: Bearer $HAVOPTIC_API_KEY" -H 'Content-Type: application/json' \
-d '{"url":"https://example.com/hooks/havoptic","events":["change.breaking","change.security"],"filters":{"tools":["claude-code","cursor"]}}'
# → { "id": "…", "url": "…", "events": […], "filters": {…}, "secret": "whsec_…" }
The secret is shown only once; store it. filters.tools and filters.kinds are optional (empty means all).
| Request | Purpose |
|---|---|
GET /v1/webhooks | List your webhooks. |
POST /v1/webhooks | Create one. |
DELETE /v1/webhooks/{id} | Delete one. |
POST /v1/webhooks/{id}/test | Send a test delivery now. |
GET /v1/webhooks/{id}/deliveries | Recent deliveries with status and attempts. |
Developer keys can register 3 webhooks (webhook_limit_reached beyond that); Pro and Team are unlimited.
Payload and headers
POST /hooks/havoptic HTTP/1.1
Content-Type: application/json
Havoptic-Event: change.breaking
Havoptic-Delivery: whd_…
Havoptic-Signature: t=1760870400,v1=5d41402abc4b2a76b9719d911017c592…
{
"id": "whd_…",
"type": "change.breaking",
"created_at": "2026-10-19T10:15:00.000Z",
"data": {
"release": { "id": "claude-code-2.1.302", "tool": "claude-code", "version": "2.1.302", "has_breaking": true, "api": "https://api.havoptic.com/v1/releases/claude-code-2.1.302?expand=changes" },
"changes": [ { "kind": "breaking", "title": "…", "evidence": "…", "confidence": 0.93 } ]
}
}
Verify the signature
v1 is HMAC_SHA256(secret, "<t>.<raw body>") in hex. Verify against the raw request body (before JSON parsing), compare in constant time, and reject timestamps older than 5 minutes to stop replays.
Node.js
// Node 18+
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyHavoptic(rawBody, header, secret, toleranceSec = 300) {
const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=')));
if (!t || !v1 || Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return expected.length === v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
// Express: keep the raw body, verify, then parse.
app.post('/hooks/havoptic', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyHavoptic(req.body.toString('utf8'), req.get('Havoptic-Signature') || '', process.env.HAVOPTIC_WEBHOOK_SECRET);
if (!ok) return res.sendStatus(400);
const event = JSON.parse(req.body);
res.sendStatus(204); // answer fast, process async
handle(event);
});Python
import hashlib, hmac, time
def verify_havoptic(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1 or abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
# Flask
@app.post("/hooks/havoptic")
def havoptic_hook():
if not verify_havoptic(request.get_data(), request.headers.get("Havoptic-Signature", ""), SECRET):
abort(400)
event = request.get_json()
return "", 204Delivery and retries
- Answer with any 2xx within 10 seconds. Do slow work after responding.
- Non-2xx answers and timeouts are retried after 1 m, 5 m, 15 m, 1 h, 3 h, 6 h and 12 h: 8 attempts over about 24 hours, then the delivery is marked
dead. - Redirects are not followed; point the webhook at the final URL.
- Deliveries can arrive more than once or out of order. Deduplicate on
Havoptic-Delivery(the same value as the bodyid, stable across retries).
Endpoint rules (SSRF protection)
- The URL must be
httpson the default port 443, without credentials in the URL. - The host must be public. Private (RFC 1918), loopback, link-local, carrier-grade NAT and cloud metadata addresses are rejected, both as literals and after DNS resolution at delivery time.
- A rejected URL returns 400 invalid_parameter with
param: "url".