Rajtík's blog

← Back to home

Business 19 min read

VIES audit trail in Laravel: 4 design iterations for an EU B2B SaaS

How to verify VAT IDs through VIES for EU B2B reverse charge invoicing in Laravel - an append-only audit log, a retry budget, two entry points for cron and checkout, and why a 'simple wrapper' is not enough.

As an identified person, for every EU B2B supply I verify before issuing an invoice in the reverse charge regime that the recipient is a VAT payer in another member state. Without that I could not apply reverse charge. The de facto only way is VIES - a European endpoint run by the European Commission. Sometimes slow, sometimes not working. And because this is tax data, I want to have on hand a record of what I backed the verification with and what VIES answered me at that moment.

This article is about how I built that integration for captchaapi.eu, step by step, as it dawned on me where the legislation lies and where the real state of VIES lies. I am writing this before the first live EU B2B payment flows through captchaapi - everything described I polished during development, not on live customers.

What an "identified person" is

For a reader hearing this term for the first time: an identified person is a hybrid under §§ 6g to 6l of Act no. 235/2004 Coll., on VAT (hereinafter "ZDPH"). It is not a VAT payer in the full sense - on invoices to Czech customers I do not charge VAT and therefore I do not remit VAT. But I am registered with the tax office under a Czech VAT ID for the purposes of cross-border EU supplies. Specifically, I fall into that status through § 6i ZDPH, which says that a taxable person based in Czechia who is not a payer becomes an identified person from the day they provide a service with a place of supply in another member state under § 9(1) ZDPH.

How a solo SaaS operator ends up there:

  • I sell a digital service (a CAPTCHA API).
  • I have customers in the Czech Republic (no VAT, a normal invoice).
  • I have customers in other EU states who are VAT payers there.
  • I have customers outside the EU + EU consumers without a VAT ID.

For the middle group (EU B2B) the reverse charge rule applies: for a service to a taxable person the place of supply is in the recipient's country (§ 9(1) ZDPH), so I as the provider do not charge tax - the customer declares and remits it in their country. That is an advantage. Otherwise I would have to deal with VAT registration in other jurisdictions or move to OSS. Reverse charge applies only if the recipient is really a person registered for VAT in another member state, and I arrange the verification of the VAT ID myself. When the customer's VAT ID does not exist or is no longer valid, it is effectively a B2C supply and I should handle it differently - which my accountant definitely does not want to deal with.

The third group (ROW + EU B2C) I solved in captchaapi by having Lemon Squeezy serve it as merchant of record - LS charges, collects the VAT, issues the invoice, I receive a clean payout. I do not care about VIES in that branch. This article is purely about the EU B2B branch via Stripe + my own invoicing.

What I need from a VIES verification

VIES (VAT Information Exchange System) is a European service you can ask: "does the VAT ID SK2020317540 exist and who does it belong to?" The answer is valid / invalid plus the company name and address and the so-called consultation number - a short string that is a record of the fact that I asked at that moment and what the system answered me.

VIES itself is not a "mandatory tool" in any guide. But it is the only source where the validity of a European VAT ID can be verified, so I practically have nothing to replace it with.

Why I put it into the flow at all was best summed up by my accountant: reverse charge makes sense only when the recipient is really a VAT payer in another member state. If I state on the invoice a VAT ID that was not valid at the moment of supply, the whole supply is built wrongly and I would have to straighten it out afterwards. VIES is a way to verify that validity before issuing the invoice, and the consultation number is the usual supporting record for it.

This is not a sharp time window - it is about having on hand a record that I did the verification honestly and with reasonable lead time before the supply.

That is why I take the internal cushion "the VIES proof is not older than three days" as an operational decision - it comes from how often my preliminary checks run. There is no magic boundary in it; a record 47 or 71 hours old is still a verification with reasonable lead time before the supply. I set the freshness of the record according to my own operation.

So what I practically need from a VIES verification:

  1. Verify the VAT ID before I issue an invoice in the reverse charge regime.
  2. Store a record of the verification so that it is retrievable even in five years - surviving schema migrations, server moves and application rewrites.
  3. Verify again before every further supply (renewal). A payer may in the meantime stop being a payer.

And because VIES is a not very stable web service that I also access from Hetzner in Nuremberg, I need to handle the whole thing so that an outage on their side does not mean I have a problem.

Why I did not buy it as a service

Before diving into my own integration, I looked around at paid "VAT validation" services with a bolted-on SLA. There are several of them, promising 99.9% uptime or higher, an "enterprise-grade" dashboard and an invoice you can wave in front of an auditor better than "I wrote PHP code for it myself".

