Guide29 min readTaxID Team

How to Validate Company Registration Number: A Dev Guide

Learn how to validate company registration number with a resilient API. A step-by-step developer guide for EU/UK validation using Node.js & Python examples.

validate company registration numbercompany id validationtax id apivies apinode.js vat

You got asked to “just validate the company number” in checkout, onboarding, or invoicing. It sounds like a half-day task. Add a field, call an API, show a green check, ship it.

Then reality shows up. Numbers come in with spaces, prefixes, punctuation, and country-specific quirks. Some users paste a legal name that doesn't match the registry. Some submit a company registered in one place but operating somewhere else. And the upstream source you planned to trust turns out to be flaky, slow, or hard to integrate into a modern JSON-first stack.

That's why teams get stuck with brittle validation code that works in demos and breaks in production. If you need to validate a company registration number reliably, treat it as a small verification system, not a single request.

Table of Contents

The Hidden Complexity of Company Number Validation

The trap is always the same. Someone asks for “simple validation,” and the first implementation assumes there's one official source, one stable format, and one obvious answer. That assumption fails fast.

Take the United States. Company registration isn't managed through one federal register. It's decentralized across 50+ state and territory registries, so verification often means checking the right state-level database and, for public companies, the SEC's EDGAR system, as explained in OpenCorporates' analysis of why U.S. company data is hard to find. That detail changes the engineering problem completely.

If your system only validates syntax, you're not validating much. You're confirming that a string looks plausible. You're not confirming that it belongs to the correct jurisdiction or that it maps to an authoritative record.

The real problem is source selection

A company registration number isn't just an internal label. In many markets, it's the key that links a business to a government registry record. That means the same input can't be validated in isolation. You need the jurisdiction, and often the legal entity name, to make the lookup meaningful.

A lot of failed implementations skip that and collect only one field.

Practical rule: If the form only asks for “company number,” the backend is already missing context it will need later.

The same issue shows up in VAT workflows. Teams often start by treating VAT ID validation as a narrow formatting task, when in practice they need a country-aware verification flow tied to an authoritative source. If you're cleaning up input handling, a good starting point is understanding the VAT number format differences across countries.

Why home-brewed wrappers get fragile fast

The first version usually grows from a helper function into an unplanned subsystem:

  • Input parsing drifts as users submit prefixes, local formats, or copied values from PDFs.
  • Jurisdiction mismatches slip through when the number belongs to a different registry than the one you query.
  • Error handling stays shallow because upstream systems don't always tell you whether the problem is bad input, temporary downtime, or no match.
  • Downstream consumers make assumptions and treat every failure as “invalid company,” which causes false rejections.

That's the iceberg. The visible part is one text field. Underneath it are registry fragmentation, naming mismatches, normalization rules, caching, retries, audit logging, and policy decisions about what your app should do when the source is unavailable.

Developers usually discover this too late, after finance complains about bad invoice data or growth complains about blocked checkouts. At that point, the feature isn't “validation” anymore. It's a reliability problem sitting in front of revenue.

Why Simple VIES Lookups Fail in Production

Developers reach for VIES because it's official and available. That instinct makes sense. The mistake is assuming that “official” also means “production-friendly.”

It usually doesn't.

An infographic comparing the pros and cons of using simple VIES lookups for VAT validation.

The protocol is already working against you

Most modern application stacks expect predictable HTTP semantics and clean JSON. VIES pushes you toward SOAP and XML handling. That's not impossible, but it adds friction in exactly the wrong place. You end up maintaining glue code before you've even solved the business problem.

Then the response handling gets ugly. Instead of clean machine-readable states, teams often end up parsing brittle messages and mapping them into app-specific conditions. That's where wrappers become fragile. One unexpected upstream response and your “simple” validator starts throwing generic errors in checkout.

Production traffic exposes the weak points

The second problem is operational, not syntactic. Real traffic means retries, bursts, duplicate submissions, and user impatience. A direct lookup approach turns every validation into a live dependency on an external service.

That creates a bad failure mode matrix:

