VAT Monitoring Webhooks – API Reference
VAT monitoring subscriptions check each registered VAT ID once per day. When the status, company name, or address changes – or when the VIES network was unreachable for the full day — vatnode delivers a signed HTTP POST to every webhook URL you have registered.
Dashboard-only. Subscriptions are created and managed in the dashboard UI. There is no API for creating, pausing or deleting them.
Schedule
Each subscribed VAT ID is checked once per day at 06:00 UTC. If the VIES network (or the country’s national fallback) is unavailable, vatnode automatically retries every hour through 22:00 UTC. If all retries fail, a single VIES_UNAVAILABLE webhook is delivered at 23:00 UTC so you know the daily check did not complete.
In practice this means change webhooks arrive shortly after 06:00 UTC on a normal day, and no later than 23:00 UTC on a day when VIES had an outage.
Payload Format
Every event is delivered as a JSON HTTP POST with the following shape:
{
"id": "01HZ...",
"version": "1",
"event": "VAT_BECAME_INVALID",
"data": {
"vatId": "DE123456789",
"countryCode": "DE",
"subscriptionId": "01HZ...",
"previousState": "valid",
"newState": "invalid"
},
"timestamp": "2026-05-19T06:03:12.847Z"
}id is a stable, unique identifier for the event – use it to deduplicate retries. version is the envelope schema version (currently 1), so you can safely version-handle future changes.
Request headers:
| Header | Value |
|---|---|
| X-Vatnode-Signature | t=<unix-ts>,v1=<hex-hmac> |
| X-Vatnode-Event | The event type (see below) |
| Content-Type | application/json |
| User-Agent | vatnode-Webhook/1.0 |
Event Types
VAT_BECAME_VALID
The monitored VAT ID was previously invalid in VIES and is now valid.
{
"vatId": "DE123456789",
"countryCode": "DE",
"subscriptionId": "01HZ...",
"previousState": "invalid",
"newState": "valid"
}VAT_BECAME_INVALID
The monitored VAT ID was previously valid and is now invalid – typical when a counterparty is deregistered.
{
"vatId": "DE123456789",
"countryCode": "DE",
"subscriptionId": "01HZ...",
"previousState": "valid",
"newState": "invalid"
}COMPANY_NAME_CHANGED
The company name returned by VIES for this VAT ID has changed since the last successful check. Only fires while the VAT ID is valid.
{
"vatId": "DE123456789",
"countryCode": "DE",
"subscriptionId": "01HZ...",
"previousName": "ACME GmbH",
"newName": "ACME Holdings GmbH"
}COMPANY_ADDRESS_CHANGED
The company address returned by VIES has changed since the last successful check. Only fires while the VAT ID is valid.
{
"vatId": "DE123456789",
"countryCode": "DE",
"subscriptionId": "01HZ...",
"previousAddress": "Hauptstr. 1, 10115 Berlin",
"newAddress": "Friedrichstr. 99, 10117 Berlin"
}VIES_UNAVAILABLE
Sent at 23:00 UTC only when VIES (and the country’s national fallback, if any) was unreachable for every retry attempt that day. The previous state of the VAT ID remains recorded – no validity change is implied.
{
"vatId": "DE123456789",
"countryCode": "DE",
"subscriptionId": "01HZ...",
"reason": "VIES unreachable for full day"
}webhook.test
Sent only when you click Send test for a webhook in the dashboard (and once automatically right after you create one). It is signed exactly like a real event but carries no VAT data – safe to ignore in your handler. Use it to confirm your endpoint is reachable and your signature check works.
{
"message": "This is a test event from vatnode. Your endpoint is reachable.",
"webhookId": "01HZ..."
}Signature Verification
Every delivery carries an X-Vatnode-Signature header of the form t=<unix-ts>,v1=<hex-hmac>. To verify:
- Parse
t(Unix timestamp in seconds) andv1(hex HMAC). - Use your per-webhook signing key — the full
vat_wh_…string, prefix included, shown once in the dashboard when you create the webhook. (If you lose it, delete the webhook and create a new one.) - Compute
HMAC_SHA256(signingKey, "{t}.{rawBody}")as a hex string and compare withv1in constant time. - Reject deliveries where
tis more than ~5 minutes from your server clock to mitigate replay attacks.
import { createHmac, timingSafeEqual } from 'crypto'
export function verifyVatnodeSignature(rawBody, header, signingKey) {
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.split('='))
)
const t = Number(parts.t)
const v1 = parts.v1
if (!t || !v1) return false
if (Math.abs(Date.now() / 1000 - t) > 300) return false
const expected = createHmac('sha256', signingKey)
.update(`${t}.${rawBody}`)
.digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(v1, 'hex')
return a.length === b.length && timingSafeEqual(a, b)
}import hmac, hashlib, time
def verify_vatnode_signature(raw_body: bytes, header: str, signing_key: str) -> bool:
parts = dict(kv.split('=', 1) for kv in header.split(','))
t = int(parts.get('t', 0))
v1 = parts.get('v1', '')
if not t or not v1:
return False
if abs(time.time() - t) > 300:
return False
expected = hmac.new(
signing_key.encode(),
f"{t}.{raw_body.decode()}".encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, v1)Retry Policy
vatnode considers any 2xx response a successful delivery. Non-2xx responses and network errors are retried with exponential backoff:
- Up to 5 attempts total.
- Delays between attempts: 1s, 2s, 4s, 8s (exponential, starting after the first failure).
- Each attempt has a 30 second request timeout.
4xxresponses (except429) are treated as permanent failures and are not retried – fix your endpoint and the next event will deliver normally.5xxand429trigger a retry.
Idempotency
Because deliveries can be retried, your endpoint must be idempotent. Every event carries a stable top-level id that is identical across retries of the same event – store the ids you have already processed and skip duplicates.
Endpoint Requirements
- Must be reachable on the public internet over HTTPS. We block delivery to private or loopback addresses (SSRF protection).
- Respond within 30 seconds and return a 2xx status to acknowledge.
- Verify the signature before trusting the payload.