Signature verification
Voxy uses one signature scheme everywhere: hex-encoded HMAC-SHA256 over the raw body. Inbound ingest uses your per-source secret; webhooks use the subscription secret. The verification code is identical — only the secret and header differ.
How signing works
The signature is hex(HMAC_SHA256(secret, rawBody)). Voxy accepts the bare hex or a sha256= prefix. Because it is keyed by a secret only the two parties know, a valid signature proves both authenticity and integrity.
Signing (you → Voxy)
When you POST inbound events, sign the exact bytes you send:
import { createHmac } from 'node:crypto';
// Sign the EXACT body you will send on the wire.
const body = JSON.stringify(payload);
const signature = createHmac('sha256', secret).update(body).digest('hex');
// Send header: x-voxy-signature: <signature> (a "sha256=" prefix is also accepted)Verifying (Voxy → you)
When Voxy delivers a webhook to you, verify it before trusting the payload. Always read the raw body — JSON.parse + re-stringify changes the bytes and breaks the hash.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyVoxySignature({
rawBody,
signatureHeader,
secret,
}: {
rawBody: Buffer;
signatureHeader: string | undefined;
secret: string;
}): boolean {
if (!signatureHeader) return false;
const received = signatureHeader.replace(/^sha256=/, '');
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(received, 'utf8');
const b = Buffer.from(expected, 'utf8');
if (a.length !== b.length) return false; // length guard before timingSafeEqual
return timingSafeEqual(a, b); // constant-time compare
}With the SDK
The official SDK exports the same helper so you never hand-roll the comparison:
import { verifyVoxySignature } from '@voxy/sdk';
app.post('/voxy/inbound', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyVoxySignature({
rawBody: req.body,
signatureHeader: req.header('x-voxy-signature'),
secret: process.env.VOXY_INBOUND_SECRET!,
});
if (!ok) return res.status(401).send('invalid signature');
res.status(200).send('ok');
});Common pitfalls
- Parsed body. Sign / verify the raw bytes, not a re-serialised object — key order and whitespace must match.
- Naïve comparison. Use
timingSafeEqual; a===check leaks timing information. - Wrong secret. Inbound and webhook secrets are distinct. Rotating a secret invalidates the old one immediately.