Problem What a naive integration does What production needs
Badly formatted number Sends request upstream anyway Reject locally before remote call
Temporary upstream outage Blocks user flow Return a distinct retryable state
Duplicate lookup Calls source again Reuse cached result when policy allows
Ambiguous error Shows generic invalid message Separate invalid, unknown, and unavailable

That distinction matters because “invalid” and “unavailable” are not the same thing. If your code treats both as hard failures, you'll reject legitimate businesses whenever the source has issues.

A validator that can't distinguish bad input from upstream failure isn't a validator. It's a denial mechanism with a green checkmark.

The maintenance burden lands on your team

Direct integrations also force your backend to own all the unpleasant bits:

  • Retry behavior when the upstream source times out
  • Circuit breaking when repeated failures start piling up
  • Caching policy for repeated lookups
  • Error normalization so your frontend doesn't need to understand upstream edge cases
  • Observability so support can tell what happened after a failed submission

Most tutorials skip that. They stop at “here's how to call the service.” That's not enough for a billing or onboarding flow.

If you're cleaning up an existing VAT validation path, this is exactly the failure pattern described in guidance on building resilience around VIES downtime. The painful part isn't making the first request. It's making the whole workflow survive when the upstream source doesn't behave.

Designing a Resilient Validation Workflow

A registration check usually fails long before the registry says "not found." The user pastes a number with the wrong country prefix. Your backend sends duplicate lookups because the form retries on refresh. The upstream source returns a timeout, and the UI tells a legitimate company it is invalid. That is how a simple validation step turns into billing friction and support work.

A reliable company registration-number validation flow looks more like a controlled pipeline than a single request. Collect the legal entity name, registration number, and jurisdiction. Normalize them before you spend a remote call. Query an authoritative source only after the local checks pass. Then map whatever comes back into one internal format your product, finance, and compliance systems can use, as described in Hyperbots' business registration verification overview.

A diagram illustrating a six-step resilient validation workflow for verifying company registration data accurately.

Start with the fields that actually matter

If the form only asks for one identifier, expect a weaker match and more manual cleanup later. A practical intake flow should collect:

  1. Jurisdiction. This determines which registry or provider response you can trust.
  2. Registration number. This is the primary lookup key.
  3. Legal entity name. This helps catch copied, stale, or mistyped identifiers.
  4. Registered address when relevant. Useful for higher-confidence matching and for downstream review.

Those fields serve different purposes. The identifier gets you to a record. The name and address help confirm the record belongs to the business the user claims to represent. If you skip them, you force the review step to do work your form could have done up front.

Separate pre-validation from authoritative verification

Keep these stages separate in code and in your response model. They answer different questions.

Local deterministic checks

Run these in the browser for fast feedback. Run them again on the server because client-side checks are only a convenience layer.

Use local validation for:

  • Required fields
  • Whitespace and punctuation normalization
  • Country or jurisdiction prefix normalization
  • Allowed character set
  • Length constraints
  • Checksum or format rules where the identifier supports them

This stage should be boring and strict. It should reject obvious junk, canonicalize valid input into one format, and produce the exact value your remote client will query. That single normalization step also improves cache hit rates, because GB123456789 and gb 123 456 789 should not become two separate lookups.

Registry-backed verification

After local checks pass, query the authoritative source or a provider that handles registry access cleanly. That step should answer a narrow question: does this identifier map to a legal entity in the claimed jurisdiction, and what official fields came back?

Return explicit states, not a boolean. At minimum, model outcomes like:

  • validated
  • not_found
  • mismatch
  • temporarily_unavailable
  • rate_limited
  • unprocessable_input

Those states drive policy. validated can pass. mismatch may need review. temporarily_unavailable should not tell the customer their company is fake. Teams get into trouble when they collapse all remote failures into one "invalid" label and then spend the next week reversing bad declines.

Every remote check should also create an audit record with the timestamp, normalized input, source used, returned status, and any matched official fields. If support cannot answer "what happened on this lookup," the system is incomplete.

Normalize once, then cache with intent

Passing raw provider payloads through your stack is lazy engineering. It spreads provider quirks into every consumer and makes migrations painful later. Build one internal schema and keep it stable even if you change providers.

A practical normalized record might include:

