vatverify home
All guides

VAT validation webhooks: react to deregistrations and registry-status changes

Wire up vatverify webhooks to catch B2B customer deregistrations, registry outages, and audit log events without polling. Includes signature verification and idempotency patterns.

TL;DR

  • vatverify emits webhooks for validation.completed, customer.deregistered, registry.status_changed, and a few related events.
  • Each webhook is signed with HMAC-SHA256 over the raw body. Verify the signature before processing the event.
  • Idempotency: every event has a stable id. Persist processed IDs and skip duplicates; vatverify will retry on non-2xx responses.

When webhooks earn their keep

Polling /v1/validate once a month against a customer list catches deregistrations on a delay of up to a month. Webhooks reduce that delay to minutes or hours, which matters in two cases:

  • High-value B2B: a customer with a six-figure annual order volume deregistering between monthly audits creates audit risk for any zero-rated invoice issued in the gap.
  • Registry outage handling: the upstream country registry going MS_UNAVAILABLE is something your invoicing flow may need to react to in near-real-time, for example by pausing automatic invoice issuance to avoid retry storms.

Webhooks are gated to the Pro and Business plans. Free and Starter receive plan_required on the webhook registration endpoint.

Registering an endpoint

Register your endpoint with a single POST. Each API key supports up to 5 active webhook endpoints; see webhook_limit_reached for the cap.

curl https://api.vatverify.dev/v1/webhooks \
  -H "Authorization: Bearer $VATVERIFY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://app.example.com/webhooks/vatverify",
    "events": [
      "validation.completed",
      "customer.deregistered",
      "registry.status_changed"
    ]
  }'

The response includes a signing_secret. Store it. You will need it to verify incoming requests.

{
  "data": {
    "id": "wh_01H...",
    "url": "https://app.example.com/webhooks/vatverify",
    "events": ["validation.completed", "customer.deregistered", "registry.status_changed"],
    "signing_secret": "whsec_..."
  }
}

Signature verification

Every delivery includes two headers:

  • Vatverify-Signature: an HMAC-SHA256 hex digest over the request body, keyed by your endpoint's signing secret.
  • Vatverify-Timestamp: the Unix timestamp at delivery time. Reject requests where the timestamp is more than 5 minutes off.
app/routes/webhooks.vatverify.ts
import crypto from 'node:crypto';

export async function POST(req: Request) {
  const signature = req.headers.get('vatverify-signature');
  const timestamp = req.headers.get('vatverify-timestamp');
  const body = await req.text();

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

  const skew = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (skew > 300) {
    return new Response('timestamp skew', { status: 400 });
  }

  const expected = crypto
    .createHmac('sha256', process.env.VATVERIFY_WEBHOOK_SECRET!)
    .update(`${timestamp}.${body}`)
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
    return new Response('bad signature', { status: 401 });
  }

  const event = JSON.parse(body);
  await handleEvent(event);
  return new Response();
}

The signature covers ${timestamp}.${body}, not just the body. This prevents replay attacks where an attacker re-sends an old signed payload with a fresh timestamp.

Idempotency

vatverify retries non-2xx responses on an exponential schedule for up to 24 hours. The same event.id may arrive multiple times.

The simplest idempotency layer is a small table of processed IDs:

async function handleEvent(event: { id: string; type: string; data: unknown }) {
  const seen = await db.processedWebhooks.findUnique({ where: { id: event.id } });
  if (seen) return;

  await db.$transaction(async (tx) => {
    await tx.processedWebhooks.create({ data: { id: event.id, type: event.type } });
    await routeEvent(tx, event);
  });
}

Wrap the dedup write and the actual side effect in a single transaction. Without a transaction you can crash between writing the dedup row and applying the side effect, leading to silently dropped events on the retry.

Event shapes

validation.completed

Fires when a single-number /v1/validate call completes. Useful for stores that validate from the front-end (where you don't want to wait for the round-trip) and react to results server-side.

{
  "id": "evt_...",
  "type": "validation.completed",
  "created_at": "2026-04-28T10:15:32Z",
  "data": {
    "request_id": "req_...",
    "vat_number": "DE123456789",
    "valid": true,
    "country": "DE",
    "consultation_number": "WAPIAAAA..."
  }
}

customer.deregistered

Fires when vatverify's monitor detects that a previously-valid VAT number now returns invalid. This is the high-value event for ongoing B2B compliance: it means a customer's audit-trail position changed.

{
  "id": "evt_...",
  "type": "customer.deregistered",
  "created_at": "2026-04-28T10:15:32Z",
  "data": {
    "vat_number": "FR12345678901",
    "country": "FR",
    "previous_valid_at": "2026-03-15T00:00:00Z",
    "current_invalid_at": "2026-04-28T10:15:32Z",
    "reason": "deregistered"
  }
}

The right reaction in your code is usually:

  1. Flip the customer's tax-exempt flag back to false.
  2. Notify the operations or finance team.
  3. Pause automatic zero-rated invoice generation for that customer until manually reviewed.

registry.status_changed

Fires when an upstream country registry transitions between available and degraded (typically MS_UNAVAILABLE returns from VIES, or HMRC API failures). Useful for pausing high-volume validation flows during outages.

{
  "id": "evt_...",
  "type": "registry.status_changed",
  "created_at": "2026-04-28T10:15:32Z",
  "data": {
    "registry": "VIES",
    "country": "FR",
    "previous_status": "available",
    "current_status": "degraded",
    "since": "2026-04-28T10:14:55Z"
  }
}

Local development

Test deliveries in development with a tunnel (ngrok, Cloudflare Tunnel, or vercel dev with a public URL). Register a separate webhook endpoint for the test environment using a vtv_test_ key; test-mode webhooks fire on test-mode events only and do not pollute the live event stream.

A useful trick: vatverify exposes a /v1/webhooks/{id}/replay endpoint that re-delivers the last 100 events to the registered URL. Useful when you change your handler logic and want to re-process recent events without waiting for natural traffic.

What this tutorial does not cover

  • Outbound webhook from your own app to vatverify. The webhook flow described here is vatverify pushing to you, not the other way around. Your app calls vatverify via REST when it needs validation.
  • Async batch processing. The batch endpoint (/v1/validate/batch) is synchronous. There is currently no batch-completed webhook; the batch returns its full result inline.

Validate VAT in three lines.

Free up to 500 requests per month. No credit card.

Start free