Webhooks
Get an HTTP POST the moment a recommendation is approved, an anomaly is acknowledged, or a fix completes — instead of polling the public API.
Webhooks push events to your own endpoint as they happen, so your system can react to a recommendation being approved, an anomaly being acknowledged, or a fix completing, failing, or rolling back — without polling /public/v1.
Connecting a webhook
- Go to Settings → API Access (organization admins only) and find the Event webhooks card.
- Click Create webhook, enter your endpoint's HTTPS URL, and choose which events to subscribe to.
- CostLens generates a signing secret and shows it to you once, at creation — save it now. It's used to verify that a delivery genuinely came from CostLens.
Your endpoint, your responsibility
CostLens validates that the URL resolves to a public address (no localhost, no private/internal IP ranges) but otherwise delivers to whatever endpoint you configure. Make sure it's reachable, fast (a slow endpoint delays retries for everyone sharing the delivery queue), and — critically — that you verify the signature below before trusting a payload.
Event catalog
| Event | Fires when |
|---|---|
recommendation_approved | A recommendation is approved (via the app, the public API, or Auto-Fix) |
recommendation_dismissed | A recommendation is dismissed |
anomaly_acknowledged | An anomaly is acknowledged |
fix_applied | A fix completes successfully |
fix_failed | A fix fails to apply |
fix_rolled_back | A completed fix is rolled back |
Payload shape
Every event delivers the same envelope, regardless of type:
{
"event_type": "recommendation_approved",
"sent_at": "2026-09-24T10:15:00Z",
"title": "Recommendation approved",
"summary": "ec2_rightsizing on i-0abc123 was approved",
"severity": "info",
"facts": {
"Check": "ec2_rightsizing",
"Resource": "i-0abc123",
"Estimated savings": "612.40"
}
}This is a neutral summary, not a raw dump of the underlying resource — the same shape CostLens's Slack/Teams integrations render from. If you need the full resource, follow up with a GET against the relevant /public/v1 endpoint using the ids in facts.
Verifying a delivery
Every request carries an X-CostLens-Signature header shaped like Stripe's well-documented scheme — a timestamp bound into the signed material, not just alongside it, so a captured request can't be replayed indefinitely:
X-CostLens-Signature: t=1735689600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Verify it by recomputing the HMAC-SHA256 over "{t}.{raw_request_body}" using your signing secret, and comparing in constant time:
import crypto from "node:crypto";
function verify(secret: string, rawBody: string, header: string, toleranceSeconds = 300): boolean {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=") as [string, string]));
const timestamp = Number(parts.t);
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ""));
}import hashlib
import hmac
import time
def verify(secret: str, raw_body: str, header: str, tolerance_seconds: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
timestamp = int(parts.get("t", 0))
if not timestamp or abs(time.time() - timestamp) > tolerance_seconds:
return False
expected = hmac.new(
secret.encode(), f"{timestamp}.{raw_body}".encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))Always compute the signature over the raw request body — before any JSON parsing — since re-serializing can change field order or whitespace and produce a different byte string than what was actually signed.
Retries and dead-lettering
A delivery that doesn't get a 2xx response is retried with backoff — 1 minute, 5 minutes, 30 minutes, 2 hours, then 12 hours — for up to 6 total attempts before it's marked dead-lettered. You can view every delivery attempt (status, HTTP code, timestamp) on the webhook's card in Settings → API Access, and manually replay any failed or dead-lettered delivery once your endpoint is back up — it resends the exact original signed payload, not a freshly-built one.
Rotating your signing secret
Click Rotate secret on the webhook's card to generate a new one — shown once, just like at creation. It takes effect immediately for all future deliveries; there's no overlap window, since a signing secret only ever verifies inbound deliveries; nothing is "mid-flight" the way an API key's holder might be.
Managing a webhook
From the webhook's card you can toggle it on/off, edit which events it's subscribed to, rotate its secret, or delete it entirely — deleting stops all future deliveries immediately and can't be undone.