Field Purpose
input_jurisdiction What the user claimed
input_identifier Raw submitted value
normalized_identifier Canonical form used for lookup
entity_name_submitted What the user entered
entity_name_official What the registry returned
status Your system's normalized outcome
address_official Registry-backed address if returned
officers Officer data if available and relevant
source Registry or provider used
verified_at Audit timestamp

Once you have that schema, caching becomes manageable instead of risky. Cache positive matches for a policy-driven period. Cache short-lived failure states separately, because a timeout five minutes ago should not poison the next request for a day. Use the normalized identifier plus jurisdiction as the cache key. If name matching matters in your workflow, store the authoritative name and compare it on read instead of exploding the cache with every spelling variation the user submits.

Decide failure policy before launch

Upstream failure handling belongs in the design, not in a pager incident. Set the rules early.

Good validation systems usually include:

  • Short request timeouts so onboarding or checkout does not stall
  • Limited retries for transient network failures
  • Cached recent results when reuse fits your compliance policy
  • Manual review queues for higher-risk or unresolved cases
  • Frontend copy that distinguishes invalid input from service unavailability

There is always a trade-off here. Aggressive timeouts protect conversion but increase unknown results. Longer waits may improve match rates but frustrate users and tie up workers. Manual review improves safety but costs operations time. The right answer depends on the risk of a false accept versus a false reject.

Set that policy explicitly. Then encode it in one backend workflow instead of scattering it across form logic, API handlers, and support playbooks.

Integrating Validation with Node.js and Python

A production integration should expose one backend endpoint that your frontend can call safely. The browser sends jurisdiction, registration number, and legal name. Your server validates the payload, queries your validation provider, normalizes the result, logs it, and returns a clean JSON response your UI can understand.

That's the shape. The exact provider client is an implementation detail.

A modern laptop on a wooden desk showing Node.js and Python code for validating company registration inputs.

If you want a broader reference for provider-facing patterns, request handling, and response design, this developer guide to VAT API integration is useful background.

A practical Node.js endpoint

This example uses Express and axios. The key point isn't the library. It's the separation of concerns.

import express from "express";
import axios from "axios";

const app = express();
app.use(express.json());

function normalizeInput({ jurisdiction, registrationNumber, legalName }) {
  return {
    jurisdiction: String(jurisdiction || "").trim().toUpperCase(),
    registrationNumber: String(registrationNumber || "").trim(),
    legalName: String(legalName || "").trim()
  };
}

function validateLocally(input) {
  const errors = [];

  if (!input.jurisdiction) errors.push("jurisdiction_required");
  if (!input.registrationNumber) errors.push("registration_number_required");
  if (!input.legalName) errors.push("legal_name_required");

  return errors;
}

function normalizeProviderResponse(data, input) {
  return {
    inputJurisdiction: input.jurisdiction,
    inputRegistrationNumber: input.registrationNumber,
    inputLegalName: input.legalName,
    status: data.status || "unknown",
    officialName: data.companyName || null,
    officialAddress: data.companyAddress || null,
    source: data.source || "registry_provider",
    verifiedAt: new Date().toISOString()
  };
}

app.post("/api/validate-company", async (req, res) => {
  const input = normalizeInput(req.body);
  const localErrors = validateLocally(input);

  if (localErrors.length) {
    return res.status(400).json({
      ok: false,
      status: "unprocessable_input",
      errors: localErrors
    });
  }

  try {
    const response = await axios.post(
      "https://provider.example.com/validate-company",
      {
        jurisdiction: input.jurisdiction,
        registrationNumber: input.registrationNumber,
        legalName: input.legalName
      },
      {
        headers: {
          Authorization: `Bearer ${process.env.COMPANY_VALIDATION_API_KEY}`,
          "Content-Type": "application/json"
        },
        timeout: 3000
      }
    );

    const result = normalizeProviderResponse(response.data, input);

    return res.json({
      ok: true,
      result
    });
  } catch (err) {
    if (err.code === "ECONNABORTED") {
      return res.status(503).json({
        ok: false,
        status: "temporarily_unavailable"
      });
    }

    if (err.response) {
      return res.status(err.response.status).json({
        ok: false,
        status: err.response.data?.status || "provider_error"
      });
    }

    return res.status(500).json({
      ok: false,
      status: "internal_error"
    });
  }
});

