---
title: Webhooks and HMAC
description: "Webhook actions let your TrustLens rules push events to external systems — CRMs, helpdesks, analytics, custom internal tools. Every webhook is signed with HMAC-SHA256 so the receiver can verify the request came from your TrustLens instance and wasn't tampered with.…"
url: "https://webstepper.io/docs/trustlens/automation/webhooks-and-hmac/"
updated: "2026-10-18"
category: Automation
---

# Webhooks and HMAC

Webhook actions let your TrustLens rules push events to external systems — CRMs, helpdesks, analytics, custom internal tools. Every webhook is signed with HMAC-SHA256 so the receiver can verify the request came from your TrustLens instance and wasn't tampered with. This page covers the webhook payload format, the signature scheme, and how to implement receivers correctly.

This page is about the **Send Webhook** automation action. The global webhook endpoints under Settings → Integrations → Webhooks are a separate feature with their own per-endpoint secrets and payloads.

---

## Webhook Action Configuration

In the rule editor, choose **Send Webhook** as the action and put the receiver's URL in the action value:

| Setting | Description |
| --- | --- |
| URL | Full URL of the receiver endpoint (the action value). Required; the rule won't save without a valid URL. Use HTTPS, and use the final URL — redirects aren't followed |
| Secret | Not set per rule. TrustLens generates one signing secret for your site the first time a webhook is sent |
| Timeout | Fixed at 10 seconds |

There are no custom-header or timeout fields. The secret isn't shown in the admin: it's stored in the `trustlens_automation_webhook_secret` WordPress option (read it with `wp option get trustlens_automation_webhook_secret`), and developers can supply their own — per rule, if needed — with the `trustlens/automation/webhook_secret` filter. Save it somewhere safe — you'll need it on the receiver side.

---

## Sending to Slack

Don't paste a Slack incoming-webhook URL into Send Webhook. Slack only accepts a body with a `text` (or `blocks`) field, and the TrustLens payload has neither, so Slack rejects every delivery and the rule logs an error. Point the action at Zapier, Make or a small endpoint of your own, and have that post a formatted message to Slack.

---

## Request Format

Each webhook is an HTTP POST with:

- Content-Type: `application/json`
- Header `X-TrustLens-Event`: always `trustlens_automation` (the trigger is in the body)
- Header `X-TrustLens-Signature`: `sha256=` followed by the hex HMAC-SHA256 of the timestamp, a dot, and the raw body
- Header `X-TrustLens-Delivery`: a new UUID for every attempt
- Header `X-TrustLens-Rule-ID`: the rule's ID
- Header `X-TrustLens-Timestamp`: Unix timestamp at dispatch time

The User-Agent is WordPress's default.

---

## Payload Schema

The body is a flat JSON object:

```
{
  "event": "trustlens_automation",
  "rule": "Auto-block on dispute",
  "rule_id": "3f2b9c1e-…",
  "trigger": "chargeback_filed",
  "email_hash": "…",
  "trust_score": 18,
  "segment": "critical",
  "fired_at": "2026-03-15 12:00:00",
  "attempt": 1
}
```

`rule_id` is the rule's UUID, `trust_score` and `segment` are read when the webhook is sent (null if the customer has no record), `fired_at` is the site's local time at sending, and `attempt` counts 1 to 4. Rules on Refund Processed also include a `refund_context` object with the refund's amount, ratio, type, classification, reason and the other refund fields. The payload carries no order, dispute or fingerprint data — use the email hash with the [REST API](/docs/trustlens/developer-documentation/rest-api-reference/) if you need more.

---

## Signature Verification

On the receiver side, verify the signature before trusting the payload. Reference implementation in pseudocode:

```
function verify(request) {
  body = request.raw_body            // exact bytes
  timestamp = request.headers['x-trustlens-timestamp']
  signature_header = request.headers['x-trustlens-signature']
  expected = "sha256=" + hmac_sha256_hex(secret, timestamp + "." + body)
  return constant_time_compare(signature_header, expected)
}
```

Key points:

- **Sign the timestamp too.** The signed string is the `X-TrustLens-Timestamp` value, a `.`, then the body — not the body alone.
- **Use the raw body bytes**, not a re-serialized JSON. JSON serialization can vary by language; the signed bytes are whatever TrustLens sent.
- **Use constant-time comparison** to prevent timing attacks. Most languages have this built in (Node: `crypto.timingSafeEqual`; Python: `hmac.compare_digest`; PHP: `hash_equals`).
- **Reject requests with missing or wrong signature** — return 401, log the attempt