The catch that shows up only after clicking through their documentation: VIES is a central European system run by the European Commission. It is not distributed, there is no authoritative output from another source. Every commercial "VAT validation API" is therefore de facto a wrapper over the VIES REST API plus maybe a light cache and a prettier response shape.

The consequence? When VIES is down, the paid service sends you 200 OK, but in the payload there will be service is currently unable to resolve VAT ID / MS_UNAVAILABLE / member state service timed out. Their SLA applies to the availability of their endpoint, not to the success of the verification. Their API is available - but it cannot answer you, because VIES is down.

For tens of dollars a month you thus get the same uncertainty as for free, just with a prettier graph. The conclusion was simple: my own integration, my own retry budget, my own audit. Operationally more expensive by my time, but at least I know exactly how it behaves.

Version 0: the naive solution

The first code I wrote looked basically like this:

public function verify(string $countryCode, string $vatId): bool
{
    return Http::get("https://ec.europa.eu/taxation_customs/vies/rest-api/ms/{$countryCode}/vat/{$vatId}")
        ->json('valid');
}

VIES offers both a legacy SOAP endpoint and a newer REST API (since 2023). I call REST - less boilerplate, better error handling. Both endpoints are equivalent in terms of the data they return.

Three lines of code: it calls VIES, takes valid from the response, returns a bool. And on we go.

But when I started running it against the real VIES (only in the dev environment, no live payments), four problems quickly surfaced that ruled out this naive version:

1. No audit. If anyone asked in a year "okay, that customer on the invoice - how do you know they were a payer in April 2026?", I would have nothing. A boolean in the memory of a process that ended long ago. No consultation number, no timestamp, nothing.

2. No retry. I saw the first VIES timeout practically right away. It was enough to call the endpoint a few times in a row and once it returns nothing. In a live checkout it would end like this: the customer completes the order, Stripe takes the money, but VIES does not answer, so my function returns false and a reverse-charge invoice is issued even to an invalid VAT ID. Without an alert on that state I would find out only long after the invoice was issued.

3. A boolean erases information. This is actually the worst of all. Http::get has two dramatically different failures: a timeout (= VIES is down, I do not know whether the VAT ID is valid) and a 200 OK with valid: false (= I know the VAT ID is not valid). The naive code returns false for both states. When VIES returns 503, my system says "the VAT ID is invalid, no reverse charge" - and that is a false negative result. I would flip the supply to B2C without any basis for it.

4. A single point of failure in the UX. When VIES answers in 18 seconds - and in testing I reached such latencies repeatedly - the customer stares for 18 seconds at a motionless "Validating…" spinner. In a live checkout, 20 seconds of waiting plus an occasional 504 is a way to lose a customer who already had a payment card in hand. That is not a theoretical scenario, that is the expected behaviour of VIES.

I solved these four problems one at a time, each with its own iteration.

Iteration 1: the audit log

The first thing I added was an append-only table vies_verifications. One row for every attempt. I persist not only 200 calls but also failures. The verdict has three values: valid, invalid, down.

The table schema looks roughly like this (simplified from the real migration):

Schema::create('vies_verifications', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->foreignId('subscription_id')
        ->nullable()->constrained()->nullOnDelete();

    $table->string('country_code', 2);
    $table->string('vat_id', 32);
    $table->string('verdict', 16);  // valid | invalid | down

    // populated only on `valid`
    $table->string('consultation_number', 32)->nullable();
    $table->dateTime('request_date')->nullable();
    $table->json('response_payload')->nullable();

    $table->timestamps();

    $table->index(['user_id', 'verdict', 'created_at']);
});

A few things in that design are deliberate:

Append-only. I never update, I never delete (except down rows older than 90 days, see below). Every attempt = one row. When something needs to be looked up in five years, I show the sequence of rows and their fingerprints.

Subscription is nullable. The first verification for a new EU B2B customer happens before a Stripe subscription even exists. When the invoice.paid webhook arrives, the sub_id is filled into the corresponding valid row. That allows keeping the checkout-time verification as an audit even if the payment never arrives.

Country code is a snapshot. If a customer changed their country a year later (legitimate, a registered seat changes), their old invoice stays backed by the original country code in the audit. No "current state" → no drift.

Retention. valid and invalid stay forever (a record for later lookup). down rows are cleaned after 90 days by a separate cron - they are purely forensic data ("why were verifications failing on Monday at 3 a.m.?"). I do not burden the table with them indefinitely.

What this step solves: the first problem from version 0 - I have a record. A second observation that paid off only later: even an attempt that ended down is a record of effort. What matters is whether I verified thoroughly, not whether it always succeeded. And I have a table from which I see every single attempt, the hour, the country and the result.

