Skip to main content

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:
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:
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:
5 minutes
This prevents old signed requests from being replayed much later.

Signature base string

The expected signature base string is:
<praeto-delivery-id>.<praeto-timestamp>.<raw-body>
Example:
d904b72a-58c5-42c0-8eaa-7f4403ec77e8.2026-04-28T09:12:00.000Z.{"event_type":"invoice.created","payload":{"invoice_id":"inv_001"}}
The signature is:
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:
parse JSON -> stringify JSON -> verify signature
This is correct:
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. Minimal example:
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. Minimal example:
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:
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:
praeto-delivery-id
or:
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.