---

## Implementation Examples

### Node.js / Express

```
const crypto = require('crypto');

app.post('/trustlens-webhook', (req, res) => {
  const signature = req.headers['x-trustlens-signature'] || '';
  const timestamp = req.headers['x-trustlens-timestamp'] || '';
  const expected = 'sha256=' + crypto
    .createHmac('sha256', SECRET)
    .update(timestamp + '.' + req.rawBody)
    .digest('hex');

  if (signature.length !== expected.length || !crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  )) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(req.rawBody.toString());
  // ... handle event
  res.status(200).send('ok');
});
```

### Python / Flask

```
import hmac, hashlib
from flask import request

@app.post('/trustlens-webhook')
def webhook():
    signature = request.headers.get('X-TrustLens-Signature', '')
    timestamp = request.headers.get('X-TrustLens-Timestamp', '')
    signed = timestamp.encode() + b'.' + request.get_data()
    expected = 'sha256=' + hmac.new(
        SECRET, signed, hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(signature, expected):
        return ('Invalid signature', 401)
    event = request.get_json()
    # ... handle event
    return ('ok', 200)
```

### PHP

```
$body      = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_TRUSTLENS_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_TRUSTLENS_SIGNATURE'] ?? '';
$expected  = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, SECRET);

if ( ! hash_equals( $expected, $signature ) ) {
    http_response_code( 401 );
    exit( 'Invalid signature' );
}

$event = json_decode( $body, true );
// ... handle event
http_response_code( 200 );
echo 'ok';
```

---

## Replay Protection

TrustLens includes a `X-TrustLens-Delivery` header with a unique UUID per dispatch attempt (different for retries).

The `X-TrustLens-Timestamp` header is part of the signed string, so it can't be altered without breaking the signature — reject any request whose timestamp is more than 5 minutes old to block replays.

---

## Expected Response

The receiver should respond with HTTP 2xx within 10 seconds. Any 2xx is treated as success and logged as OK; the response body is ignored.

Anything else — a 3xx redirect, 4xx, 5xx, a timeout or a connection error — triggers automatic retry: 60s / 120s / 240s backoff. After 3 failed retries, the delivery is logged as failed.

If your receiver does long-running processing, respond 200 immediately and process asynchronously. Don't keep TrustLens waiting.

---

## Idempotency

TrustLens retries a delivery only when it didn't get a 2xx — but a receiver that processed the request and then timed out will see it again. Each retry carries a new `X-TrustLens-Delivery` UUID, a new `fired_at` and a higher `attempt`, so there's no single ID that stays the same across retries. If duplicates matter, dedupe on `rule_id` + `email_hash` + `trigger` within a short window, or treat `attempt` > 1 as a possible repeat.

Before sending, identical pending deliveries (same rule and customer) are collapsed into one, and the one-hour rule cooldown limits how often the same rule can queue a webhook for the same customer.

---

## Secret Rotation

There's no "Regenerate Secret" button. To rotate the site secret:

1. Delete the `trustlens_automation_webhook_secret` option (e.g. `wp option delete trustlens_automation_webhook_secret`) — or return a new secret from the `trustlens/automation/webhook_secret` filter
2. A new secret is used from the next delivery (a deleted one is regenerated automatically)
3. Read the new value and update every receiver's expected secret

There's no graceful transition window — the old secret stops working as soon as the new one is in use, for every rule at once. For zero-downtime rotation, update the receiver first to accept both old and new secrets, then rotate, then remove the old secret.

---

## Debugging

The Rule Inspector shows each delivery's final result:

- QUEUED while the delivery waits in Action Scheduler
- OK once a 2xx arrives
- ERR after the last retry, with the HTTP status ("Webhook returned HTTP 500 after 4 attempt(s).") or the connection error ("Webhook delivery failed: …")

Request and response bodies aren't stored. To see exactly what TrustLens sends, point a rule at a tool like webhook.site or RequestBin before connecting your real receiver. TrustLens's payload format is predictable; verifying it against a test endpoint is faster than debugging via real downstream effects. Pending and retrying deliveries are visible under WooCommerce → Status → Scheduled Actions (hook `trustlens/automation/dispatch_webhook`).
