Skip to main content
Every webhook delivery is signed with HMAC-SHA256. Verify the signature to confirm the payload came from AgentRef and wasn’t tampered with.

Signature Format

AgentRef sends three headers with each webhook delivery:
HeaderDescription
svix-idUnique message ID
svix-timestampUnix timestamp (seconds) when the message was sent
svix-signaturev1,{base64_signature}

Verification Algorithm

1

Get the raw request body

Read the body as a raw string – do not parse JSON first. Parsing and re-serializing can change whitespace or key order, breaking the signature.
2

Read the headers

Extract svix-id, svix-timestamp, and svix-signature from the request headers.
3

Compute the expected signature

Build the signed content string: {svix-id}.{svix-timestamp}.{body}Compute HMAC-SHA256 of this string using your webhook signing secret (the part after whsec_, base64url-decoded).
4

Compare signatures

Base64-encode your HMAC result and compare it to the signature value (after the v1, prefix) using constant-time comparison.

Code Examples

import crypto from 'crypto'

function verifyWebhookSignature(req, secret) {
  const msgId = req.headers['svix-id']
  const timestamp = req.headers['svix-timestamp']
  const signature = req.headers['svix-signature']

  // Check timestamp to prevent replay attacks (5 min tolerance)
  const now = Math.floor(Date.now() / 1000)
  if (Math.abs(now - parseInt(timestamp)) > 300) {
    throw new Error('Timestamp too old')
  }

  // Decode the secret (remove whsec_ prefix, base64url decode)
  const secretBytes = Buffer.from(secret.replace('whsec_', ''), 'base64url')

  // Build the signed content
  const signedContent = `${msgId}.${timestamp}.${req.body}`

  // Compute expected signature
  const expected = crypto
    .createHmac('sha256', secretBytes)
    .update(signedContent)
    .digest('base64')

  // Extract actual signature (remove v1, prefix)
  const actual = signature.split(',')[1]

  // Constant-time comparison
  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(actual))) {
    throw new Error('Invalid signature')
  }

  return JSON.parse(req.body)
}

// Express route
app.post('/webhooks/agentref', express.raw({ type: 'application/json' }), (req, res) => {
  try {
    const event = verifyWebhookSignature(req, process.env.AGENTREF_WEBHOOK_SECRET)
    console.log('Verified event:', event.type)
    res.status(200).send('OK')
  } catch (err) {
    console.error('Verification failed:', err.message)
    res.status(400).send('Invalid signature')
  }
})

Common Pitfalls

Don’t parse the body before verifying. If your framework automatically parses JSON, use a raw body parser for the webhook route. JSON.parse(JSON.stringify(body)) may reorder keys or change whitespace, producing a different signature.
  • Use express.raw() in Express (not express.json()) for the webhook route
  • In Next.js App Router, use await request.text() to get the raw body
  • Always use constant-time comparison (crypto.timingSafeEqual in Node, hmac.compare_digest in Python) to prevent timing attacks
  • Validate the timestamp – reject events older than 5 minutes to prevent replay attacks