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

# Signature Verification

> Verify signed outbound Praeto Dispatcher webhook requests.

# Praeto Dispatcher Signature Verification

Praeto Dispatcher signs every outbound webhook so receivers can verify that the request came from Praeto Dispatcher and was not modified in transit.

Webhook signing is not optional for production integrations. Customers should reject unsigned or unverifiable webhook requests.

***

## Headers

Outbound webhook deliveries include these headers:

```http theme={null}
praeto-event-id: 811fad9a-d2cb-4dd2-a2e1-9bb5d90190db
praeto-event-type: invoice.created
praeto-delivery-id: d904b72a-58c5-42c0-8eaa-7f4403ec77e8
praeto-timestamp: 2026-04-28T09:12:00.000Z
praeto-signature: v1=...
```

During secret rotation overlap, `praeto-signature` can include more than one signature:

```http theme={null}
praeto-signature: v1=<signature-from-current-secret>,v1=<signature-from-previous-secret>
```

The receiver should accept the webhook if **any** provided signature matches an active secret.

***

## Verification rules

A receiver should verify:

1. Required headers exist.
2. Timestamp is within tolerance.
3. Signature matches the raw request body.
4. Event ID has not already been processed if the receiver needs idempotent processing.

Recommended timestamp tolerance:

```text theme={null}
5 minutes
```

This prevents old signed requests from being replayed much later.

***

## Signature base string

The expected signature base string is:

```text theme={null}
<praeto-delivery-id>.<praeto-timestamp>.<raw-body>
```

Example:

```text theme={null}
d904b72a-58c5-42c0-8eaa-7f4403ec77e8.2026-04-28T09:12:00.000Z.{"event_type":"invoice.created","payload":{"invoice_id":"inv_001"}}
```

The signature is:

```text theme={null}
HMAC_SHA256(endpoint_signing_secret, signature_base_string)
```

Encoded as lowercase hex and prefixed with `v1=`.

***

## Important: use the raw request body

Do not parse JSON and re-serialize it before checking the signature.

This is wrong:

```text theme={null}
parse JSON -> stringify JSON -> verify signature
```

This is correct:

```text theme={null}
read raw bytes/body exactly as received -> verify signature
```

JSON serializers may change whitespace, key order, or escaping, which will break HMAC verification.

***

## Node.js verification example

See [`../examples/node/verify-praeto-signature.js`](../examples/node/verify-praeto-signature.js).

Minimal example:

```js theme={null}
const crypto = require("crypto");

function timingSafeEqualHex(a, b) {
  const aBuf = Buffer.from(a, "hex");
  const bBuf = Buffer.from(b, "hex");
  if (aBuf.length !== bBuf.length) return false;
  return crypto.timingSafeEqual(aBuf, bBuf);
}

function verifyPraetoWebhook({ rawBody, headers, secret, toleranceSeconds = 300 }) {
  const deliveryId = headers["praeto-delivery-id"];
  const timestamp = headers["praeto-timestamp"];
  const signatureHeader = headers["praeto-signature"];

  if (!deliveryId || !timestamp || !signatureHeader) return false;

  const timestampMs = Date.parse(timestamp);
  if (!Number.isFinite(timestampMs)) return false;

  const ageSeconds = Math.abs(Date.now() - timestampMs) / 1000;
  if (ageSeconds > toleranceSeconds) return false;

  const base = `${deliveryId}.${timestamp}.${rawBody}`;
  const expected = crypto.createHmac("sha256", secret).update(base).digest("hex");

  const provided = signatureHeader
    .split(",")
    .map((part) => part.trim())
    .filter((part) => part.startsWith("v1="))
    .map((part) => part.slice(3));

  return provided.some((sig) => timingSafeEqualHex(sig, expected));
}
```

***

## Python verification example

See [`../examples/python/verify_praeto_signature.py`](../examples/python/verify_praeto_signature.py).

Minimal example:

```python theme={null}
import hmac
import hashlib
from datetime import datetime, timezone


def verify_praeto_webhook(raw_body: bytes, headers: dict, secret: str, tolerance_seconds: int = 300) -> bool:
    delivery_id = headers.get("praeto-delivery-id")
    timestamp = headers.get("praeto-timestamp")
    signature_header = headers.get("praeto-signature")

    if not delivery_id or not timestamp or not signature_header:
        return False

    try:
        dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
    except ValueError:
        return False

    age_seconds = abs((datetime.now(timezone.utc) - dt).total_seconds())
    if age_seconds > tolerance_seconds:
        return False

    base = delivery_id.encode() + b"." + timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), base, hashlib.sha256).hexdigest()

    signatures = []
    for part in signature_header.split(","):
        part = part.strip()
        if part.startswith("v1="):
            signatures.append(part[3:])

    return any(hmac.compare_digest(sig, expected) for sig in signatures)
```

***

## Secret rotation behavior

When an endpoint secret is rotated:

1. The old secret becomes `previous_secret`.
2. A new current secret is generated.
3. For the configured overlap window, outbound deliveries include signatures for both current and previous secrets.
4. After the overlap expires, only the current secret is valid.

Default overlap:

```text theme={null}
7 days
```

This prevents customer downtime during secret rotation.

***

## Receiver idempotency recommendation

Praeto Dispatcher prevents duplicate event ingestion on the publisher side when `Idempotency-Key` is used.

Receivers should still process inbound webhooks idempotently by storing:

```text theme={null}
praeto-delivery-id
```

or:

```text theme={null}
praeto-event-id
```

Use `praeto-delivery-id` if you want to dedupe exact delivery attempts. Use `praeto-event-id` if you want to process a business event only once regardless of replay.
