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

# Webhooks

> Receive completed batches by signed POST, and verify the signature.

Instead of polling, pass `webhook_url` on submit and the completed payload is POSTed to it,
signed. Omit `webhook_url` and your account's **default endpoint** is used, if one is set
(Dashboard → Webhooks); omit it with no default set and nothing is delivered — poll instead.

> [!NOTE]
>
> The endpoint is resolved when the batch is **accepted**, so changing your default does not
> retarget a batch already submitted. Results always remain fetchable via `GET`, so a failed
> delivery never loses them.

The delivery body is the same [`SignalsResponse`](https://docs.truthfactor.ai/api-reference/introduction) the `GET`
endpoint returns. Deliveries are signed, retried with backoff, and dead-lettered on exhaustion.

## Verifying the signature

Every delivery carries an `X-TruthFactor-Signature` header. Its value is the **HMAC-SHA256** of
the **exact raw request-body bytes**, keyed by your account's **webhook signing secret**,
lowercase hex-encoded and prefixed `sha256=`:

```http theme={null}
X-TruthFactor-Signature: sha256=<hmac_sha256_hex(signing_secret, raw_body)>
```

Recompute the same value over the body you received and compare the two in **constant time**.

> [!WARNING]
>
> Verify against the **raw bytes before you parse or re-serialise** the JSON — a re-serialised
> body will not reproduce the signature.

```js Node theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

function verifyWebhook(signingSecret, rawBody, received) {
  const expected =
    "sha256=" + createHmac("sha256", signingSecret).update(rawBody, "utf8").digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(received);
  return a.length === b.length && timingSafeEqual(a, b);
}
```

```bash Shell theme={null}
SIGNING_SECRET='whsec_example_do_not_use_in_production'
RAW_BODY='{"request_id":"rq_b42e","requested_datetime":"2026-09-26T18:20:05Z","completed_datetime":"2026-09-26T18:20:38Z","status":"complete"}'

echo "sha256=$(printf '%s' "$RAW_BODY" \
  | openssl dgst -sha256 -hmac "$SIGNING_SECRET" -r | cut -d' ' -f1)"
# sha256=12362f6de2f6f2b7577e1a509d98e4cb9fd70762c4f8c769bcb07d0efd7bfbad
```

## Test vector

Check your implementation against this before going live:

| Field | Value |
| - | - |
| Signing secret | `whsec_example_do_not_use_in_production` |
| Raw body | `{"request_id":"rq_b42e","requested_datetime":"2026-09-26T18:20:05Z","completed_datetime":"2026-09-26T18:20:38Z","status":"complete"}` |
| `X-TruthFactor-Signature` | `sha256=12362f6de2f6f2b7577e1a509d98e4cb9fd70762c4f8c769bcb07d0efd7bfbad` |

## Managing deliveries

**Dashboard → Webhooks** is where you set the default endpoint, reveal or rotate the signing
secret, and read the delivery log — every attempt for every batch, its response status, when
the next retry is due, and which deliveries dead-lettered. A batch you've fixed the receiver
for can be re-sent from there; the replay is recorded as a further attempt and erases none of
the failed ones.

> [!TIP]
>
> Revealing the secret mints one if your account has none yet, so you can wire verification up
> before your first delivery. Rotating takes effect immediately — a signature computed with the
> old secret stops verifying.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.