What this iteration does not solve: as soon as VIES answers down, my code still falls over on an exception. The boolean is gone, but the behaviour is not.

Iteration 2: the retry budget

The second iteration is about how to handle three-valued logic. A simple rule I set myself:

  • Valid = green, I issue the reverse-charge invoice.
  • Invalid = red, I do not retry, I block the action and tell the customer: "Your VAT ID is invalid, fix it or switch to the B2C path (Lemon Squeezy)."
  • Down = yellow, I retry, but with a reasonable limit.

Down ≠ Invalid is the key. When VIES returns 503, it must not fall into the Invalid branch. When VIES answers valid: false, it must not fall into Down (= retry).

The pseudocode of the retry loop looks like this:

public function run(User $user, string $countryCode, string $vatId): ViesVerification
{
    $lastResult = null;

    for ($attempt = 1; $attempt <= self::MAX_ATTEMPTS; $attempt++) {
        $result = $this->client->validate($countryCode, $vatId);

        $lastResult = $this->repository->persist(
            user: $user,
            countryCode: $countryCode,
            vatId: $vatId,
            result: $result,
        );

        // Valid or Invalid - a final state, no further attempts
        if ($result->verdict !== Verdict::Down) {
            return $lastResult;
        }

        // Skip the sleep on the last iteration - a trailing sleep helps nobody
        if ($attempt < self::MAX_ATTEMPTS) {
            Sleep::for(self::RETRY_INTERVAL_SECONDS)->seconds();
        }
    }

    // After exhausting attempts we return Down - the caller decides what next
    return $lastResult;
}

The constants are MAX_ATTEMPTS = 3 and RETRY_INTERVAL_SECONDS = 10, plus a 20s timeout inside $this->client->validate(). Worst case 3 × 20 + 2 × 10 = 80 s. Acceptable for a background flow.

Three important details:

1. I persist every attempt, even the one that is then retried. When the first attempt is Down and the second Valid, I have both rows in the audit. A record that I verified "thoroughly, not just once".

2. I skip the sleep on the last iteration. Without this the upper total time would be attempts × timeout + attempts × sleep, which is wasteful. A trailing sleep after the last attempt helps nobody.

3. After exhausting attempts I return Down, not an exception. The caller is to decide what to do with it - some paths have a fallback (LS), some want a customer-facing alert.

Testability. I send the sleep through the Laravel facade Sleep::for(...)->seconds(), not through the native sleep(). In a test I call Sleep::fake() and the loop does not wait. No "test mode" flag, no branching in production code - just a facade that has a test mode built in.

This iteration settles the second and third problem from version 0. What still remains: an 80 s retry is unacceptable in a flow that blocks the UX.

Iteration 3: a UX-blocking flow is a different mode

When a new customer does the first upgrade from Free to a paid plan, they stare at "Verifying VAT ID…" on the screen. Here I cannot spend 80 seconds - the customer would leave the page and go away. At the same time I need the same audit, the same three-valued logic, the same table.

The solution: two entry points into the same class, different retry profiles.

ResolveVatVerification::run(user, cc, vat)
    → sequential 3×20s + 10s sleep, ~80s budget
    → background paths (renewal cron)

ResolveVatVerification::runForCheckout(user, cc, vat)
    → Http::pool(2) in parallel, no retry budget
    → synchronous UI (Checkout wizard)

runForCheckout makes two parallel VIES requests in one round (through Http::pool). When either answers Valid / Invalid within the usual 2-3 seconds, I return the verdict. When both fail on a timeout, I return Down right away - no second round, no sleep, no waiting.

It assumes that parallel redundancy masks transient flakiness better than a second serial attempt. In dev observation that fits me: VIES has more the characteristic of "one node 503, the other OK" than "the whole service down for five minutes". Pool(2) should therefore dramatically lower the Down rate, at the cost of double the number of calls. I do not use it for cron - there an 80 s retry is fine and there is no point in saving calls needlessly.

What I do with Down in checkout. Instead of waiting longer, I show the customer a callout with three buttons:

  • Try again - just a rerun of the same action, often it should pass on the second try.
  • Save and come back later - the wizard has a persisted draft (checkout_drafts, 30-day TTL), so they can close the tab and finish the upgrade in an hour.
  • Continue through Lemon Squeezy - a compromise. When VIES keeps not working for me and the customer does not want to wait, they get to pay through LS as merchant of record. It is the price of a non-working VIES. They lose reverse charge and B2B invoicing by it, but it is the customer's conscious choice. Better than losing them completely.

This entry-point design is important: the same persistence, the same audit trail, a different retry profile. The vies_verifications table does not know whether it is called by cron or by a Livewire component. The audit is homogeneous.

