Skip to main content

HMAC Verification

Every webhook delivery is signed with HMAC-SHA256 over the timestamp and the raw body. Verify the signature before trusting the payload — anyone could POST to your endpoint, but only Voka can sign it correctly.

Headers we send

HeaderValue
X-Voka-TimestampUnix timestamp in seconds (10-digit integer string, e.g. "1747000000") — Stripe/Slack convention
X-Voka-Signature-256Hex-encoded HMAC-SHA256
X-Voka-EventEvent name for fast routing
note

The header X-Voka-Timestamp is Unix seconds (a bare numeric string). This is not the same format as the timestamp field inside the JSON body — that one is ISO-8601. The HMAC is computed against the header value, so always use that one for signature checks.

Signature base string

${X-Voka-Timestamp}.${rawBody}

The dot is literal. rawBody is the untouched request body — do NOT re-serialize the parsed JSON before hashing, or whitespace differences will break the signature check.

Replay protection

Reject any delivery where X-Voka-Timestamp differs from your server clock by more than 5 minutes (300 seconds). This is the standard window used by GitHub, Stripe, and the marketplace platforms — it's tight enough to defeat replay attacks while generous enough to survive minor clock skew.

Node.js (Express / Next.js)

import crypto from 'node:crypto';

export function verifyVokaSignature(req, secret) {
const timestamp = req.headers['x-voka-timestamp'];
const signature = req.headers['x-voka-signature-256'];

if (!timestamp || !signature) {
throw new Error('Missing Voka signature headers');
}

// Replay protection: reject deliveries older than 5 minutes.
// X-Voka-Timestamp is Unix SECONDS (10-digit string). new Date() on a
// bare numeric string returns Invalid Date — parse explicitly.
const tsSeconds = parseInt(timestamp, 10);
if (Number.isNaN(tsSeconds)) {
throw new Error('Malformed Voka timestamp header');
}
const ageSeconds = Math.abs(Date.now() / 1000 - tsSeconds);
if (ageSeconds > 300) {
throw new Error('Voka signature timestamp outside 5-minute window');
}

// The body must be the RAW string, not the parsed JSON.
// In Express: use express.raw({ type: 'application/json' }) on this route.
// In Next.js: read the request body as text BEFORE parsing it as JSON.
const rawBody = req.rawBody.toString('utf8');
const baseString = `${timestamp}.${rawBody}`;

const expected = crypto
.createHmac('sha256', secret)
.update(baseString)
.digest('hex');

// Constant-time comparison defeats timing-attack signature forgery
const provided = Buffer.from(signature, 'hex');
const computed = Buffer.from(expected, 'hex');
if (provided.length !== computed.length || !crypto.timingSafeEqual(provided, computed)) {
throw new Error('Voka signature mismatch');
}

return JSON.parse(rawBody); // Safe to parse + use now
}

Next.js App Router route

// app/api/webhooks/voka/route.ts
import { NextRequest } from 'next/server';
import crypto from 'node:crypto';

export async function POST(request: NextRequest) {
const rawBody = await request.text(); // RAW
const timestamp = request.headers.get('x-voka-timestamp');
const signature = request.headers.get('x-voka-signature-256');
const secret = process.env.VOKA_WEBHOOK_SECRET!;

if (!timestamp || !signature) {
return new Response('Missing signature headers', { status: 400 });
}

// X-Voka-Timestamp is Unix SECONDS — parse as integer, not via new Date()
const tsSeconds = parseInt(timestamp, 10);
if (Number.isNaN(tsSeconds)) {
return new Response('Malformed Voka timestamp', { status: 400 });
}
const ageSeconds = Math.abs(Date.now() / 1000 - tsSeconds);
if (ageSeconds > 300) {
return new Response('Timestamp outside replay window', { status: 400 });
}

const expected = crypto.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const provided = Buffer.from(signature, 'hex');
const computed = Buffer.from(expected, 'hex');
if (provided.length !== computed.length || !crypto.timingSafeEqual(provided, computed)) {
return new Response('Bad signature', { status: 401 });
}

const event = JSON.parse(rawBody);
// ...handle event...
return Response.json({ received: true });
}

Python (Flask / FastAPI)

import hashlib
import hmac
import time

def verify_voka_signature(headers, raw_body: bytes, secret: str) -> dict:
timestamp = headers.get('X-Voka-Timestamp')
signature = headers.get('X-Voka-Signature-256')
if not timestamp or not signature:
raise ValueError('Missing Voka signature headers')

# Replay protection: reject older than 5 minutes.
# X-Voka-Timestamp is Unix SECONDS (10-digit integer string) — parse
# directly with int(); don't pass it to datetime.fromisoformat().
try:
sent_at_unix = int(timestamp)
except ValueError as e:
raise ValueError('Malformed Voka timestamp header') from e
age = abs(time.time() - sent_at_unix)
if age > 300:
raise ValueError('Timestamp outside 5-minute replay window')

base_string = f"{timestamp}.{raw_body.decode('utf-8')}"
expected = hmac.new(
secret.encode('utf-8'),
base_string.encode('utf-8'),
hashlib.sha256,
).hexdigest()

if not hmac.compare_digest(expected, signature):
raise ValueError('Voka signature mismatch')

import json
return json.loads(raw_body)

FastAPI route

from fastapi import FastAPI, Request, HTTPException

app = FastAPI()

@app.post('/webhooks/voka')
async def receive_voka(request: Request):
raw_body = await request.body()
try:
event = verify_voka_signature(request.headers, raw_body, SECRET)
except ValueError as e:
raise HTTPException(status_code=401, detail=str(e))
# ...handle event...
return {'received': True}

Common mistakes

SymptomLikely cause
Signature mismatch on every eventRe-serializing JSON before hashing — must use the raw body string
Works in dev, fails in prodReverse proxy or framework rewriting the body (Cloudflare, body-parser) — use the raw bytes from before middleware
Random failuresServer clock drift — sync via NTP and confirm timestamp comparison uses UTC on both sides
Tests fail with "outside replay window"Test fixtures use stale timestamps — generate a fresh ISO timestamp at test time, or skip the time check in tests

Rotating the secret

Rotation is in Integrations → Webhooks → (your subscription) → Rotate secret in the dashboard.

Rotation invalidates the old secret immediately. To avoid downtime: deploy code that accepts both the old and new secret, rotate, then remove the old after a few minutes once you've verified deliveries work with the new one.