Docs β€Ί Webhooks

Verifying signatures

dragonfly-signature / Svix-compatible verification.

Every message is signed so you can prove it came from Dragonfly and wasn't altered or replayed.

Headers

HeaderValue
dragonfly-idUnique message id (msg_…). Use it to deduplicate.
dragonfly-timestampUnix seconds when the message was sent.
dragonfly-signaturev1,<base64 HMAC-SHA256>, possibly several space-separated during secret rotation.

The same three values are also sent as svix-id, svix-timestamp and svix-signature. A verifier built for Nash (the Svix libraries) works unchanged.

Algorithm

  1. Build the signed content: {dragonfly-id}.{dragonfly-timestamp}.{raw request body}.
  2. Take your endpoint secret, strip the whsec_ prefix and base64-decode the rest. That is the HMAC key.
  3. Compute base64(HMAC_SHA256(key, signedContent)).
  4. Compare against each v1,-prefixed signature in the header, using a constant-time comparison.
  5. Reject the message if the timestamp is more than 5 minutes from your clock.

Node.js

javascript
import crypto from 'node:crypto'

export function verifyDragonfly(rawBody, headers, secret) {
  const id = headers['dragonfly-id'], ts = headers['dragonfly-timestamp'], sigHeader = headers['dragonfly-signature'] ?? ''
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
  const expected = crypto.createHmac('sha256', key).update(`${id}.${ts}.${rawBody}`).digest('base64')
  return sigHeader.split(' ').some((s) => {
    const [ver, sig] = s.split(',')
    return ver === 'v1' && sig && sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
  })
}

Python

python
import base64, hashlib, hmac, time

def verify_dragonfly(raw_body: bytes, headers: dict, secret: str) -> bool:
    msg_id, ts, sig_header = headers["dragonfly-id"], headers["dragonfly-timestamp"], headers.get("dragonfly-signature", "")
    if abs(time.time() - int(ts)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    expected = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + raw_body, hashlib.sha256).digest()).decode()
    return any(part.split(",", 1)[0] == "v1" and hmac.compare_digest(part.split(",", 1)[1], expected)
               for part in sig_header.split() if "," in part)

Using the Svix library

javascript
import { Webhook } from 'svix'
const wh = new Webhook(process.env.DRAGONFLY_WEBHOOK_SECRET)
const event = wh.verify(rawBody, { 'svix-id': req.headers['svix-id'], 'svix-timestamp': req.headers['svix-timestamp'], 'svix-signature': req.headers['svix-signature'] })
Important: verify against the raw body bytes. Parsing and re-serializing the JSON first changes the bytes and breaks the signature.

Something unclear or missing? Tell us.