What this showed me: having one class with two public methods is more sensible than having "one true retry strategy". The call sites have different tolerances, different expectations, different fallbacks. If I had unified them at all costs, I would have produced either a checkout that is too slow or a cron that is too aggressive.

Iteration 4: the renewal cron and a "fresh" record

The last piece of the puzzle is subscription renewal. An EU B2B customer pays monthly. Between the first upgrade (when the first VIES verification happens) and the first renewal invoice, 30 days pass. In that time they may:

  • Stop being a VAT payer (closed the company, became a non-payer, …).
  • Change the VAT ID (a company transfer, restructuring).
  • Change nothing - that will be 99% of cases, but not a guarantee.

Before every renewal invoice I therefore have to recheck VIES. Not at the moment of issuing the invoice (that is 0:00:01 on the first day of the month and VIES would needlessly block me at night), but with reasonable lead time.

My schedule:

  • T-3, T-2, T-1 (= 3, 2, 1 days before subscription renewal) - a nightly cron that calls ResolveVatVerification::run(...) for every active EU B2B customer. Until the first Valid on T-3, T-2 or T-1 - then it ends for that person.
  • T-0 (the day of subscription renewal) - escalation. If a Valid is still missing, it puts the customer into a VAT grace (a 14-day window in which I send them an email "Your VAT ID could not be verified, please write to me" and the Stripe subscription is switched to pause_collection.behavior = 'void' - during the pause no invoices are created, so I do not risk issuing a reverse-charge invoice to an unverified VAT ID).
  • The renewal payment - when it arrives, it reads from vies_verifications the newest row with a Valid verdict (≤ 3 days old) and attaches its consultation_number to the invoice as a supporting record of the reverse charge verification.

Here comes a difference that matters to me especially. The "≤ 3 days" boundary in the code is purely operational - it comes from how often my cron runs, not from any fixed boundary. I do not have an hourly ceiling enforced anywhere.

That three-day cushion is an operational decision, it follows from when my cron runs. It can rather be read as: "I verified several times, once every 24 hours, in the last 72 hours before the supply took place." From a control point of view that is stronger than one 12-month-old fingerprint. But it is not an absolute ceiling - if I needed to extend it to 5 or 7 days for operational reasons, nothing fundamental happens, I just have the freshness of the record slightly weaker.

I am writing this here deliberately, because I have seen several similar articles where the author made a statutory deadline for themselves out of their own cron schedule and then was afraid to move it. Compliance is here for me, not me for it.

What the design showed me

Four takeaways from this design, transferable beyond VIES.

1. A three-valued verdict > a binary bool. As soon as you have an external API whose states include "down", you need your own case for "down". Collapsed into false (= negative) or true (= optimistically assumed) it is always wrong. In the VIES context, merging Valid/Invalid would mean a wrongly built supply; in other contexts (auth, billing, healthchecks) it has its own business cost. And as the section on paid VIES services shows - even commercial wrappers only translate that "down" into prettier JSON for you.

2. The audit trail is more important than the current state. This was the least intuitive takeaway for me. As a developer I wanted one "source of truth" on the User model (vies_verified_at, vies_consultation_number). An append-only table seemed like overhead to me at first. But the truth in users.* always gets overwritten and tells me nothing about the history of the verification and how I got there. A later lookup does not want to see the last verification timestamp, it wants to see the history of records. For the same reason I never delete invalid rows - the fact that someone was invalid at some point is information in itself.

3. One client, two retry profiles. When you have the same external call at two call sites with dramatically different UX requirements (background cron vs. UI checkout), there is no point in unifying them into one retry strategy. Two public methods on the same class, shared persistence, different retry budgets. Unifying the whole verification flow for two different use cases leads to either a UX that is too slow or a job that is too aggressive.

4. A time-based operational cushion is not law. When for reliability I make an internal rule (≤ 3 days of VIES freshness, ≤ 24 hours of backup retention, ≤ 30 min p99 latency, …), it is an operational compromise, not law. It is not fixed anywhere, I cannot build on it, and above all I must not confuse my own cushion with some fixed deadline. What is important is thoroughness - that I verified honestly and repeatedly - not precision to the hour. Thoroughness is harder to define, but in the end it is what matters.

Bonus

For captchaapi.eu I use my other project billify.cz, which issues invoices for EU B2B customers and internally re-verifies VIES when issuing an invoice in the reverse charge regime. That means I have every reverse-charge supply verified twice: once in captchaapi.eu (before the upgrade or renewal), a second time in billify.cz (when issuing the invoice). The flow in billify is different - there the invoice is issued synchronously with the payment, so I cannot use a serial retry budget. And how I solved that will be the content of the next article.