Documentation

Verifying webhooks

Client Posture sends signed HTTPS POSTs when a scan finishes, a grade changes, a Security Brief is viewed, a proposal is sent or accepted, a prospect is created or changes stage, or a client is created. Payloads are identifiers and metrics only — no contact PII, secrets, or full reports. Test clicks include test: true and are not listed as recent events.

Webhooks, HubSpot, HaloPSA, REST API. Included with Pro, Founding Partner, and the 7-day Pro trial.

Headers

  • Client-Posture-Signature: t=<unix>,v1=<hex> (a second v1= appears during secret rotation)
  • Client-Posture-Event-Id: use this for idempotency
  • Client-Posture-Event: one of scan.completed, grade.changed, brief.viewed, proposal.sent, proposal.accepted, prospect.stage_changed, prospect.created, client.created

Signature

HMAC-SHA256 over ${timestamp}.${rawBody}, the same construction Stripe uses. Reject deliveries older or newer than 5 minutes to stop replay attacks. The signing secret is shown once when you create or rotate an endpoint.

Check outcomes

scan.completed payloads may include an optional data.checks array. Each item is { id, status, certainty } — identifiers and outcomes only, no evidence. Older receivers can ignore the field.

  • verified — observed in public data (pass, warn, or fail). Grade is computed from these only.
  • unknown — could not confirm from outside (for example DKIM when the selector is not listed, a DNS timeout, or an incomplete TLS handshake). Do not treat as a problem.
  • not_tested — outside this scan's passive scope.

Scans that are not graded

When too few checks can be confirmed, a scan is not graded. Its scan.completed payload sets grade_withheld: true, sends grade and score as null, and includes verified_checks and total_checks. Graded scans send grade_withheld: false. If your receiver expects grade to always be a string, handle null first.

grade.changed never fires to or from a scan that was not graded. It compares the new grade with the last graded scan.

Grade changed and new prospects

grade.changed includes previous_grade, grade, and direction (improved or declined). Filter to declined before opening a ticket so improvements do not create work.

prospect.created fires when a new prospect is added. It includes the prospect id and domain only.

Send test event deliveries go only to the endpoint you clicked. They set test: true and are excluded from GET /api/v1/events/{type}.

Node.js

import crypto from "node:crypto";

const TOLERANCE_MS = 5 * 60 * 1000;

export function verifyWebhook(opts) {
  const header = opts.header || "";
  const parts = Object.fromEntries(
    header.split(",").map((part) => {
      const [k, ...rest] = part.trim().split("=");
      return [k, rest.join("=")];
    })
  );
  const timestamp = Number(parts.t);
  if (!Number.isFinite(timestamp)) throw new Error("missing timestamp");
  if (Math.abs(Date.now() - timestamp * 1000) > TOLERANCE_MS) {
    throw new Error("replay window exceeded");
  }
  const expected = crypto
    .createHmac("sha256", opts.secret)
    .update(`${timestamp}.${opts.body}`)
    .digest("hex");
  const signatures = header
    .split(",")
    .filter((part) => part.trim().startsWith("v1="))
    .map((part) => part.trim().slice(3));
  const ok = signatures.some((sig) => {
    const a = Buffer.from(sig, "hex");
    const b = Buffer.from(expected, "hex");
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
  if (!ok) throw new Error("invalid signature");
  return JSON.parse(opts.body);
}

Python

import hashlib, hmac, json, time

TOLERANCE_SECONDS = 5 * 60

def verify_webhook(secret: str, header: str, body: str):
    parts = dict(item.split("=", 1) for item in header.split(",") if "=" in item)
    timestamp = int(parts["t"])
    if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
        raise ValueError("replay window exceeded")
    expected = hmac.new(
        secret.encode(), f"{timestamp}.{body}".encode(), hashlib.sha256
    ).hexdigest()
    signatures = [
        item.split("=", 1)[1]
        for item in header.split(",")
        if item.strip().startswith("v1=")
    ]
    if not any(hmac.compare_digest(sig, expected) for sig in signatures):
        raise ValueError("invalid signature")
    return json.loads(body)
Verifying webhooks · Client Posture