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

# Securing Webhooks

> Verify Truedy webhook signatures and prevent replay attacks in Node.js, Python, and Go

# Securing Webhooks

Truedy signs every webhook request so you can confirm:

1. The request really came from Truedy
2. The payload wasn't modified in transit
3. The request isn't a replay of an older delivery

<Warning>
  Never process a webhook payload without verifying its signature first. Skipping verification lets any attacker fake events to your server.
</Warning>

## Headers sent with every request

| Header               | Value                                                     |
| -------------------- | --------------------------------------------------------- |
| `X-Truedy-Timestamp` | Unix timestamp in seconds (when the event was dispatched) |
| `X-Truedy-Signature` | HMAC-SHA256 hex digest of the signed message              |
| `Content-Type`       | `application/json`                                        |

## Signature algorithm (step by step)

1. Read the **raw request body bytes** exactly as received — do not parse JSON first.
2. Extract `X-Truedy-Timestamp` and `X-Truedy-Signature`.
3. Build the signed message: `"{timestamp}.{raw_body_as_string}"`
4. Compute `HMAC-SHA256(key=webhook_secret, message=signed_message)` and hex-encode it.
5. Compare with `X-Truedy-Signature` using a **constant-time** comparison.
6. Reject if the timestamp is more than **5 minutes** old (replay protection).

## Implementation examples

<CodeGroup>
  ```typescript Node.js / TypeScript (Express) theme={null}
  import crypto from 'crypto'
  import express, { Request, Response } from 'express'

  const app = express()

  // IMPORTANT: use express.raw() to preserve the exact body bytes for HMAC
  app.post('/webhooks/truedy', express.raw({ type: 'application/json' }), (req: Request, res: Response) => {
    const rawBody = req.body as Buffer
    const timestamp = req.headers['x-truedy-timestamp'] as string
    const signature = req.headers['x-truedy-signature'] as string
    const secret = process.env.TRUEDY_WEBHOOK_SECRET!

    if (!verifyTruedyWebhook(rawBody, timestamp, signature, secret)) {
      return res.status(401).json({ error: 'Invalid signature' })
    }

    const event = JSON.parse(rawBody.toString('utf8'))
    console.log('Event received:', event.event)

    // Acknowledge quickly — do heavy work in a background job
    res.status(204).send()

    // Process asynchronously
    processWebhookAsync(event).catch(console.error)
  })

  function verifyTruedyWebhook(
    rawBody: Buffer,
    timestamp: string,
    signature: string,
    secret: string
  ): boolean {
    // 1. Replay protection: reject timestamps older than 5 minutes
    const now = Math.floor(Date.now() / 1000)
    const ts = parseInt(timestamp, 10)
    if (isNaN(ts) || now - ts > 300 || ts > now + 60) {
      return false
    }

    // 2. Compute expected HMAC
    const message = `${timestamp}.${rawBody.toString('utf8')}`
    const expected = crypto
      .createHmac('sha256', secret)
      .update(message)
      .digest('hex')

    // 3. Constant-time comparison (prevents timing attacks)
    if (expected.length !== signature.length) return false
    return crypto.timingSafeEqual(
      Buffer.from(expected, 'hex'),
      Buffer.from(signature, 'hex')
    )
  }

  async function processWebhookAsync(event: Record<string, unknown>) {
    switch (event.event) {
      case 'call.ended':
        // update CRM, store transcript, etc.
        break
      case 'batch.completed':
        // finalize campaign reporting
        break
      default:
        console.log('Unhandled event type:', event.event)
    }
  }
  ```

  ```python Python (FastAPI) theme={null}
  import hashlib
  import hmac
  import time
  import os
  from fastapi import FastAPI, Request, HTTPException
  from fastapi.responses import Response

  app = FastAPI()

  TRUEDY_WEBHOOK_SECRET = os.environ["TRUEDY_WEBHOOK_SECRET"]
  MAX_AGE_SECONDS = 300  # 5 minutes


  def verify_truedy_webhook(
      raw_body: bytes,
      timestamp: str,
      signature: str,
      secret: str,
  ) -> bool:
      # 1. Replay protection
      try:
          ts = int(timestamp)
      except (TypeError, ValueError):
          return False

      now = int(time.time())
      if now - ts > MAX_AGE_SECONDS or ts > now + 60:
          return False

      # 2. Compute expected HMAC
      message = f"{timestamp}.{raw_body.decode('utf-8')}"
      expected = hmac.new(
          secret.encode("utf-8"),
          message.encode("utf-8"),
          hashlib.sha256,
      ).hexdigest()

      # 3. Constant-time comparison
      return hmac.compare_digest(expected, signature)


  @app.post("/webhooks/truedy")
  async def truedy_webhook(request: Request):
      raw_body = await request.body()
      timestamp = request.headers.get("x-truedy-timestamp", "")
      signature = request.headers.get("x-truedy-signature", "")

      if not verify_truedy_webhook(raw_body, timestamp, signature, TRUEDY_WEBHOOK_SECRET):
          raise HTTPException(status_code=401, detail="Invalid signature")

      event = await request.json()
      event_type = event.get("event")
      print(f"Event received: {event_type}")

      # Acknowledge quickly — return 204 before doing any heavy work
      # In production, enqueue event to a background worker here
      return Response(status_code=204)
  ```

  ```python Python (Flask) theme={null}
  import hashlib
  import hmac
  import time
  import os
  import json
  from flask import Flask, request, Response

  app = Flask(__name__)

  TRUEDY_WEBHOOK_SECRET = os.environ["TRUEDY_WEBHOOK_SECRET"]


  def verify_truedy_webhook(raw_body: bytes, timestamp: str, signature: str) -> bool:
      try:
          ts = int(timestamp)
      except (TypeError, ValueError):
          return False

      now = int(time.time())
      if now - ts > 300 or ts > now + 60:
          return False

      message = f"{timestamp}.{raw_body.decode('utf-8')}"
      expected = hmac.new(
          TRUEDY_WEBHOOK_SECRET.encode("utf-8"),
          message.encode("utf-8"),
          hashlib.sha256,
      ).hexdigest()

      return hmac.compare_digest(expected, signature)


  @app.route("/webhooks/truedy", methods=["POST"])
  def truedy_webhook():
      raw_body = request.get_data()
      timestamp = request.headers.get("X-Truedy-Timestamp", "")
      signature = request.headers.get("X-Truedy-Signature", "")

      if not verify_truedy_webhook(raw_body, timestamp, signature):
          return Response("Invalid signature", status=401)

      event = json.loads(raw_body)
      print(f"Event received: {event.get('event')}")

      return Response(status=204)
  ```
