Most VAT validation guides focus on single-number checkout validation. But teams handling ERP onboarding, migrating customer data, or running monthly compliance jobs need to validate thousands of VAT numbers at a time. Bulk validation has different requirements: rate limiting, concurrency control, partial-failure handling, and audit trail design at scale. This guide covers the three main bulk patterns with complete TypeScript examples.
Three bulk validation patterns
| Pattern | Trigger | Volume | Time constraint | Primary challenge |
|---|---|---|---|---|
| CSV one-time import | Data migration or customer import | 100–100,000 numbers | Hours acceptable | Normalization + deduplication before querying |
| Recurring batch job | Monthly re-validation, scheduled cron | All active customers | Must complete overnight | Rate limiting + partial failure recovery |
| Streaming ERP sync | Real-time ERP events (new supplier, invoice) | 1–10 per event | Seconds | Integration pattern, idempotency |
CSV import: normalizing before you validate
VAT numbers in customer data exports are rarely clean. You will find numbers with spaces, dashes, lowercase prefixes, duplicate entries, and numbers for non-EU countries mixed in. Normalize before querying — it saves API calls and catches obvious errors before they hit VIES.
const EU_PREFIXES = new Set([
'AT','BE','BG','HR','CY','CZ','DK','EE','FI','FR',
'DE','EL','HU','IE','IT','LV','LT','LU','MT','NL',
'PL','PT','RO','SK','SI','ES','SE'
]);
export function normalizeVatNumber(
raw: string
): { countryCode: string; vatNumber: string } | null {
// Remove all whitespace, dashes, dots
const cleaned = raw.toUpperCase().replace(/[\s\-\.]/g, '');
// Extract country code prefix (always 2 letters for EU)
const countryCode = cleaned.slice(0, 2);
if (!EU_PREFIXES.has(countryCode)) return null; // Not an EU VAT number
return { countryCode, vatNumber: cleaned };
}
export function deduplicateVatNumbers(
numbers: Array<{ countryCode: string; vatNumber: string }>
) {
const seen = new Set<string>();
return numbers.filter(({ vatNumber }) => {
if (seen.has(vatNumber)) return false;
seen.add(vatNumber);
return true;
});
}Calling the API in parallel with concurrency control
Sending all requests simultaneously will hit rate limits and overwhelm the VIES backend. Use a semaphore-based concurrency limiter to keep 5–10 requests in flight at once, which is the right balance between throughput and stability.
import pLimit from 'p-limit';
type BulkResult = {
vatNumber: string;
countryCode: string;
status: string;
companyName: string | null;
requestId: string | null;
error: string | null;
};
export async function bulkValidate(
numbers: Array<{ countryCode: string; vatNumber: string }>
): Promise<BulkResult[]> {
const limit = pLimit(8); // Max 8 concurrent requests
const results = await Promise.allSettled(
numbers.map(({ countryCode, vatNumber }) =>
limit(async () => {
try {
const res = await fetch(
`https://www.taxid.dev/api/v1/validate/${countryCode}/${vatNumber}`,
{
headers: { Authorization: `Bearer ${process.env.TAXID_API_KEY}` },
signal: AbortSignal.timeout(10000),
}
);
const data = await res.json();
return {
vatNumber, countryCode,
status: data.status,
companyName: data.company_name ?? null,
requestId: data.request_id ?? null,
error: null,
} as BulkResult;
} catch (err) {
return {
vatNumber, countryCode,
status: 'error',
companyName: null,
requestId: null,
error: (err as Error).message,
} as BulkResult;
}
})
)
);
return results.map((r) =>
r.status === 'fulfilled' ? r.value : ({ ...r.reason, error: r.reason.message })
);
}Handling results: status × action
| Status | Bulk import action | Re-validation action |
|---|---|---|
| active | Import with reverse_charge flag = true | No change — continue current treatment |
| invalid | Import with reverse_charge flag = false, flag for review | Change to B2C treatment, notify finance |
| inactive | Import with reverse_charge flag = false, flag for review | Change to B2C treatment, notify finance immediately |
| format_invalid | Reject row — log for manual correction | Flag for manual review |
| service_unavailable | Import as 'pending validation', retry within 24h | Skip this cycle — retry on next run |
| error | Log and skip — retry this specific number | Log and skip — retry on next run |
The monthly re-validation job architecture
export async function runMonthlyRevalidation() {
// Get all customers with active VAT treatment
const customers = await db.customer.findMany({
where: { vatTreatment: 'reverse_charge' },
select: { id: true, vatNumber: true, vatCountryCode: true },
});
const normalized = customers
.map(c => normalizeVatNumber(c.vatNumber))
.filter(Boolean) as Array<{ countryCode: string; vatNumber: string }>;
const results = await bulkValidate(normalized);
const statusChanges: string[] = [];
for (const result of results) {
if (result.error || result.status === 'service_unavailable') continue;
if (result.status !== 'active') {
const customer = customers.find(c => c.vatNumber === result.vatNumber);
if (!customer) continue;
await db.$transaction([
db.customer.update({
where: { id: customer.id },
data: { vatTreatment: 'b2c', vatStatusChangedAt: new Date() },
}),
db.vatValidation.create({
data: {
customerId: customer.id,
vatNumber: result.vatNumber,
status: result.status,
requestId: result.requestId,
source: 'monthly_revalidation',
validatedAt: new Date(),
},
}),
]);
statusChanges.push(customer.id);
}
}
// Notify finance team of all treatment changes
if (statusChanges.length > 0) {
await notifyFinanceTeam(statusChanges);
}
return { checked: customers.length, changed: statusChanges.length };
}Building the audit trail for bulk operations
In bulk validation, each individual validation still needs its own audit record — even though you ran them all in a batch. Store the request_id for every result where one was returned. For service_unavailable results, store the timestamp and schedule a retry. Your audit log should make it possible to answer: 'What was the validation status of customer X's VAT number on a specific date?' for any customer and any date.
Note
In bulk validation, service_unavailable results should not be re-queued immediately — they indicate the upstream VIES node for that country is down. Log them and retry in the next scheduled run (typically 24 hours later). Retrying immediately will return the same status and waste your quota.
Related resources
Start validating EU VAT numbers
Free plan — 100 validations/month. No credit card required.