---
title: "Webhooks - Trooth"
description: "Receive a signed JSON POST when a record changes. The events, the envelope, and how to check the signature on each of Trooth's two delivery channels."
canonical_url: "https://trooth.co/docs/webhooks"
markdown_url: "https://trooth.co/docs/webhooks.md"
generated_from: "the rendered page, converted to Markdown when this was requested"
agent_index: "https://trooth.co/llms.txt"
---

# Webhooks

A webhook is a message Trooth sends to your server when something changes: a POST request whose body is JSON, a machine-readable format, signed so you can check that Trooth sent it. Endpoint events come from the Trooth application programming interface (API) at api.trooth.co, and alert deliveries come from Trooth's own alert sender, which is why there are two ways to check a signature.

Subscribe to state changes and Trooth will POST a JSON event to your endpoint. Manage endpoints from your [dashboard](https://trooth.co/dashboard/webhooks).

Trooth sends webhooks on two channels, and they share one header name while signing different bytes. Endpoints you register at /dashboard/webhooks are the channel the events, envelope and first recipe below belong to. Alert destinations, which a workspace configures under Alerts, are delivered by Trooth itself and sign a timestamp as well as the body; their recipe is the second one, at the end of this page. Tell a delivery apart by its headers: an x-trooth-timestamp header, or an x-trooth-signature that begins sha256=, is the alert channel. A recipe written for one channel returns false on every genuine delivery from the other.

## Events

| Event | Fires when |
| --- | --- |
| witness.changed | What Trooth has witnessed changed: the counts in the published coverage object, with the previous pair. trust.score.changed is accepted as a legacy name at registration and delivered as witness.changed. |
| control.witnessed | A control in your workspace was witnessed. data carries controlId, in whatever name your own control set uses, and the recorded status. |
| profile.viewed | Someone read your public Trust Profile, by the profile read or the view beacon on api.trooth.co. Repeat reads from one address within ten minutes count once. data carries slug, referrer and at. |
| profile.requested | Accepted when you register an endpoint, and not sent today: the Trooth API has no code that emits it. |
| monitoring.drift | Monitoring recorded a regression or a lost connection on a connected source. One event per workspace per re-witness run; data carries count, changes[] and a url. |

Each event shares the outer envelope of id, type, created and data. The shape inside data varies by type, so switch on type before reading it. created is epoch milliseconds on every event. Each event is attempted once: a failed delivery is recorded in the endpoint's history and is not retried on its own, and a test send from that history is signed the same way real events are.

```
{
  "id": "evt_a1b2c3d4e5f6",
  "type": "witness.changed",
  "created": 1785340800000,
  "data": {
    "coverage": { "passed": 41, "run": 44 },
    "previous": { "passed": 40, "run": 44 },
    "change": { "passed": 1, "run": 0 },
    "source": "capability",
    "changed_at": 1785340800000
  }
}
```

Profile subscriptions are a second system with their own event. A subscription made with POST /public/trust/:slug/agent-subscribe receives trust.posture.changed in a different envelope, is attempted up to three times, and cannot be registered at /dashboard/webhooks. It is documented on [Agents and MCP](https://trooth.co/docs/agents).

The legacy name control.verified is accepted as an alias when registering webhooks.

## Verifying a delivery to an endpoint you registered

This recipe is for the channel above: the endpoints you register at /dashboard/webhooks, delivered by the Trooth worker at api.trooth.co. Every delivery on it carries two headers. x-trooth-signature is the HMAC-SHA256 of the raw request body, keyed with your endpoint's signing secret, as 64 lowercase hex characters with no prefix. x-trooth-event repeats the event type. This channel sends no timestamp header, and the signed bytes are the body and nothing else.

Compute the same HMAC over the exact bytes you received, before any JSON parsing, and compare in constant time. Key order, whitespace and Unicode are all part of the signed bytes: a body your framework parsed and re-serialized will not verify. Deduplicate on the event id; with no timestamp signed on this channel, that is what makes a replayed delivery harmless.

```
import crypto from "node:crypto";

// rawBody: the exact bytes of the request as a Buffer, read BEFORE any JSON
// parsing. A body a framework parsed and re-serialised is not what was signed.
// headers: the request headers with lower-case names.
// secret: the endpoint signing secret shown once at registration.
export function verifyTrooth(rawBody, headers, secret) {
  const signature = String(headers["x-trooth-signature"] || "");
  const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(signature, "utf8");
  // Compare the length first: timingSafeEqual throws on unequal lengths, and
  // a missing or malformed header must be a clean false, not a crash.
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}
```

## Verifying a delivery to an alert destination

A workspace can also send Trooth's signals to a destination it configures under Alerts: a chat app, or a generic HTTPS endpoint with a SIEM, a ticket queue or an automation behind it. Those deliveries never touch the worker. Trooth sends them itself, and signs them the other way. x-trooth-timestamp carries the second the delivery was signed at, as a Unix time. x-trooth-signature is the string sha256= followed by the HMAC-SHA256, in 64 lowercase hex characters, of the timestamp, a literal full stop, and the exact bytes of the body. The full stop is part of the signed material and the prefix is part of the header value, so the recipe above returns false on every one of these deliveries.

Check the timestamp before the HMAC, and refuse a delivery whose timestamp is more than 300 seconds from your own clock in either direction. A delivery that reaches you honestly is seconds old: it is attempted at most three times inside a 15 second budget, and every attempt carries the timestamp that was signed before the first one. The remainder of the window is room for clock skew. Narrow it if your clock is disciplined; widening it is the one change that costs you something, because the width of this window is how long a captured delivery stays usable against you.

Each destination has its own key, and it is not the per-endpoint secret shown once at /dashboard/webhooks. It is derived from Trooth's master key and that destination's own identity: an HMAC-SHA256 over the label trooth-notify-route-v1, the workspace, the destination id and the key generation, each on its own line, given to you as 64 lowercase hex characters. It verifies deliveries to that destination and to no other, and it does not yield the master key it was derived from.

A destination created before September 19, 2026, is at key generation 0 and is still signed with the shared key it has been signed with since it was created; nothing about its deliveries changes until its owner rotates it. A destination created since then has a key of its own from its first delivery. Rotating one destination changes that destination's key and no other, so no other workspace's receiver is affected by it.

Read x-trooth-signature as a list. It carries one value normally, and two comma-separated values, newest first, for the seven days after a rotation, so a receiver that has not swapped its key yet keeps working. Split the header on commas, trim each value, and accept the delivery if any value matches the signature you compute. A receiver that compares the whole header string to one expected value works until its destination's key is rotated and then rejects every genuine delivery for that week.

The second value is the key the rotation replaced, and for a destination rotated off the shared key that is the shared key itself: the one its receiver is already verifying with. So a receiver that has not been given the new key keeps working for seven days, and a destination that has never been rotated sees a list for the first time on the day it is rotated. Those seven days are the time to swap the key, not a week in which nothing has to be done. When the window closes, only the new key is sent.

```
import crypto from "node:crypto";

// rawBody: the exact bytes of the request as a Buffer, read BEFORE any JSON
// parsing. What was signed is what came off the wire, not a re-serialised object.
// headers: the request headers with lower-case names.
// secret: the signing key Trooth issued for this destination. It is NOT the
// per-endpoint secret from /dashboard/webhooks; that one signs the other channel.
export function verifyTroothAlert(rawBody, headers, secret, toleranceSeconds = 300) {
  const timestamp = String(headers["x-trooth-timestamp"] || "");
  const header = String(headers["x-trooth-signature"] || "");
  // A missing or non-numeric timestamp is a delivery that cannot be placed in
  // time, which is the thing this check exists to refuse.
  if (!/^[0-9]+$/.test(timestamp)) return false;
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  // The window is checked first so a replay cannot be rescued by the fact that
  // its signature is genuine. It is genuine; that is what a replay is.
  if (age > toleranceSeconds) return false;
  const signed = Buffer.concat([Buffer.from(`${timestamp}.`, "utf8"), rawBody]);
  const expected = "sha256=" + crypto.createHmac("sha256", secret).update(signed).digest("hex");
  const a = Buffer.from(expected, "utf8");
  // The header carries one value normally, and two comma-separated values,
  // newest first, for seven days after this destination's key is rotated.
  // Split the header on commas, trim each value, and accept the delivery if any
  // value matches the signature you compute.
  let matched = false;
  for (const value of header.split(",")) {
    const b = Buffer.from(value.trim(), "utf8");
    // Compare the length first: timingSafeEqual throws on unequal lengths, and a
    // missing or malformed value must be a clean false, not a crash.
    if (a.length !== b.length) continue;
    // Every value is compared rather than returning on the first match, so how
    // long the check takes says nothing about which value matched.
    if (crypto.timingSafeEqual(a, b)) matched = true;
  }
  return matched;
}
```

## Structured data

```json
{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "Organization",
      "@id": "https://trooth.co/#org",
      "name": "Trooth",
      "legalName": "Trooth, LLC",
      "alternateName": [
        "Trooth, LLC",
        "trooth.co"
      ],
      "url": "https://trooth.co",
      "logo": {
        "@type": "ImageObject",
        "@id": "https://trooth.co/#logo",
        "url": "https://trooth.co/brand/trooth-mark_black-on-white_1024.png",
        "contentUrl": "https://trooth.co/brand/trooth-mark_black-on-white_1024.png",
        "width": 1024,
        "height": 1024,
        "caption": "Trooth"
      },
      "image": {
        "@id": "https://trooth.co/#logo"
      },
      "description": "Trooth is an infrastructure and cybersecurity company providing Machine-Readable Trust. The Trooth Network keeps one current, evidence-backed page per company, rechecked on a schedule and signed so it can be replayed. 10 frameworks mapped.",
      "foundingDate": "2026",
      "address": {
        "@type": "PostalAddress",
        "streetAddress": "777 Brickell Ave, Suite 500, PMB 1174",
        "addressLocality": "Miami",
        "addressRegion": "FL",
        "postalCode": "33131",
        "addressCountry": "US"
      },
      "contactPoint": {
        "@type": "ContactPoint",
        "contactType": "customer support",
        "email": "hello@trooth.co",
        "url": "https://trooth.co/contact"
      },
      "sameAs": [
        "https://x.com/Troothllc",
        "https://github.com/troothllc",
        "https://www.crunchbase.com/organization/trooth",
        "https://www.wikidata.org/wiki/Q141292994",
        "https://www.youtube.com/@Troothllc",
        "https://www.trustpilot.com/review/trooth.co"
      ]
    },
    {
      "@type": "WebSite",
      "@id": "https://trooth.co/#website",
      "url": "https://trooth.co",
      "name": "Trooth",
      "alternateName": "Trooth Network",
      "inLanguage": "en",
      "publisher": {
        "@id": "https://trooth.co/#org"
      },
      "potentialAction": {
        "@type": "SearchAction",
        "target": {
          "@type": "EntryPoint",
          "urlTemplate": "https://trooth.co/network?q={search_term_string}"
        },
        "query-input": "required name=search_term_string"
      }
    },
    {
      "@type": "ItemList",
      "@id": "https://trooth.co/#sitelinks",
      "name": "Trooth sitelinks",
      "itemListElement": [
        {
          "@type": "SiteNavigationElement",
          "position": 1,
          "name": "Join Trooth now - it's free!",
          "url": "https://trooth.co/signup"
        },
        {
          "@type": "SiteNavigationElement",
          "position": 2,
          "name": "Company, Trooth",
          "url": "https://trooth.co/network/company/trooth"
        },
        {
          "@type": "SiteNavigationElement",
          "position": 3,
          "name": "Trooth Network",
          "url": "https://trooth.co/network"
        }
      ]
    }
  ]
}
```
