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:

Webhook payload
{
  "event": "VAT_BECAME_INVALID",
  "data": {
    "vatId": "DE123456789",
    "countryCode": "DE",
    "subscriptionId": "01HZ...",
    "previousState": "valid",
    "newState": "invalid"
  },
  "timestamp": "2026-05-19T06:03:12.847Z"
}

Request headers:

HeaderValue
X-vatnode-Signaturet=<unix-ts>,v1=<hex-hmac>
X-vatnode-EventThe event type (see below)
Content-Typeapplication/json
User-Agentvatnode-Webhook/1.0

Event types

VAT_BECAME_VALID

The monitored VAT ID was previously invalid in VIES and is now valid.

VAT_BECAME_VALID — data
{
  "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.

VAT_BECAME_INVALID — data
{
  "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.

COMPANY_NAME_CHANGED — data
{
  "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.

COMPANY_ADDRESS_CHANGED — data
{
  "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.

VIES_UNAVAILABLE — data
{
  "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:

  1. Parse t (Unix timestamp in seconds) and v1 (hex HMAC).
  2. 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.
  3. Compute HMAC_SHA256(signingKey, "{t}.{rawBody}") as a hex string and compare with v1 in constant time.
  4. Reject deliveries where t is more than ~5 minutes from your server clock to mitigate replay attacks.
Node.js verification
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)
}
Python verification
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.
  • 4xx responses (except 429) are treated as permanent failures and are not retried — fix your endpoint and the next event will deliver normally.
  • 5xx and 429 trigger 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.