Skip to main content

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
Never process a webhook payload without verifying its signature first. Skipping verification lets any attacker fake events to your server.

Headers sent with every request

HeaderValue
X-Truedy-TimestampUnix timestamp in seconds (when the event was dispatched)
X-Truedy-SignatureHMAC-SHA256 hex digest of the signed message
Content-Typeapplication/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

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)
  }
}

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

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

Troubleshooting signature failures

SymptomLikely causeFix
Signature always wrongParsing JSON before reading raw bytesUse express.raw() / request.get_data() before parsing
Works locally, fails in prodBody modified by middleware/proxyEnsure raw body passthrough; check load balancer config
Intermittent failuresClock drift between serversSync server time via NTP; widen timestamp window slightly
Timestamp rejectedSlow processing between receipt and verificationMove verification to the very first line of your handler

Next steps

Available Webhooks

Full event catalogue and payload shapes

Error Handling & Retries

Idempotent receiver runbook