vatverify home
All guides

WooCommerce VAT validation: a custom plugin pattern

Add proper VAT validation to a WooCommerce EU store. Hook into woocommerce_after_checkout_validation, validate via vatverify, reject invalid B2B VAT.

TL;DR

  • A small WordPress plugin hooks woocommerce_after_checkout_validation and calls vatverify on the submitted VAT number.
  • Invalid VAT numbers get an inline WooCommerce error before the order is created.
  • A WP transients layer caches results locally for 24 hours, on top of vatverify's 30-day VIES cache.

The WooCommerce EU VAT landscape

WooCommerce has a handful of plugins aimed at EU VAT: EU VAT Assistant, YITH WooCommerce EU VAT, and a few others. They're useful for collecting VAT numbers at checkout and applying zero-rating for B2B customers. The gap is validation quality: most of them check format only, or call VIES directly (which is slow and fails during the regular VIES outages). Rolling a small custom plugin (or extending an existing one with a vatverify call) gives you live registration validation with caching, without coupling your checkout to VIES reliability. With OSS and IOSS in play for EU B2C digital sales, you also need to be precise about when reverse-charge applies (cross-border B2B) versus when you collect destination VAT (B2C). This plugin pattern focuses on the B2B side.

Why vatverify over existing WP plugins

Most existing WooCommerce VAT plugins share the same weakness: they either validate format only (EU VAT Assistant does regex + basic checksum) or they make a direct VIES SOAP call during checkout. A direct VIES call adds 1–3 seconds of latency to checkout on a good day, and blocks entirely when VIES is down (which happens regularly, especially around month-end). vatverify proxies VIES with a 30-day cache: fast, resilient, and returns structured data (company name, address, registration status) that you can store on the order.

Plugin skeleton

Create wp-content/plugins/vatverify-for-woo/vatverify-for-woo.php:

<?php
/**
 * Plugin Name: VAT Verify for WooCommerce
 * Description: Validates EU VAT numbers at checkout via vatverify API.
 * Version: 1.0.0
 * Requires at least: 6.0
 * Requires PHP: 8.1
 * WC requires at least: 8.0
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

class Vatverify_For_WooCommerce {

    public function __construct() {
        add_action( 'woocommerce_after_checkout_validation', [ $this, 'validate_vat_number' ], 10, 2 );
        add_action( 'admin_init', [ $this, 'register_settings' ] );
        add_action( 'admin_menu', [ $this, 'add_settings_page' ] );
    }

    public function register_settings(): void {
        register_setting( 'vatverify_settings', 'vatverify_api_key', [
            'type'              => 'string',
            'sanitize_callback' => 'sanitize_text_field',
        ] );
    }

    public function add_settings_page(): void {
        add_options_page(
            'VAT Verify Settings',
            'VAT Verify',
            'manage_options',
            'vatverify-settings',
            [ $this, 'render_settings_page' ]
        );
    }

    public function render_settings_page(): void {
        ?>
        <div class="wrap">
            <h1>VAT Verify Settings</h1>
            <form method="post" action="options.php">
                <?php settings_fields( 'vatverify_settings' ); ?>
                <table class="form-table">
                    <tr>
                        <th><label for="vatverify_api_key">API Key</label></th>
                        <td>
                            <input
                                type="password"
                                id="vatverify_api_key"
                                name="vatverify_api_key"
                                value="<?php echo esc_attr( get_option( 'vatverify_api_key' ) ); ?>"
                                class="regular-text"
                            />
                            <p class="description">Get your key at <a href="https://vatverify.dev" target="_blank">vatverify.dev</a>.</p>
                        </td>
                    </tr>
                </table>
                <?php submit_button(); ?>
            </form>
        </div>
        <?php
    }

    public function validate_vat_number( array $data, \WP_Error $errors ): void {
        $vat_number = isset( $data['billing_vat_number'] )
            ? sanitize_text_field( $data['billing_vat_number'] )
            : '';

        // Skip if no VAT number submitted
        if ( empty( $vat_number ) ) {
            return;
        }

        // Skip non-EU billing countries
        $billing_country = isset( $data['billing_country'] )
            ? sanitize_text_field( $data['billing_country'] )
            : '';

        $eu_countries = [
            'AT','BE','BG','CY','CZ','DE','DK','EE','ES','FI',
            'FR','GR','HR','HU','IE','IT','LT','LU','LV','MT',
            'NL','PL','PT','RO','SE','SI','SK',
        ];

        if ( ! in_array( $billing_country, $eu_countries, true ) ) {
            return;
        }

        $result = $this->call_vatverify( $vat_number );

        if ( is_wp_error( $result ) ) {
            // vatverify unreachable: fail open with a flag for manual review
            // or change to wc_add_notice( ..., 'error' ) to fail closed
            wc_get_logger()->warning(
                'vatverify unreachable: ' . $result->get_error_message(),
                [ 'source' => 'vatverify-for-woo' ]
            );
            return;
        }

        if ( ! $result['valid'] ) {
            $error_detail = implode( ', ', $result['errors'] ?? [ 'not registered' ] );
            $errors->add(
                'vat_invalid',
                sprintf(
                    /* translators: %s: error detail */
                    __( 'VAT number is not valid: %s. Please check and try again.', 'vatverify-for-woo' ),
                    esc_html( $error_detail )
                )
            );
        }
    }

    private function call_vatverify( string $vat_number ): array|\WP_Error {
        $api_key = get_option( 'vatverify_api_key', '' );
        if ( empty( $api_key ) ) {
            return new \WP_Error( 'no_api_key', 'vatverify API key not configured.' );
        }

        // Check WP transient cache first (24-hour local cache)
        $cache_key = 'vatverify_' . md5( $vat_number );
        $cached = get_transient( $cache_key );
        if ( false !== $cached ) {
            return $cached;
        }

        $response = wp_remote_get(
            add_query_arg( [ 'vat_number' => $vat_number ], 'https://api.vatverify.dev/v1/validate' ),
            [
                'timeout' => 5,
                'headers' => [
                    'Authorization' => 'Bearer ' . $api_key,
                    'Accept'        => 'application/json',
                ],
            ]
        );

        if ( is_wp_error( $response ) ) {
            return $response;
        }

        $status = wp_remote_retrieve_response_code( $response );
        $body   = json_decode( wp_remote_retrieve_body( $response ), true );

        if ( $status !== 200 || ! is_array( $body ) ) {
            return new \WP_Error( 'api_error', "vatverify returned HTTP $status" );
        }

        // Cache for 24 hours (vatverify itself caches for 30 days)
        set_transient( $cache_key, $body, DAY_IN_SECONDS );

        return $body;
    }
}