</CodeGroup>

## Where to find your webhook secret

1. Open the Truedy dashboard → **Settings → Webhooks**
2. Select (or create) your webhook endpoint
3. Copy the **Signing Secret** — it looks like `whsec_...`

Store it as an environment variable (`TRUEDY_WEBHOOK_SECRET`). Never hardcode it in source files.

## Security checklist

<Check>Always use HTTPS — never accept webhooks over plain HTTP in production</Check>
<Check>Read the raw body bytes before parsing JSON — parsers may normalize whitespace and break HMAC</Check>
<Check>Reject stale timestamps (>5 min old) to block replay attacks</Check>
<Check>Use constant-time comparison (`timingSafeEqual` / `compare_digest`) — never `===` on signatures</Check>
<Check>Return `2xx` immediately after verification — process asynchronously in a background job</Check>
<Check>Store your webhook secret in an environment variable, not in source code</Check>
<Check>Treat all payload fields as untrusted input — validate types and handle missing fields</Check>

## Troubleshooting signature failures

| Symptom                      | Likely cause                                     | Fix                                                       |
| ---------------------------- | ------------------------------------------------ | --------------------------------------------------------- |
| Signature always wrong       | Parsing JSON before reading raw bytes            | Use `express.raw()` / `request.get_data()` before parsing |
| Works locally, fails in prod | Body modified by middleware/proxy                | Ensure raw body passthrough; check load balancer config   |
| Intermittent failures        | Clock drift between servers                      | Sync server time via NTP; widen timestamp window slightly |
| Timestamp rejected           | Slow processing between receipt and verification | Move verification to the very first line of your handler  |

## Next steps

<Columns>
  <Card title="Available Webhooks" icon="list" href="/guides/available-webhooks">
    Full event catalogue and payload shapes
  </Card>

  <Card title="Error Handling & Retries" icon="rotate" href="/guides/webhook-error-handling-and-retries">
    Idempotent receiver runbook
  </Card>
</Columns>
