‘Fail open’ is shorthand that hides the interesting part. When a WooCommerce plugin calls an external API during checkout and the API is down, the plugin does not simply let everything through. In my EU VAT plugin for WooCommerce it fails open only when it cannot get an answer. A definitive ‘no’ (a malformed number, a country mismatch, a number VIES says is not registered) still blocks the order.
That distinction is the whole design. Below is the decision ladder as the code runs it, what the API has to guarantee for the ladder to be safe, and what fail-open costs the buyer and the store. It also covers a regression in an early release that taught me what ‘cannot check’ must never be allowed to mean.
The decision ladder
Every VAT number typed at checkout goes through Vatnode_Validator::evaluate(). The order of the checks matters, so here it is in the order the code runs them:
| Step | Condition | Status | Blocks checkout? |
|---|---|---|---|
| 1 | Field is empty | empty | No |
| 2 | Rate dataset unavailable | unverified | No |
| 3 | Store is not in an EU/VIES-eligible country | not_eligible | No |
| 4 | Wrong format for the country, or prefix not VIES-eligible | format_invalid | Yes |
| 5 | VAT number country differs from billing country | country_mismatch | Yes |
| 6 | VAT number country is the store’s own country | domestic | No (VAT charged) |
| 7 | Live validation not active (no API key, or switched off) | unverified | No |
| 8a | API answers INVALID_FORMAT | format_invalid | Yes |
| 8b | API call fails any other way | unverified | No (VAT charged) |
| 8c | API answers 200 with valid: false | invalid | Yes |
| 8d | API answers 200 with valid: true | valid | No (reverse charge) |
Steps 4 and 5 run before any network call. A number with the wrong shape for its country, or one that belongs to a different country than the billing address, is rejected from the plugin’s own data. That is a definitive answer and it does not depend on anyone being up.
Step 8b is the fail-open branch, and it is deliberately wide. In the code it is a single condition: if the call did not produce a usable answer, for any reason other than INVALID_FORMAT, the result is unverified, no exemption is applied, VAT is charged as usual and the order goes through. A missing key, a transport error, a 401 or 403, a 429 and any other non-200 all land there. I did not try to sort failures into ‘retry’ and ‘give up’ at this layer. The plugin has exactly one question at that point (did I get a verdict?), and the answer is yes or no.
The ladder, reduced to the one decision that matters:
// Simplified from Vatnode_Validator::evaluate()
$response = Vatnode_Api::validate( $vat_number );
if ( ! $response['ok'] ) {
if ( $response['error_code'] === 'INVALID_FORMAT' ) {
return result( FORMAT_INVALID, blocking: true ); // definitive no
}
return result( UNVERIFIED, blocking: false ); // no answer: fail open
}
return $response['valid']
? result( VALID, exempt: true ) // reverse charge
: result( INVALID, blocking: true ); // definitive noThe API contract that makes this safe
The ladder only works if ‘no answer’ and ‘a no’ arrive in different shapes. If the API reported an upstream outage as valid: false, step 8c would treat an outage as a definitive rejection and block real buyers.
This is not a hypothetical. The raw VIES service can answer valid: false for numbers that are fine when a member state’s own registry is down; I covered that in EU VAT Number Validation in Next.js and will not repeat it here. The consequence for an integration is that someone has to translate. In my EU VAT validation API, a member-state registry outage or a concurrency limit comes back as HTTP 503, a timeout as 504 (if the client waits long enough), and network or parse failures as 502. The plugin trusts that contract: anything that is not a 200 is ‘no answer’, and only a 200 carries a verdict.
If you are designing the API side of a pair like this, that is the part to get right first. A fail-open client is only as safe as the API’s refusal to turn ‘I could not find out’ into ‘no’.
What gets cached, and what does not
Verdicts are cached for 24 hours in a WordPress transient keyed on the normalised VAT number. That covers successful 200 answers, including valid: false, and INVALID_FORMAT. Failures are not cached.
A verdict is a fact about the number and stays true for a day. A failure is a fact about the moment and says nothing about the number, so caching it would extend an outage past its end. The price is that during an outage every evaluation makes a fresh outbound request.
The cost of failing open
Failing open is a trade, and the cost lands on a specific person.
If a legitimate business buyer with a valid VAT number checks out while validation is unavailable, they are charged VAT. They may be able to recover it, but they paid it first. The sale the store would have lost by blocking the checkout is kept. I think that is the right side of the trade for this domain: the alternative is a lost order caused by someone else’s infrastructure, and the cost of the error is bounded and recoverable.
Two things make that cost larger than it needs to be, and I would rather state them than hide them:
- The plugin does not re-check. I searched the code for any path that revisits an
unverifiedorder later (retry, recheck, refund) and found none. Recovering the VAT is a manual job outside the plugin. - The order record is thin on cause. Each evaluated order gets meta fields (number, status, country, a reverse-charge flag, a timestamp) and one order note: the VAT number plus the status label, for example ‘Not verified – VAT charged’. The underlying cause (quota exceeded, API unreachable, key rejected) is not in the note. It shows in the key status on the plugin’s settings screen. If you want to know why last Tuesday’s orders were unverified, the order alone will not tell you.
The wait-time trade-off
Fail-open protects the order. It does not protect the shopper’s time. This part I derived from reading the code, not from measuring it.
On the classic checkout, the place-order path calls evaluate() three times: while preparing posted data, during checkout validation, and when creating the order. The AJAX totals refresh calls it as well. The request timeout is 8 seconds. When the API is slow or unreachable at the transport level and nothing is cached, each of those calls can wait up to the timeout, so a single submit can in principle wait through three of them in a row. Fast refusals such as a 401, 403 or 429 do not have this problem. A slow upstream does: my API is willing to wait on VIES longer than the plugin is willing to wait on my API, so a slow registry reaches the plugin as a transport timeout rather than as a tidy 503 or 504. From the code, that is the likeliest way for this path to go wrong.
I have not measured this against a real outage, so treat the number as a ceiling derived from the code, not an observed delay. It also shows why ‘fail open’ is not a complete resilience strategy. It answers ‘does the order survive?’ and says nothing about ‘how long did the buyer stare at a spinner?’ A timeout is also a case of ‘unknown’ rather than ‘no’, the same principle as in idempotency keys for API retries: when you did not get an answer, you do not know the outcome, and the code should say so instead of guessing.
The regression that wrote the rule
The ladder’s step 2, the dataset check, runs before any network call and exists because of a bug in an early release.
In that release the country-pattern dataset used for offline format checks lived only in a cache. When the cache expired while the upstream source was unreachable, there were no patterns left. With no patterns, no VAT number could match its country’s format, so every number came back invalid and checkout was blocked. That was precisely the failure the plugin promised could not happen.
The fix had two parts. The dataset is now bundled with the plugin, with a last-known-good copy kept in an option and the stale cache served while a refresh is scheduled. And the validator treats ‘I have no data’ as unverified, never as invalid. The comment in the code says why: calling it invalid would block every B2B checkout for as long as the source is unreachable.
The general rule I took from it: whatever your validation function returns for ‘I could not evaluate this’ must be a different value from what it returns for ‘this is wrong’. If your function returns a boolean, you have already lost that distinction. Mine returns a status, and the blocking decision is made from the status.
When fail-open is the wrong choice
Everything above rests on one property: letting the order through is cheap and recoverable. When that is not true, the same design is a bug.
- Payments and fraud checks. If the API call decides whether a charge is legitimate, ‘no answer’ passing through is the harm itself.
- Authorisation and entitlement. Letting an unchecked request through is the failure mode you are trying to prevent.
- Anything irreversible. If you cannot correct the outcome afterwards, you cannot afford to guess.
For those, fail closed and spend the effort on making the outage short and visible. The test I use is to ask what the worst outcome of letting an unverified request through is, and who pays for it. For a B2B buyer who overpays a VAT charge they may recover, I am comfortable. For anything where the cost is a fraud loss or a data exposure, I am not.
The plugin has one more thing going for it: ‘unknown’ maps to the taxable default, not to the exemption. A failure can never grant a reverse charge, only withhold it. Where unknown would map to the permissive outcome, that is a different design.
Further reading:
- EU VAT Rates for WooCommerce – WordPress Plugin – the plugin this article is about
- vatnode.dev – EU VAT Validation API – the API behind the live validation step
- EU VAT Number Validation in Next.js (VIES API Tutorial) – why raw VIES can say ‘invalid’ during an outage
- Idempotency Keys: Building Retries That Don’t Double-Charge – treating a timeout as unknown








