Webhooks
When a new certificate matches one of your watch rules,
CT Watch POSTs a JSON payload to each of your delivery targets. Add and remove
targets from the dashboard’s Targets tab or via the API.
Targets
A target is just a URL. On create, CT Watch validates it against an SSRF guard — internal, loopback, and link-local addresses are rejected, so you can’t point a target at infrastructure behind CT Watch. You may attach a signing secret; it’s write-only (CT Watch never returns it) and is used to sign every delivery.
The request
Each delivery is a single POST with these headers:
| Header | Value |
|---|---|
| Content-Type | application/json |
| X-CT-Event | match |
| X-CT-Delivery | a stable id for this certificate’s delivery (use it for idempotency) |
| X-CT-Signature | sha256=<hex> — present only when the target has a secret |
Deliveries are retried with exponential backoff on a non-2xx response or
transport error, so design your handler to be idempotent on
X-CT-Delivery.
The payload
{
"event": "match",
"matched_at_ms": 1717372800000,
"rules": [
{ "id": 1, "kind": "domain", "pattern": "example.com" }
],
"certificate": {
"log_id": "…",
"leaf_index": 123456,
"entry_type": "precert",
"timestamp_ms": 1717372799000,
"reversed_apex": "com.example",
"sans": ["example.com", "www.example.com"],
"issuer": "…",
"not_before": 1717372000,
"not_after": 1725148000,
"fingerprint": "<hex sha-256 of the leaf>",
"serial": "<hex>"
}
}
rules lists which of your rules matched (a single certificate can trip more
than one). reversed_apex is the apex with its labels reversed — reverse it
back for display (com.example → example.com).
Verifying the signature
The signature is sha256= followed by the hex HMAC-SHA256 of the raw request
body, keyed by the target’s secret. Recompute it over the bytes you received
and compare in constant time:
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, header, secret) {
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(header);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
Always verify against the exact bytes of the body, before any JSON parsing re-serializes them. Reject anything that doesn’t match.