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.exampleexample.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.