> For the complete documentation index, see [llms.txt](https://docs.teranode.group/tng-identity-documentation/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.teranode.group/tng-identity-documentation/introduction/portal-api/guides/verify-webhook-signatures.md).

# Verify Webhook Signatures

### 1. Overview

Identity Portal signs every outbound webhook request so you can verify that it genuinely came from Identity Portal and has not been tampered with.

Each delivery includes two headers you need for verification:

| Header                | Description                                  |
| --------------------- | -------------------------------------------- |
| `X-Webhook-Signature` | HMAC-SHA256 signature, lowercase hex-encoded |
| `X-Webhook-Timestamp` | Unix timestamp in milliseconds               |

### 2. Signing algorithm

* **Algorithm:** HMAC-SHA256
* **Secret:** the `plainSecret` returned when you created the webhook
* **Signed content:**

```
{X-Webhook-Timestamp}.{raw request body}
```

For example:

```
1741512345678.{"id":"...","type":"event","payload":{"event":"credentialOffers.created","data":{...}}}
```

### 3. Verification steps

```
1. Read the X-Webhook-Signature header
2. Read the X-Webhook-Timestamp header
3. Read the raw request body as a string
4. Reject the request if either header is missing
5. Reject the request if the timestamp is too old or too far in the future
6. Build the signed payload: timestamp + "." + rawBody
7. Compute the expected signature: HMAC-SHA256(signedPayload, secret)
8. Compare signatures using constant-time comparison
9. If valid, process the event
10. Use the envelope id for idempotency
```

{% hint style="warning" %}
**Important:** Verify the signature against the **exact raw request body** as received over HTTP. Do not parse the JSON and re-serialize it, pretty-print it, reorder keys, or trim whitespace before verification. Any change to the body will cause signature validation to fail.
{% endhint %}

### 4. Node.js example

Using Express with raw body parsing:

```javascript
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;

app.post(
  '/webhooks',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.header('X-Webhook-Signature');
    const timestamp = req.header('X-Webhook-Timestamp');

    if (!signature || !timestamp) {
      return res.status(400).send('Missing signature headers');
    }

    // Reject timestamps outside a 5-minute window
    const now = Date.now();
    const timestampMs = Number(timestamp);

    if (!Number.isFinite(timestampMs) || Math.abs(now - timestampMs) > 5 * 60 * 1000) {
      return res.status(400).send('Invalid timestamp');
    }

    // Verify signature using the raw body
    const rawBody = req.body.toString('utf8');
    const signedPayload = `${timestamp}.${rawBody}`;
    const expectedSignature = crypto
      .createHmac('sha256', WEBHOOK_SECRET)
      .update(signedPayload)
      .digest('hex');

    const provided = Buffer.from(signature, 'hex');
    const expected = Buffer.from(expectedSignature, 'hex');

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

    const event = JSON.parse(rawBody);

    // Use event.id for idempotency
    console.log('Received event:', event.payload.event);

    return res.status(200).send('ok');
  },
);

app.listen(3000);
```

{% hint style="info" %}
The key detail is `express.raw({ type: 'application/json' })` — this gives you the raw body bytes instead of a parsed JSON object, which is required for correct signature verification.
{% endhint %}

### 5. Python example

Using Flask:

```python
import hashlib
import hmac
import json
import os
import time

from flask import Flask, request, Response

app = Flask(__name__)
WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"]
MAX_SKEW_MS = 5 * 60 * 1000  # 5 minutes


@app.post("/webhooks")
def webhooks():
    signature = request.headers.get("X-Webhook-Signature")
    timestamp = request.headers.get("X-Webhook-Timestamp")

    if not signature or not timestamp:
        return Response("Missing signature headers", status=400)

    # Reject timestamps outside a 5-minute window
    try:
        timestamp_ms = int(timestamp)
    except ValueError:
        return Response("Invalid timestamp", status=400)

    now_ms = int(time.time() * 1000)
    if abs(now_ms - timestamp_ms) > MAX_SKEW_MS:
        return Response("Invalid timestamp", status=400)

    # Verify signature using the raw body
    raw_body = request.get_data(as_text=True)
    signed_payload = f"{timestamp}.{raw_body}".encode("utf-8")

    expected_signature = hmac.new(
        WEBHOOK_SECRET.encode("utf-8"),
        signed_payload,
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(signature, expected_signature):
        return Response("Invalid signature", status=400)

    event = json.loads(raw_body)

    # Use event["id"] for idempotency
    print(f"Received event: {event['payload']['event']}")

    return Response("ok", status=200)
```

### 6. Replay protection

To prevent replay attacks, reject requests with timestamps that are too far from the current time.

**Recommended:** allow at most a 5-minute clock skew window.

```
abs(current_time_ms - header_timestamp_ms) <= 300000
```

Optionally, also reject timestamps that are too far in the future.

### 7. Idempotency

Webhook delivery is **at-least-once** — the same event may be delivered more than once (e.g. due to retries or network issues).

To handle this:

1. Store the top-level `id` from the delivery envelope
2. Before processing an event, check if you have already processed that `id`
3. Skip duplicates that were already handled successfully

The envelope `id` is the safest idempotency key.

### 8. Failure handling

* Return a non-`200` status code if signature verification fails
* Log the failure reason, but **never log the shared secret**
* Reject requests with missing or malformed headers
* Do not process the event if verification fails