app.listen(3000);

A few things matter here:

  • The browser never sees your API key.
  • Local validation happens before the provider call.
  • The provider response gets mapped into your schema, not leaked raw.
  • Timeouts create a distinct unavailable state.

A practical Python endpoint

Here's the same idea in Flask with requests.

import os
from datetime import datetime, timezone

import requests
from flask import Flask, jsonify, request

app = Flask(__name__)

def normalize_input(payload):
    return {
        "jurisdiction": str(payload.get("jurisdiction", "")).strip().upper(),
        "registrationNumber": str(payload.get("registrationNumber", "")).strip(),
        "legalName": str(payload.get("legalName", "")).strip(),
    }

def validate_locally(data):
    errors = []

    if not data["jurisdiction"]:
        errors.append("jurisdiction_required")
    if not data["registrationNumber"]:
        errors.append("registration_number_required")
    if not data["legalName"]:
        errors.append("legal_name_required")

    return errors

def normalize_provider_response(data, input_data):
    return {
        "inputJurisdiction": input_data["jurisdiction"],
        "inputRegistrationNumber": input_data["registrationNumber"],
        "inputLegalName": input_data["legalName"],
        "status": data.get("status", "unknown"),
        "officialName": data.get("companyName"),
        "officialAddress": data.get("companyAddress"),
        "source": data.get("source", "registry_provider"),
        "verifiedAt": datetime.now(timezone.utc).isoformat()
    }

@app.post("/api/validate-company")
def validate_company():
    payload = request.get_json(silent=True) or {}
    input_data = normalize_input(payload)
    errors = validate_locally(input_data)

    if errors:
        return jsonify({
            "ok": False,
            "status": "unprocessable_input",
            "errors": errors
        }), 400

    try:
        response = requests.post(
            "https://provider.example.com/validate-company",
            json={
                "jurisdiction": input_data["jurisdiction"],
                "registrationNumber": input_data["registrationNumber"],
                "legalName": input_data["legalName"]
            },
            headers={
                "Authorization": f"Bearer {os.environ['COMPANY_VALIDATION_API_KEY']}"
            },
            timeout=3
        )

        response.raise_for_status()
        result = normalize_provider_response(response.json(), input_data)

        return jsonify({
            "ok": True,
            "result": result
        })

    except requests.Timeout:
        return jsonify({
            "ok": False,
            "status": "temporarily_unavailable"
        }), 503

    except requests.HTTPError:
        provider_status = "provider_error"
        try:
            provider_status = response.json().get("status", provider_status)
        except Exception:
            pass

        return jsonify({
            "ok": False,
            "status": provider_status
        }), response.status_code

    except Exception:
        return jsonify({
            "ok": False,
            "status": "internal_error"
        }), 500

What the handler should return

The frontend doesn't need the provider's full payload. It needs a stable contract it can branch on. Keep it small and explicit.

A good response pattern looks like this:

  • Success with match returns normalized official data and a verification timestamp.
  • Failure from bad input returns field-level errors.
  • Temporary source problems return a retryable status and let the UI decide whether to continue, defer, or route to manual review.

That design scales better than embedding provider semantics all over your app. If you swap vendors later, your UI and internal services won't need a rewrite.

Handling Edge Cases and Upstream Failures

The limitations of hobby code become evident. Production traffic forces every ambiguity into the open. Users retry. Queues replay jobs. Providers slow down. Upstream registries disappear for a while and come back with no warning.

If your validation path is in billing, onboarding, or supplier approval, failure handling isn't optional system polish. It's the core behavior.

A digital interface display showing a complex API validation and resilience architecture diagram in a server room.

Treat invalid input and service failure differently

This sounds obvious, but many systems still collapse everything into one red badge. That's wrong operationally and wrong for users.

Use separate categories:

  • Invalid means the identifier failed deterministic checks or didn't match an authoritative record.
  • Unavailable means your system couldn't complete verification because the source or provider failed.
  • Inconclusive means the lookup returned something that needs review, such as name mismatch or partial data.

