# Receive webhooks

> Accept your organization's audit events at your own endpoint. Verify each signature, drop retried duplicates, and read daily batches.

Source: https://docs.trustaige.com/provisioning/webhooks

A webhook posts your organization's audit events to an HTTPS endpoint you own:
each event as it happens, or every event of the day in one batch. You add one
in the hub under **Audit log → Webhooks**, and the hub shows its signing secret
once. Your endpoint's job is to check that each request came from Trustaige,
answer quickly, and ignore an event it has already seen.

1. Trustaige → Your endpoint: POST the event, signed
2. Your endpoint → Trustaige: 2xx within 10 seconds
3. Trustaige → Your endpoint: on failure: again after 30 s, then 5 min

## What each request carries

Every delivery is an HTTPS `POST`. Redirects are never followed, so give the
final address.

| Header | What it holds |
|---|---|
| `X-Webhook-Signature` | `sha256=` followed by the hex HMAC-SHA256 of the raw body, keyed with the signing secret |
| `X-Webhook-Signature-Old` | The same, signed with the previous secret. Sent only while a rotation's 24 hours run |
| `X-Event-ID` | The audit event's ID. Every retry of one event carries the same ID. A daily batch sends `batch` |
| `X-Event-Type` | The event's type, such as `session.created`. A daily batch sends `scheduled.export` |
| `X-Webhook-Delivery-ID` | New for every attempt. Quote it when you ask about one delivery |
| `Content-Type` | Depends on the format; see [Formats](#formats) |
| `User-Agent` | `Trustaige-Identity-Hub/1.0` |

Any headers you added to the webhook in the hub, such as an API token for your
collector, are sent with every delivery, real-time or daily batch.

## Verify the signature

Compute the HMAC-SHA256 of the request body **exactly as it arrived**, before
any JSON parsing, keyed with the signing secret **as the hub showed it** (use
the text itself; don't hex-decode it). Prefix `sha256=` and compare it with
`X-Webhook-Signature` in constant time. Refuse the request if it doesn't match.

```js title="verify.js"
import crypto from 'node:crypto';

// rawBody: the request body exactly as received, before any JSON parsing.
export function verify(rawBody, signatureHeader, secret) {
  const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader ?? '');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

```go title="verify.go"
import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
)

// rawBody: the request body exactly as received, before any JSON parsing.
func verify(rawBody []byte, signatureHeader, secret string) bool {
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write(rawBody)
	expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(signatureHeader))
}
```

```python title="verify.py"
import hashlib
import hmac

# raw_body: the request body exactly as received, before any JSON parsing.
def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature_header or "")
```

> **Tip:** Most web frameworks parse JSON before your handler runs. Ask yours for the raw
> bytes (in Express, `express.raw({ type: '*/*' })` on the webhook route): a body
> that was parsed and serialized again will not match its signature.

## When you rotate the secret

Rotating in the hub shows a new secret once. For the next 24 hours every
delivery, real-time or daily batch, is signed with both: the new secret in
`X-Webhook-Signature`, the previous one in `X-Webhook-Signature-Old`. Accept a
request if either matches, give your endpoint the new secret, and the old one
stops on its own when the 24 hours end.

## Retries and duplicates

Your endpoint has 10 seconds to answer. Any `2xx` is a success; anything else,
a timeout, a redirect or a reply over 8 KB is a failure. A real-time event is
tried up to three times: at once, after 30 seconds, and after 5 minutes.

Because a slow `2xx` can still be retried, the same event can arrive more than
once. Keep the `X-Event-ID` values you've handled and skip one you've seen.

When ten events in a row fail all three attempts, the webhook turns itself off
and the hub says so. Fix your endpoint, send a test event from the webhook's
page, then turn it back on; that clears the count. Events that happened while it
was off are not sent later. The audit log still has them, and you can export
them.

## Formats

Each webhook sends one format, chosen in the hub.

| Format | Body | `Content-Type` |
|---|---|---|
| JSON | One event as a JSON object | `application/json` |
| CEF | One event as an ArcSight CEF line | `text/plain` |
| LEEF | One event as an IBM QRadar LEEF line | `text/plain` |
| OCSF | One event as an OCSF JSON object | `application/json` |

A test event sent from the hub is a real delivery of type `webhook.tested`,
signed the same way, so it is the quickest check that your verification works.

## Daily batches

A webhook set to deliver once a day sends one request every 24 hours instead of
one per event.

- The body is newline-delimited: one event per line, each in the webhook's
  format, and the request's `Content-Type` is `application/x-ndjson`.
- `X-Event-Type` is `scheduled.export` and `X-Event-ID` is `batch`. To skip an
  event you've already stored, use the event's own ID inside each line (`id` in
  JSON).
- Each batch starts after the last event the previous one delivered, so none
  is skipped. A backlog goes out oldest first, at most 10,000 events to a
  request, in as many requests as it takes.
- A batch that fails is not retried at once: the next day's run sends it again,
  from the same event.
- A batch carries the headers you added in the hub, so a collector that needs
  a token in a header (Splunk's HTTP Event Collector, for example) can take
  batches as well as real-time deliveries.
