Verifying vatnode Webhook Signatures (HMAC-SHA256)
23 Oct 2026

A webhook signature proves the request was sent by someone holding your webhook’s signing key – vatnode, unless that key has leaked – and that the body wasn’t altered in transit. Getting verification right is less about the HMAC itself – that part is five lines – and more about three things people get wrong: reading the wrong bytes, skipping the timestamp check, and returning the wrong status code when verification fails. This post covers all three, with a verifier you can drop in as-is.
What’s actually in the signature
Every webhook delivery carries an X-Vatnode-Signature header:
X-Vatnode-Signature: t=1760000000,v1=3f2a9c...64 hex chars
t— the unix timestamp (seconds) at the moment vatnode signed and sent this specific attempt. It changes on every retry; the body bytes don’t.v1— a lowercase hex HMAC-SHA256, 64 characters. Only onev1value is ever sent; there’s no multi-secret or multi-version list to loop over.
The signed message is ${t}.${rawBody}, where rawBody is the exact compact JSON string vatnode wrote to the wire – JSON.stringify(payload), no pretty-printing. The HMAC key is the full signing secret returned once in the 201 response of POST /v1/webhooks, vat_wh_ prefix included, used as a plain UTF-8 string – not decoded from hex or base64. Keep it in your secret store or environment, never in code, and never log it or the raw X-Vatnode-Signature header; log rejections without either. The framework examples below refuse to start without it: a missing key would otherwise make the verifier reject every real delivery, with nothing in the logs to say why.
X-Vatnode-Event carries the event type as a convenience header, but it isn’t covered by the signature. Route on the verified body’s event field, not the header – the header is for logging and quick filtering, not for a decision that depends on the payload being authentic.
The verified body is a JSON envelope with id, version, event, data, and timestamp fields. id is stable across every retry of the same event, and identical across every webhook endpoint on your account that received it – dedupe on id, not on the HTTP request. Possible event values are VAT_BECAME_VALID, VAT_BECAME_INVALID, COMPANY_NAME_CHANGED, COMPANY_ADDRESS_CHANGED, VIES_UNAVAILABLE, and webhook.test.
If VAT_BECAME_INVALID lands for a customer you’ve already invoiced, that’s a signal to re-check before your next invoice – not something to resolve automatically from this event alone. See VAT validation and subscription renewals for that workflow.
Here’s the full verifier both the Express and Next.js examples below import. TypeScript:
import { createHmac, timingSafeEqual } from 'node:crypto'
const TOLERANCE_SECONDS = 300
export function verifyVatnodeSignature(
rawBody: string | Buffer,
header: string | undefined,
signingKey: string,
nowSeconds = Math.floor(Date.now() / 1000)
): boolean {
if (!header || !signingKey) return false
let t: number | undefined
let v1: string | undefined
for (const part of header.split(',')) {
const i = part.indexOf('=')
if (i === -1) continue
const name = part.slice(0, i).trim()
const value = part.slice(i + 1).trim()
if (name === 't') t = Number(value)
if (name === 'v1') v1 = value
}
if (!t || !Number.isInteger(t) || !v1 || !/^[0-9a-f]{64}$/.test(v1)) return false
if (Math.abs(nowSeconds - t) > TOLERANCE_SECONDS) return false
const expected = createHmac('sha256', signingKey).update(`${t}.`).update(rawBody).digest()
const received = Buffer.from(v1, 'hex')
return received.length === expected.length && timingSafeEqual(received, expected)
}
Python:
import hashlib
import hmac
import re
import time
TOLERANCE_SECONDS = 300
def verify_vatnode_signature(raw_body: bytes, header: str | None, signing_key: str, now: int | None = None) -> bool:
if not header or not signing_key:
return False
parts = {}
for part in header.split(","):
name, sep, value = part.partition("=")
if sep:
parts[name.strip()] = value.strip()
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not re.fullmatch(r"[0-9]{1,12}", t) or not re.fullmatch(r"[0-9a-f]{64}", v1):
return False
now = int(time.time()) if now is None else now
if abs(now - int(t)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(signing_key.encode("utf-8"), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
Pass it the request body as bytes, exactly as received: request.get_data() in Flask, request.body in Django, await request.body() in FastAPI or Starlette.
Both use a constant-time comparison (timingSafeEqual, hmac.compare_digest) rather than === or == on the hex strings – a plain string comparison leaks timing information about how many leading bytes matched, which is exactly the kind of detail an HMAC is supposed to deny an attacker.
The raw-body trap
The most common way to get a correct-looking implementation that still fails in production is verifying against the wrong bytes. A signature is a function of exact bytes, not of the data those bytes represent. If something in your stack parses the body into an object before your handler gets it, and you reconstruct a string from that object to verify, you risk hashing different bytes than the ones vatnode actually signed – a different serializer can add spacing JSON.stringify doesn’t, format a number differently, or otherwise produce output that means the same thing but isn’t the same bytes. The HMAC won’t match, and you’ll see a steady trickle of false rejections that look like an intermittent bug but aren’t.
The fix: capture the raw body before any parsing happens, and hash that. In Express, that means registering the webhook route with express.raw() ahead of any global express.json() middleware:
import express from 'express'
import { verifyVatnodeSignature } from './verify'
const VATNODE_WEBHOOK_SECRET = process.env.VATNODE_WEBHOOK_SECRET ?? ''
if (!VATNODE_WEBHOOK_SECRET) throw new Error('VATNODE_WEBHOOK_SECRET is not set')
const app = express()
// Register the raw-body route before any app.use(express.json()).
app.post('/webhooks/vatnode', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body as Buffer
const ok = verifyVatnodeSignature(rawBody, req.get('X-Vatnode-Signature'), VATNODE_WEBHOOK_SECRET)
if (!ok) {
res.status(400).send('invalid signature')
return
}
const event = JSON.parse(rawBody.toString('utf8')) as {
id: string
event: string
data: Record<string, unknown>
}
// record event.id and enqueue your work in one step (see dedupe below)
res.sendStatus(200)
})
app.use(express.json())
app.listen(3000)
Next.js route handlers give you the raw body by default – request.text() returns the exact string that arrived, with no parsing step in between:
import { verifyVatnodeSignature } from './verify'
const VATNODE_WEBHOOK_SECRET = process.env.VATNODE_WEBHOOK_SECRET ?? ''
if (!VATNODE_WEBHOOK_SECRET) throw new Error('VATNODE_WEBHOOK_SECRET is not set')
export async function POST(request: Request): Promise<Response> {
const rawBody = await request.text()
const ok = verifyVatnodeSignature(
rawBody,
request.headers.get('x-vatnode-signature') ?? undefined,
VATNODE_WEBHOOK_SECRET
)
if (!ok) return new Response('invalid signature', { status: 400 })
const event = JSON.parse(rawBody) as { id: string; event: string }
return new Response(null, { status: 200 })
}
Whatever the framework, verify the bytes you received, never a re-serialized copy. If something upstream parses the body before your code sees it, you need the original string preserved somewhere, not just the parsed object.
Timestamp tolerance and replay protection
vatnode does not enforce any tolerance on t itself – a signature stays mathematically valid forever unless your verifier rejects it based on age. That check turns a signature from ‘proves the sender holds the key’ into ‘proves the sender held the key recently’, and it’s entirely your responsibility.
Compare t against your own clock and reject anything too far off – the documented tolerance is 300 seconds (5 minutes). Use the absolute difference so the bound is symmetric: a delivery is accepted only within 5 minutes either side of your clock, and clock skew in either direction fails visibly instead of quietly widening the window. (t is part of the signed message, so nobody can move it without breaking the HMAC.)
if (Math.abs(nowSeconds - t) > TOLERANCE_SECONDS) return false
Each delivery attempt – including retries of the same event – is re-signed with a fresh t at send time; the body bytes stay identical across attempts, only the timestamp and signature change. That’s why dedupe has to key on the envelope’s id, not on t or on the signature value: a retried event is a legitimately different signature for the same logical event. Together, the tolerance check and id-based dedupe are what make a captured delivery unusable if it’s replayed later.
Keep seen ids for at least as long as your tolerance window (longer is fine). Record the id in the same transaction as the work it triggers, or when you enqueue that work: if you mark it seen first and then crash and return a 5xx, the retry is deduped away and the event is lost.
The 401 trap: how a verifier bug silently drops events
This is the failure mode that costs the most and gets noticed last. vatnode’s delivery model treats any 4xx response other than 429 as a permanent failure – not retried, full stop. Only 5xx, 429, and network/timeout errors get retried (up to 5 attempts, exponential backoff of 1s/2s/4s/8s between them). There’s no redelivery or replay endpoint on the API, so a verifier that incorrectly returns 401 or 400 on a signature that was actually valid doesn’t get a second chance – that event is gone.
Returning a 4xx for a genuinely invalid signature is correct – it tells vatnode not to waste retries on a request that will never verify. The trap is a verifier bug that returns the same 4xx on signatures that are actually valid: a wrong secret loaded from an env var, a proxy that re-encodes the body, a clock far enough out of sync to exceed your tolerance window. From vatnode’s side that looks identical to a deliberate rejection, so it’s never retried and nothing flags it as a bug on your end.
The same goes for redirects: a 3xx response is never followed and counts as a permanent failure, so a trailing-slash or HTTP-to-HTTPS redirect in front of your endpoint loses events the same way.
It compounds: enough consecutive definitive failures (5), or 3 days of continuous failure, and vatnode auto-disables the webhook – at that point you’re missing everything until you notice and re-enable it with a passing test.
Only return a 2xx after verification succeeds, and investigate every rejection in production rather than just logging it. A verifier that wrongly rejects valid traffic is exactly the bug a test event is supposed to catch before it touches anything real.
Testing your verifier before it goes live
Every new webhook starts in a disabled state and only becomes active once a test delivery succeeds – a 2xx response is what flips it. If you created the webhook from the dashboard, it sends that test automatically right after creation. If you created it through the API, call POST /v1/webhooks/{id}/test yourself to trigger one.
The test event (event: "webhook.test") is signed exactly like a real one – same header format, same HMAC construction, same raw-body requirement. That makes it a real end-to-end check of your verifier, not a stub: if your verifier has a bug that rejects valid signatures, the test delivery fails the same way a real one would, and the webhook stays disabled instead of silently losing production events. Catching that during setup, against a webhook nothing depends on yet, is a lot cheaper than catching it after VAT_BECAME_INVALID stopped arriving for a week.
Webhooks and VAT monitoring both need a paid plan – the free plan runs on 100 requests/month with no card required, but has no monitoring subscriptions and no webhook slots at all. See pricing for what each paid plan includes.
Rotating the signing key
There’s no rotation endpoint – a webhook’s signing key is fixed for that webhook’s entire lifetime. Rotating means creating a new webhook rather than updating an existing one:
- Create a second webhook pointing at the same URL with
POST /v1/webhooksand store the newsecretfrom the response. There’s no uniqueness constraint on URL, so two webhooks can target the same endpoint at once – you’ll need a free slot under your plan’s webhook limit. - Deploy your verifier accepting either the old or the new key, and keep deduping on the envelope’s
id– during the overlap, the same logical event can arrive twice, once signed with each key. - Call
POST /v1/webhooks/{id}/testfor the new webhook. It staysdisableduntil that test passes, so do this only after step 2 is live. - Once the new webhook is
active, delete the old one.
Deliveries already queued for retry at the moment you delete the old webhook were signed with the key snapshot taken at fan-out time, so a few old-key deliveries can still land afterward. Retries normally finish within a few minutes, but a queued delivery can be delayed further if delivery is backed up. That’s expected: keep the old key in your accepted set for a generous window – an hour is plenty – unless the key leaked.
If you’re rotating because the key leaked, skip the overlap: delete the old webhook and remove the old key from your verifier immediately. The few retries still in flight will be rejected, which is the price of not accepting forged requests.
Managing more than one webhook – which rotation requires, even briefly – needs the API; the dashboard’s webhook UI manages a single webhook. The management endpoints (/v1/webhooks*) accept either a dashboard session or a live API key; a test key gets 403 TEST_KEY_NOT_ALLOWED, because webhooks deliver real HTTP callbacks to a real endpoint, not fixtures.
FAQ
Why does my webhook endpoint need the raw request body, not the parsed JSON?
Because the signature covers exact bytes, not parsed data. vatnode signs ${t}.${rawBody}, where rawBody is the compact JSON string it sent on the wire. If you reconstruct that string yourself – especially with a different serializer, such as re-encoding in a language whose default JSON writer inserts different spacing – you can produce different bytes than the ones that were actually signed, and the signature will never match even though the payload means the same thing. Verify against the exact body you received, not a round-tripped copy.
What timestamp tolerance should I use when verifying a vatnode webhook signature?
vatnode does not enforce a tolerance itself – that check is entirely up to your verifier. Reject the request when the absolute difference between the current time and the header’s t exceeds 300 seconds (5 minutes). Use absolute difference, not just whether t is in the past, so the window is bounded in both directions and clock skew can’t stretch it.
If my verifier wrongly rejects a valid signature, will vatnode retry the event?
No – a 4xx response other than 429 is treated as a permanent failure and is never retried; only 5xx, 429, and network or timeout errors are. There is no redelivery or replay endpoint, so a verifier bug that returns 401 on a valid signature loses that event for good. Enough consecutive failures, or three days of continuous failure, disables the webhook entirely.
How do I rotate a webhook’s signing key without missing events?
There is no rotation endpoint – a signing key is fixed to its webhook for that webhook’s lifetime. To rotate, create a second webhook pointing at the same URL (there is no uniqueness constraint on URL) and get its new key into your verifier. Accept either key for an overlap window of about an hour, dedupe on the event id, then delete the old webhook. Already-queued retries were signed with the key snapshot at fan-out, so a few old-key deliveries can still arrive for a while after deletion – that’s expected, not a bug. If the key leaked, skip the overlap and drop the old key at once.
Next step
Full endpoint and payload reference lives in VAT monitoring; the end-to-end setup guide for monitoring alerts and webhook delivery is at VAT monitoring and webhooks. Before you wire real business logic to an event, make sure idempotency and retries on the rest of your VAT stack hold up too – see idempotency and retries when calling a VAT validation API.