Those states should drive different product behavior. Invalid input can block submission. Temporary unavailability often shouldn't.

When the source is down, your app should say “we couldn't verify right now,” not “your company number is invalid.”

Cache with intent, not by accident

Caching helps in two ways. It cuts latency for repeat lookups, and it reduces your dependency on real-time source availability.

The mistake is throwing a generic cache in front of the endpoint without a policy. Validation caches need rules:

Cache decision Recommended approach
Cache key Use normalized jurisdiction plus normalized identifier
Stored value Cache normalized result, source, and verification timestamp
Use on retry Safe for repeated submissions of the same entity under your policy
Negative results Store carefully and for shorter periods if your risk policy requires caution
Auditability Keep the original verification timestamp, not just cache insertion time

A cached result is still a verification artifact. If finance or compliance asks when the number was checked, “sometime recently” isn't good enough.

Know what a valid result does not prove

A valid registration number proves less than many teams assume. It does not prove beneficial ownership, operating address legitimacy, or that the entity matches the counterparty on the invoice. Higher-risk use cases need more than existence checks and should cross-reference other signals such as address consistency and ownership data, as noted in Clustdoc's guide to checking whether a company is legitimate.

That distinction matters in fraud workflows. A registry match can tell you the entity exists. It can't tell you the person submitting the form is authorized to act for it, or that the invoice sender is that company.

For lower-risk flows, registration validation may be enough to reduce typo-driven errors and improve invoice quality. For higher-risk flows, treat it as one signal inside a wider KYB process.

A practical failure policy

If you want one production-ready rule set, use this:

  • Local format failure. Reject immediately and show a precise error.
  • Registry mismatch. Mark invalid and ask the user to correct the data.
  • Upstream timeout or outage. Return retryable status. Don't automatically relabel as invalid.
  • Repeat lookup for same entity. Prefer a recent cached result when your policy allows it.
  • High-risk onboarding. Escalate to review if validation is unavailable or if returned data conflicts with submitted data.

That's what separates a resilient validation system from a wrapper script with a dashboard attached.

Putting It All Together for Invoicing and Compliance

A finance team closes the month, then spends two days cleaning invoices because supplier records don't match registry data, VAT treatment was applied from unverified input, and duplicate entities slipped in under slightly different names. That failure usually starts upstream, at validation.

A good registration check should feed the rest of the system. Verified entity data should populate invoice records, vendor profiles, and tax logic from a single normalized result instead of whatever a user typed into a form. That cuts down on manual corrections, prevents avoidable tax handling mistakes, and gives compliance teams a traceable record of what was checked, when it was checked, and what the registry returned.

The practical design is straightforward. Treat validation as a shared service, not a helper function buried inside checkout or onboarding code. Store canonical fields, keep the raw response for auditability, cache successful lookups under a clear freshness policy, and return explicit states such as valid, invalid, retryable, and review_required. Those states matter because invoicing and compliance do not make the same decision from the same result. Billing may proceed on a recent cached success. High-risk onboarding may stop and wait for a fresh confirmation.

That is the part many teams miss.

The hard problems are not the first lookup. They are repeated submissions, name mismatches caused by local abbreviations, registry outages during peak onboarding, and the question of whether to block invoice creation when an upstream service times out. Systems that handle those cases cleanly save more operational time than systems that only optimize the happy path.

If you need to validate a company registration number, build the workflow around failure handling first, then plug it into invoicing and compliance. The API call is one step. The production job is deciding what the business should do when that step returns stale data, conflicting data, or no data at all.

If you're tired of maintaining fragile VAT and company-number validation code, TaxID gives you a cleaner path. It wraps registry-backed validation behind a developer-friendly REST API, returns structured JSON instead of brittle SOAP responses, and adds practical reliability features like format checks, caching, and standardized error codes. It's a good fit for SaaS billing, B2B checkout, and compliance workflows where validation has to keep working when upstream systems don't.

AG
Alberto García

Founder, TaxID

Building EU VAT validation tools for developers. Obsessed with compliance automation and developer experience.