> ## Documentation Index
> Fetch the complete documentation index at: https://docs.truthlocks.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive real-time HTTP notifications when events occur in your Truthlocks tenant.

Webhooks let your application receive automatic notifications when events happen in your Truthlocks account — for example, when an attestation is minted, a key is rotated, or a verification fails. Instead of polling the API for changes, your server receives an HTTP POST request with the event details.

## How it works

<Steps>
  <Step title="Register an endpoint">
    Add a webhook endpoint in **Settings > Webhooks** in the [tenant console](https://console.truthlocks.com). Provide a publicly reachable HTTPS URL and choose which event types you want to receive.
  </Step>

  <Step title="Receive events">
    When a matching event occurs, Truthlocks sends an HTTP POST request to your URL with a JSON payload describing the event.
  </Step>

  <Step title="Verify the signature">
    Each request includes an HMAC-SHA256 signature in the `X-Truthlocks-Signature` header. Verify it using the endpoint secret to confirm the request came from Truthlocks.
  </Step>

  <Step title="Respond with 200">
    Return an HTTP `200` status code to acknowledge receipt. If your endpoint fails or times out, Truthlocks retries with exponential backoff.
  </Step>
</Steps>

## Event types

Events are grouped into categories. You can subscribe to individual events or use a wildcard (`attestation.*`) to receive all events in a category.

| Category         | Event                          | Trigger                                                    |
| :--------------- | :----------------------------- | :--------------------------------------------------------- |
| **Attestations** | `attestation.created`          | A new attestation is minted                                |
|                  | `attestation.revoked`          | An attestation is revoked                                  |
|                  | `attestation.expired`          | An attestation reaches its expiry date                     |
|                  | `attestation.superseded`       | An attestation is replaced by a newer version              |
| **Verification** | `verification.completed`       | A verification check succeeds                              |
|                  | `verification.failed`          | A verification check fails                                 |
| **Issuers**      | `issuer.created`               | A new issuer is registered                                 |
|                  | `issuer.updated`               | An issuer's profile or settings change                     |
|                  | `issuer.suspended`             | An issuer is suspended                                     |
| **Keys**         | `key.created`                  | A signing key is registered                                |
|                  | `key.rotated`                  | A signing key is rotated                                   |
|                  | `key.revoked`                  | A signing key is revoked                                   |
|                  | `key.compromised`              | A key is reported as compromised                           |
| **Team**         | `team.member_invited`          | A team invitation is sent                                  |
|                  | `team.member_joined`           | A team member accepts an invitation                        |
|                  | `team.member_removed`          | A team member is removed                                   |
|                  | `team.role_changed`            | A team member's role is updated                            |
| **Billing**      | `billing.subscription.created` | A new subscription is created                              |
|                  | `billing.subscription.updated` | A subscription is modified                                 |
|                  | `billing.invoice.created`      | A new invoice is generated                                 |
|                  | `billing.usage.threshold`      | A usage threshold is reached                               |
| **Consumer**     | `consumer.content.protected`   | A consumer protects new content                            |
| **Risk signals** | `risk.signal.created`          | A risk signal is ingested (any detection path)             |
|                  | `risk.signal.escalated`        | A risk signal score crosses the automatic review threshold |
| **Receipts**     | `receipt.created`              | A new receipt is minted                                    |
|                  | `receipt.revoked`              | A receipt is revoked                                       |
|                  | `receipt.type.created`         | A new receipt type is created                              |
|                  | `receipt.type.deprecated`      | A receipt type is deprecated                               |
| **Security**     | `security.password_changed`    | A user changes their password                              |
|                  | `security.api_key_compromised` | An API key is flagged as compromised                       |
|                  | `security.suspicious_activity` | Suspicious account activity is detected                    |

### Wildcard filters

Use `category.*` to subscribe to every event in a category. For example, `attestation.*` matches `attestation.created`, `attestation.revoked`, `attestation.expired`, and `attestation.superseded`.

## Endpoint limits by plan

The number of webhook endpoints you can create depends on your plan tier:

| Plan       | Endpoint limit |
| :--------- | :------------- |
| Free       | 1              |
| Starter    | 3              |
| Business   | 10             |
| Enterprise | 25             |

Need more endpoints? Contact your account manager or upgrade your plan in **Settings > Billing**.

## Creating an endpoint

1. In the tenant console, go to **Settings > Webhooks**.
2. Click **Add endpoint**.
3. Enter a name, your HTTPS destination URL, and select the event types you want to receive.
4. Click **Create**.

The console displays your endpoint secret once. Copy it immediately — you cannot retrieve it later.

### Via the API

```http Request theme={null}
POST /v1/webhooks/endpoints
X-API-Key: tl_live_...
Content-Type: application/json

{
  "name": "My Backend",
  "url": "https://api.example.com/webhooks/truthlock",
  "event_filters": ["attestation.*", "verification.completed"]
}
```

```json Response theme={null}
{
  "id": "ep_abc123",
  "name": "My Backend",
  "url": "https://api.example.com/webhooks/truthlock",
  "status": "active",
  "secret": "whsec_...",
  "event_filters": ["attestation.*", "verification.completed"],
  "created_at": "2026-03-25T12:00:00Z"
}
```

### Listing endpoints

Retrieve all webhook endpoints for your tenant:

```http Request theme={null}
GET /v1/webhooks/endpoints
X-API-Key: tl_live_...
```

See the full [API reference](/api-reference/webhooks/list-endpoints) for response details.

## Verifying signatures

Every webhook request includes a signature header for verification. Always verify signatures before processing events.

**Signature format:**

```
X-Truthlocks-Signature: t=1710000000,v1=a1b2c3d4e5f6...
```

Where `t` is the Unix timestamp and `v1` is the HMAC-SHA256 hex digest.

**Verification steps:**

1. Extract `t` (timestamp) and `v1` (signature) from the header.
2. Reject the request if the timestamp is more than 5 minutes old.
3. Concatenate `{timestamp}.{raw_request_body}` to form the signing string.
4. Compute HMAC-SHA256 of the signing string using your endpoint secret.
5. Compare the computed digest to `v1` using a constant-time comparison.

<CodeGroup>
  ```typescript verify-webhook.ts theme={null}
  import crypto from "crypto";
  import express from "express";

  const app = express();
  app.use(express.raw({ type: "application/json" }));

  app.post("/webhooks/truthlock", (req, res) => {
    const sigHeader = req.headers["x-truthlocks-signature"] as string;
    if (!sigHeader) return res.status(401).send("Missing signature");

    const parts = Object.fromEntries(
      sigHeader.split(",").map((p) => p.split("=") as [string, string])
    );
    const timestamp = parts["t"];
    const signature = parts["v1"];

    // Reject stale requests (5-minute window)
    const age = Math.abs(Date.now() / 1000 - Number(timestamp));
    if (age > 300) return res.status(401).send("Timestamp expired");

    // Compute expected signature
    const signingString = `${timestamp}.${req.body.toString()}`;
    const expected = crypto
      .createHmac("sha256", process.env.WEBHOOK_SECRET!)
      .update(signingString)
      .digest("hex");

    // Constant-time comparison
    if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
      return res.status(401).send("Invalid signature");
    }

    const event = JSON.parse(req.body.toString());
    console.log("Received event:", event.type);

    res.status(200).json({ received: true });
  });
  ```

  ```go verify-webhook.go theme={null}
  package main

  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/hex"
  	"fmt"
  	"io"
  	"math"
  	"net/http"
  	"strconv"
  	"strings"
  	"time"
  )

  func verifyWebhook(r *http.Request, secret string) ([]byte, error) {
  	sig := r.Header.Get("X-Truthlocks-Signature")
  	if sig == "" {
  		return nil, fmt.Errorf("missing signature header")
  	}

  	parts := map[string]string{}
  	for _, p := range strings.Split(sig, ",") {
  		kv := strings.SplitN(p, "=", 2)
  		if len(kv) == 2 {
  			parts[kv[0]] = kv[1]
  		}
  	}

  	ts, _ := strconv.ParseInt(parts["t"], 10, 64)
  	age := math.Abs(float64(time.Now().Unix() - ts))
  	if age > 300 {
  		return nil, fmt.Errorf("timestamp expired")
  	}

  	body, err := io.ReadAll(r.Body)
  	if err != nil {
  		return nil, err
  	}

  	mac := hmac.New(sha256.New, []byte(secret))
  	mac.Write([]byte(fmt.Sprintf("%d.%s", ts, body)))
  	expected := hex.EncodeToString(mac.Sum(nil))

  	if !hmac.Equal([]byte(parts["v1"]), []byte(expected)) {
  		return nil, fmt.Errorf("invalid signature")
  	}

  	return body, nil
  }
  ```

  ```python verify_webhook.py theme={null}
  import hashlib
  import hmac
  import time
  from flask import Flask, request, abort

  app = Flask(__name__)

  @app.route("/webhooks/truthlock", methods=["POST"])
  def handle_webhook():
      sig_header = request.headers.get("X-Truthlocks-Signature", "")
      parts = dict(p.split("=", 1) for p in sig_header.split(","))

      timestamp = parts.get("t", "")
      signature = parts.get("v1", "")

      # Reject stale requests
      if abs(time.time() - int(timestamp)) > 300:
          abort(401, "Timestamp expired")

      # Compute expected signature
      signing_string = f"{timestamp}.{request.get_data(as_text=True)}"
      expected = hmac.new(
          WEBHOOK_SECRET.encode(),
          signing_string.encode(),
          hashlib.sha256,
      ).hexdigest()

      if not hmac.compare_digest(signature, expected):
          abort(401, "Invalid signature")

      event = request.get_json()
      print(f"Received event: {event['type']}")
      return {"received": True}
  ```
</CodeGroup>

<Warning>
  Always use a constant-time comparison function (like `crypto.timingSafeEqual` or `hmac.compare_digest`) to prevent timing attacks.
</Warning>

## Request headers

Every webhook delivery includes these headers:

| Header                    | Description                              |
| :------------------------ | :--------------------------------------- |
| `Content-Type`            | `application/json`                       |
| `User-Agent`              | `Truthlocks-Webhook-Service/1.0`         |
| `X-Truthlocks-Event-Id`   | Unique identifier for the event          |
| `X-Truthlocks-Event-Type` | Event type (e.g., `attestation.created`) |
| `X-Truthlocks-Timestamp`  | Unix timestamp of the delivery attempt   |
| `X-Truthlocks-Signature`  | HMAC-SHA256 signature for verification   |

## Retry behavior

If your endpoint returns a non-2xx status code or doesn't respond within 5 seconds, Truthlocks retries the delivery with exponential backoff:

| Attempt   | Approximate delay |
| :-------- | :---------------- |
| 1st retry | \~1 second        |
| 2nd retry | \~2 seconds       |
| 3rd retry | \~4 seconds       |
| 4th retry | \~16 seconds      |
| 5th retry | \~1 minute        |
| 6th retry | \~2 minutes       |
| 7th retry | \~5 minutes (max) |

Delays include up to 30% jitter. After 8 total attempts (1 initial + 7 retries), the delivery is marked as failed.

You can view delivery attempts and failure details in **Settings > Webhooks** by clicking on an endpoint, or query them via the API:

```http Request theme={null}
GET /v1/webhooks/endpoints/{id}/deliveries
X-API-Key: tl_live_...
```

See the [list deliveries API reference](/api-reference/webhooks/list-deliveries) for response details.

## Rotating secrets

If your webhook secret is compromised, rotate it immediately:

1. In the tenant console, go to **Settings > Webhooks** and click on the endpoint.
2. Click **Rotate secret**.
3. Copy the new secret — the old secret stops working immediately.

You can also rotate via the API:

```http Request theme={null}
POST /v1/webhooks/endpoints/{id}/rotate
X-API-Key: tl_live_...
```

## Testing webhooks

Send a test event to verify your endpoint is working:

1. In the tenant console, go to **Settings > Webhooks** and click on the endpoint.
2. Click **Send test**.
3. A `webhook.test` event is sent to your URL using the full signing and delivery pipeline.

Via the API:

```http Request theme={null}
POST /v1/webhooks/test-delivery
X-API-Key: tl_live_...
Content-Type: application/json

{
  "endpoint_id": "ep_abc123",
  "event_type": "webhook.test",
  "payload": { "status": "ok", "source": "test-delivery" }
}
```

The `event_type` and `payload` fields are optional — they default to `webhook.test` and a basic status payload if omitted.

## Risk signal notifications

You can receive real-time webhook notifications whenever a risk signal is created by any of the five [Anti-Fraud Identity Firewall](/guides/risk-signals) detection paths — direct ingestion, event normalization, deepfake scanning, ATO detection, and velocity scoring.

Subscribe to `risk.signal.*` to receive all risk signal events, or subscribe to `risk.signal.created` or `risk.signal.escalated` individually.

### Example payload

```json theme={null}
{
  "id": "evt_r1s2k3...",
  "type": "risk.signal.created",
  "created_at": "2027-07-24T09:15:00Z",
  "data": {
    "signal_id": "a1b2c3d4-...",
    "signal_source": "velocity",
    "signal_type": "velocity",
    "risk_score": 72,
    "subject_type": "user",
    "subject_id": "usr_8f14e45f"
  }
}
```

### Responding to risk signals

Use risk signal webhooks to trigger automated responses — block a user session, notify your security team, or escalate to a fraud case — without polling the API.

```javascript theme={null}
app.post("/webhooks/truthlock", (req, res) => {
  const event = JSON.parse(req.body.toString());

  if (event.type === "risk.signal.created") {
    const { risk_score, subject_id, signal_type } = event.data;

    if (risk_score >= 80) {
      // Critical — block immediately and alert security
      blockSubject(subject_id);
      notifySecurityTeam(event.data);
    } else if (risk_score >= 60) {
      // High — throttle and log for review
      throttleSubject(subject_id);
    }
  }

  res.status(200).json({ received: true });
});
```

<Info>
  Risk signal webhooks fire for signals created by all five detection paths. The `signal_source` field in the payload tells you which path generated the signal — `external`, `event_normalization`, `deepfake`, `ato`, or `velocity`.
</Info>

## Best practices

<Tip>
  Respond to webhooks quickly. Do your heavy processing asynchronously after returning a `200` response to avoid timeouts and retries.
</Tip>

* **Verify every signature.** Never skip signature verification, even in development.
* **Use a message queue.** Enqueue incoming events and process them in a worker to avoid blocking the HTTP response.
* **Handle duplicates.** Use the `X-Truthlocks-Event-Id` header to deduplicate events in case of retries.
* **Monitor delivery failures.** Check the deliveries tab in the console regularly for failed or dead deliveries.
* **Keep secrets secure.** Store your webhook secret in environment variables, not in source code.