new Vatverify_For_WooCommerce();

OSS / IOSS notes

If you have OSS (One Stop Shop) registration, EU B2C digital sales still require you to collect and remit VAT at the destination country's rate. The plugin's EU-country check correctly skips validation for orders without a VAT number (B2C), letting your OSS-aware tax plugin handle those. B2B reverse-charge still applies for cross-border transactions where billing_vat_number is present and valid.

The plugin does not currently make a /v1/decide call. It validates only. If you need reverse-charge logic baked into the WooCommerce flow, extend validate_vat_number to also call https://api.vatverify.dev/v1/decide and store the mechanism as order meta.

Testing via WP-CLI / Playwright

Smoke-test the plugin with WP-CLI to trigger the hook directly:

# Install and activate
wp plugin activate vatverify-for-woo

# Set the API key (test-mode key)
wp option update vatverify_api_key vtv_test_xxx

# Verify the option was saved
wp option get vatverify_api_key

For end-to-end checkout testing with Playwright:

tests/checkout-vat.spec.ts
import { test, expect } from '@playwright/test';

test('invalid VAT shows inline error at checkout', async ({ page }) => {
  await page.goto('/checkout');
  await page.fill('#billing_vat_number', 'DE000000000');
  await page.fill('#billing_country', 'DE');
  await page.click('button[name="woocommerce_checkout_place_order"]');

  await expect(
    page.locator('.woocommerce-error li')
  ).toContainText('VAT number is not valid');
});

test('valid VAT passes checkout validation', async ({ page }) => {
  await page.goto('/checkout');
  await page.fill('#billing_vat_number', 'DE811805056');
  await page.fill('#billing_country', 'DE');
  // No VAT error should appear
  await expect(
    page.locator('.woocommerce-error')
  ).not.toBeVisible();
});

Caching

The plugin sets a 24-hour WP transient on each validated VAT number. vatverify's own VIES cache is 30 days. Within a 24-hour window, repeat checkouts by the same customer hit your local transient, and after 24 hours they hit vatverify's cache, both free and fast. The only live VIES call happens when a VAT number is seen for the first time and vatverify's cache is also cold.

// Cache key is an MD5 of the VAT number, predictable but not guessable
$cache_key = 'vatverify_' . md5( $vat_number );

// Read from transient
$cached = get_transient( $cache_key );
if ( false !== $cached ) {
    return $cached; // Cache hit: zero API calls
}

// ... call API ...

// Store for 24 hours
set_transient( $cache_key, $body, DAY_IN_SECONDS );

For high-traffic stores, you can reduce transient churn by extending to 48 hours (2 * DAY_IN_SECONDS). vatverify's 30-day cache ensures the underlying data stays accurate.

FAQ

Do I need a PHP SDK?

No. The plugin uses wp_remote_post, which is available in every WordPress installation. A vatverify PHP SDK is on the roadmap, but until then the raw HTTP approach shown above is the idiomatic WordPress way. It respects the WordPress HTTP API, timeout settings, and proxy configuration.

What about existing plugins like EU VAT Assistant?

EU VAT Assistant and similar plugins do format validation. They check the country prefix and basic structure of the VAT number. They do not verify live registration against VIES. You can combine them with vatverify: let the existing plugin handle the checkout field UI and zero-rating logic, and add a vatverify hook for the live registration check. The woocommerce_after_checkout_validation hook fires independently of what other plugins do with the field.

Will this work with Divi / Elementor checkout overrides?

Yes. The woocommerce_after_checkout_validation hook is a WooCommerce core hook that fires during order submission processing, not during UI rendering. Page builder checkout overrides change what the form looks like, not how WooCommerce processes the submitted data. As long as the VAT field has the billing_vat_number name attribute, the hook fires.

How do I store the valid-VAT flag on the order?

Add it as order meta in the same hook, after validation passes:

// Inside validate_vat_number(), after confirming $result['valid'] is true:
add_action( 'woocommerce_checkout_create_order', function( $order ) use ( $vat_number, $result ) {
    $order->update_meta_data( '_vat_number', $vat_number );
    $order->update_meta_data( '_vat_valid', '1' );
    $order->update_meta_data( '_vat_company_name', $result['company']['name'] ?? '' );
} );

For classic (non-HPOS) orders you can also use update_post_meta( $order_id, '_vat_valid', '1' ) in woocommerce_checkout_order_created.

What about High-Performance Order Storage (HPOS)?

With HPOS enabled, orders are stored in custom tables rather than wp_posts. Use $order->update_meta_data( '_vat_valid', '1' ) followed by $order->save() instead of update_post_meta. The woocommerce_checkout_create_order hook passes the order object directly, so the code above works for both classic and HPOS storage without changes.

Validate VAT in three lines.

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

Start free