Skip to content

Verify signatures

Every webhook carries an X-VoDialer-Signature header. Check it before you trust the message.

X-VoDialer-Signature: t=1791208991,v1=9c1f2ab6…
  • t is the time we signed the message, in seconds since 1970. It is fresh on every try.
  • v1 is a hex HMAC-SHA256. The key is your whole signing secret, whsec_ included. The signed text is t, a dot, then the exact body: <t>.<body>.
  • Reject the message if t is more than 5 minutes (300 seconds) from your clock, in either direction.
  • Compare the signatures in constant time.
  • Use the raw bytes of the body. If you parse the JSON and write it back, the bytes change and the check fails.
  1. Copy the signing secret when you create the webhook. See webhooks.
  2. Read the raw body before any JSON parser touches it.
  3. Run the check below. If it fails, answer 401 and do nothing else.
  4. Answer any 2xx code as soon as you have stored the message. Do slow work afterwards.
  5. Press Test on the connection to send a signed vd.test.ping and watch your check pass.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyVoDialer(
header: string,
secret: string,
rawBody: Buffer,
toleranceSeconds = 300,
): boolean {
let t: number | null = null;
const signatures: string[] = [];
for (const part of header.split(',')) {
const [key, ...rest] = part.trim().split('=');
const value = rest.join('=');
if (key === 't' && /^\d+$/.test(value)) t = Number(value);
else if (key === 'v1') signatures.push(value);
}
if (t === null || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
return signatures.some((hex) => {
const given = Buffer.from(hex, 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
});
}

With Express, take the body as bytes:

app.post('/hooks/vodialer', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.header('x-vodialer-signature') ?? '';
if (!verifyVoDialer(header, process.env.VODIALER_SECRET!, req.body)) return res.sendStatus(401);
const event = JSON.parse(req.body.toString('utf8'));
// store event.id and the X-VoDialer-Delivery header, then answer
res.sendStatus(204);
});
import hashlib
import hmac
import time
def verify_vodialer(header: str, secret: str, raw_body: bytes, tolerance: int = 300) -> bool:
t = None
signatures = []
for part in header.split(","):
key, sep, value = part.strip().partition("=")
if not sep:
continue
if key == "t" and value.isascii() and value.isdigit():
t = int(value)
elif key == "v1":
signatures.append(value)
if t is None or abs(time.time() - t) > tolerance:
return False
message = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected.encode(), s.lower().encode()) for s in signatures)

With Flask, request.get_data() returns the raw bytes.

Open the connection’s menu and choose Make a new signing secret (New signing secret in the older layout). The new secret is shown once. The old one stops working at once.

A message can arrive more than once, for example after a timeout on your side. X-VoDialer-Delivery is the same on every retry of one message, and id in the body is the event’s id. Keep one of them and skip a message you have already handled.