Webhooks & Monitoring
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 for now. Subscriptions are created and managed in the dashboard UI. Programmatic API access to create/pause/delete subscriptions is on the roadmap — let us know if you need it.
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:
{
"event": "VAT_BECAME_INVALID",
"data": {
"vatId": "DE123456789",
"countryCode": "DE",
"subscriptionId": "01HZ...",
"previousState": "valid",
"newState": "invalid"
},
"timestamp": "2026-05-19T06:03:12.847Z"
}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"
}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). - Derive the per-webhook signing key:
HMAC_SHA256(WEBHOOK_SIGNING_SECRET, webhookId)as a hex string. Your per-webhook signing key is shown once in the dashboard when you create the webhook. - 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, 16s (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 data.subscriptionId plus the event timestamp — combine them, or store the most recent X-vatnode-Signature value per subscription, to deduplicate.
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.