Iurii RoguliaIurii Rogulia
AboutServicesPricingProjectsStackReviewsPhrasesBlog
Contact
Iuriiย ships.

Iurii Rogulia, IT partner for business & fractional CTO. Professionally building software since 2001.

Think of a number
PricingQuality checklistPrivacy PolicyCookie Policy

Business

TMI Iurii Rogulia
VAT ID: FI29845875
DUNS: 368664211
Lappeenranta, Finland ๐Ÿ‡ซ๐Ÿ‡ฎ

[email protected]
Back to projects

Iuriiย Validates: vatnode โ€” EU VAT Validation API

January 19, 2026

Developer-first SaaS API for EU VAT validation via VIES with Redis caching, change monitoring, and webhook notifications.

Live demo

Stack

Next.jsReactTypeScriptHonoNode.jsTurborepoDockerCaddy

Libraries

Drizzle ORMBullMQZodBetter AuthTanStack QueryZustand

Databases

PostgreSQLRedis

Services

StripeResendGoogle Analytics

Topics

SaaSAPIAuthFintechTax/VATMonorepoWebhooksSEOSchema.org

Key Results

  • Confirm an EU customer's VAT number in under 100ms instead of 2โ€“5 seconds
  • Self-service: customers get an API key, usage stats and Stripe billing without contacting anyone
  • Automatic alerts when a monitored VAT number's status changes
  • Runs on โ‚ฌ0/month bootstrap infrastructure, keeping pricing competitive
vatnode โ€” EU VAT Validation API

The Business Problem

Any company that sells to other businesses across the EU has to check whether its customers' VAT numbers are valid. That single check decides whether an invoice carries VAT or is zero-rated โ€” and getting it wrong turns into an accounting and audit problem later.

The official EU service that answers this question (VIES) is slow, offers no caching or accounts, and goes down country-by-country without warning. So companies are left with two bad options: build and maintain their own integration, or pay $50โ€“200/month for a third-party service that still leaves gaps โ€” no way to keep watching a customer's VAT number for changes, and no clean self-service billing.

The need is simple to state: check a VAT number fast and reliably, keep watching the ones that matter, get told the moment something changes, and pay a predictable price.

The Solution

I built vatnode, a service that answers "is this EU VAT number valid?" through one clean request instead of the awkward official interface. A cached result comes back in under 100ms rather than the 2โ€“5 seconds VIES takes, and when the official service is down the system degrades gracefully instead of failing.

Beyond one-off checks, customers can subscribe to the VAT numbers that matter to them and get a notification the moment a status changes โ€” useful for keeping a customer or supplier list compliant over time. Everything is self-service: a customer signs up, gets an API key, sees their usage, and manages billing through Stripe without ever contacting me. The whole platform runs on bootstrap-grade infrastructure costing effectively nothing per month, which is what keeps the pricing competitive against the incumbents.

Results

The platform is ~90% MVP complete:

MetricValue
Codebase~10,200 lines TypeScript
Database11 normalized tables
Cache hit rateUp to 95% (15-60 min TTL)
Rate limiting30 req/min sliding window
Pricing tiersFree (20/mo) โ†’ Enterprise (unlimited)
Infrastructure costโ‚ฌ0/month (bootstrap optimized)

The monorepo architecture (Turborepo) keeps API, frontend, and shared code in sync while allowing independent deployment: Vercel for Next.js, Vultr VPS with Docker Compose for Hono API + workers.

Technical SEO

vatnode competes with established VAT-validation SaaS players (vatstack, vatlayer) in a niche where developers find vendors through search. The marketing site and docs needed to rank for technical queries like "EU VAT validation API", "VIES API wrapper", and country-specific variations โ€” so technical SEO was treated as part of the product, not a marketing layer bolted on top:

  • Server-rendered marketing and docs pages via Next.js App Router โ€” full HTML reaches Googlebot, including code samples and API reference tables
  • JSON-LD schema for SoftwareApplication, Organization, FAQPage, and BreadcrumbList โ€” what an API SaaS actually needs to surface rich results
  • Per-page dynamic OG images for the blog, docs, and pricing pages โ€” link previews on LinkedIn, X, and developer Slack workspaces show the actual page content
  • llms.txt at the domain root pointing AI assistants at the docs, pricing, and quickstart โ€” the developer audience increasingly discovers tools through ChatGPT and Perplexity
  • Sitemap auto-generated from the docs and pricing data, submitted via IndexNow on every deploy so new endpoint documentation reaches Bing within minutes
  • Core Web Vitals passing on mobile and desktop โ€” minimal third-party scripts, optimized fonts, no client-side data fetching on the public marketing pages

Under the Hood

For the technically curious, here is how the core pieces are built. The API is a developer-first REST interface with modern DX:

  • Single endpoint GET /v1/vat/:vatId instead of SOAP XML
  • Smart caching โ€” valid VAT 15 min, invalid 5 min, latency drops from 2-5s to <100ms
  • Graceful degradation โ€” in-memory fallback when Redis unavailable
  • Monitoring โ€” VAT change subscriptions with webhook notifications
  • Self-service dashboard โ€” API keys, usage stats, billing via Stripe Customer Portal
  • Cost optimization โ€” Vercel (free) + Vultr VPS ($6/mo) instead of managed services

Lightweight VIES SOAP Client

Instead of heavy SOAP libraries (~500KB), a custom client with pure fetch and manual XML parsing:

// apps/api/src/services/vies.ts
function buildSoapEnvelope(countryCode: string, vatNumber: string): string {
  return `<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
               xmlns:tns1="urn:ec.europa.eu:taxud:vies:services:checkVat:types">
  <soap:Body>
    <tns1:checkVat>
      <tns1:countryCode>${countryCode}</tns1:countryCode>
      <tns1:vatNumber>${vatNumber}</tns1:vatNumber>
    </tns1:checkVat>
  </soap:Body>
</soap:Envelope>`;
}
 
export async function checkVat(countryCode: string, vatNumber: string): Promise<ViesResponse> {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), 10000);
 
  const response = await fetch(VIES_ENDPOINT, {
    method: "POST",
    headers: { "Content-Type": "text/xml; charset=utf-8", SOAPAction: "" },
    body: buildSoapEnvelope(countryCode, vatNumber),
    signal: controller.signal,
  });
 
  clearTimeout(timeoutId);
  return parseViesResponse(await response.text());
}

Result: 0KB bundle (native fetch), timeout handling, typed errors.

Sliding Window Rate Limiter with Redis + Fallback

Rate limiting via Redis Sorted Sets for distributed environments with automatic in-memory fallback:

// apps/api/src/middleware/rateLimit.ts
async function getRateLimitInfo(key: string, config: RateLimitConfig): Promise<RateLimitInfo> {
  const now = Date.now()
  const windowStart = now - config.windowMs
 
  try {
    const redis = getRedis()
    const redisKey = `ratelimit:${config.keyPrefix}:${key}`
 
    // Sliding window: remove old entries, count current
    await redis.zremrangebyscore(redisKey, 0, windowStart)
    const count = await redis.zcard(redisKey)
 
    if (count < config.maxRequests) {
      await redis.zadd(redisKey, now, `${now}-${Math.random()}`)
      await redis.expire(redisKey, Math.ceil(config.windowMs / 1000))
    }
 
    return { remaining: Math.max(0, config.maxRequests - count - 1), ... }
  } catch {
    // Fallback to in-memory Map
    return getFromMemoryStore(key, config)
  }
}

Result: Distributed rate limiting, graceful degradation, standard X-RateLimit-* headers.

Secure API Key Management

API keys are stored as SHA-256 hashes, shown to the user only once on creation:

// apps/api/src/services/apiKeys.ts
export async function generateApiKey(userId: string, label: string, env: "live" | "test") {
  const randomPart = randomBytes(32).toString("base64url");
  const prefix = env === "live" ? "vat_live_" : "vat_test_";
  const fullKey = `${prefix}${randomPart}`;
 
  // Hash for storage, save hint for UI
  const keyHash = createHash("sha256").update(fullKey).digest("hex");
  const keyHint = randomPart.slice(-4);
 
  await db.insert(apiKeys).values({ userId, label, keyHash, keyPrefix: prefix, keyHint, env });
 
  return { key: fullKey, hint: keyHint }; // fullKey shown only here
}
 
export async function validateApiKey(key: string): Promise<ValidatedApiKey | null> {
  const keyHash = createHash("sha256").update(key).digest("hex");
  const [apiKey] = await db.select().from(apiKeys).where(eq(apiKeys.keyHash, keyHash));
 
  if (!apiKey || apiKey.revokedAt) return null;
 
  // Fire-and-forget: update lastUsedAt
  db.update(apiKeys)
    .set({ lastUsedAt: new Date() })
    .where(eq(apiKeys.id, apiKey.id))
    .catch(() => {});
 
  return { id: apiKey.id, userId: apiKey.userId, environment: apiKey.environment };
}

Result: Secure storage (only hashes in DB), O(1) validation, audit via lastUsedAt.

Type-Safe Database Schema with Drizzle ORM

Declarative schema with full typing, cascading deletes, and optimized indexes:

// apps/api/src/db/schema.ts
export const apiKeys = pgTable(
  "api_keys",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    userId: text("user_id")
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    label: varchar("label", { length: 100 }).notNull(),
    keyHash: text("key_hash").notNull(),
    keyPrefix: varchar("key_prefix", { length: 20 }).notNull(),
    keyHint: varchar("key_hint", { length: 8 }).notNull(),
    environment: varchar("environment", { length: 10 }).default("live").notNull(),
    lastUsedAt: timestamp("last_used_at"),
    createdAt: timestamp("created_at").defaultNow().notNull(),
    revokedAt: timestamp("revoked_at"),
  },
  (table) => [
    index("api_keys_user_id_idx").on(table.userId),
    uniqueIndex("api_keys_key_hash_idx").on(table.keyHash),
  ]
);
 
// Automatic TypeScript types
export type ApiKey = typeof apiKeys.$inferSelect;
export type NewApiKey = typeof apiKeys.$inferInsert;

Result: Zero-runtime type checking, SQL-like DX, ~50KB bundle (vs Prisma ~2MB).

Project Review

โ€œ

We picked vatnode for our B2B billing flow and asked Iurii to help us integrate it properly. He wired up the validation calls in our checkout, set up the webhook for VAT status changes, and walked us through the edge cases โ€” what to do when a customer's VAT registration is suspended mid-contract, how to handle VIES outages without blocking the order. The whole integration was production-ready in a few days. Two months in, the webhook has already flagged a customer whose VAT got suspended, which would have meant invoices we couldn't legally issue. Solid work.

Mฤrtiล†ลก Liepa ๐Ÿ‡ฑ๐Ÿ‡ป

CTO

Services

Stripe

Topics

Tax/VATB2BAPIWebhooks
Iurii RoguliaAvailable

Need something similar?

I build custom solutions โ€” from APIs to full products. Let's talk about your project.

View all projects

Related projects

HTPBE? โ€” Has This PDF Been Edited?
HTPBE? โ€” Has This PDF Been Edited?
September 25, 2024
HTPBE? โ€” Has This PDF Been Edited?

SaaS platform for PDF authenticity verification with a public REST API.

Stack

Next.jsReactTypeScript

Libraries

Drizzle ORMZodNextAuth.jspdf-lib

Databases

PostgreSQL

Services

MollieResendGoogle Analytics

Topics

SaaSPDFAPIAuthSecuritySEOSchema.org
Pikkuna โ€” E-commerce for Vinyl Curtains & PVC Products
Pikkuna โ€” E-commerce for Vinyl Curtains & PVC Products
October 12, 2024
Pikkuna โ€” E-commerce for Vinyl Curtains & PVC Products

International e-commerce platform with 30 locales, product configurators, AI chatbot, and fully automated order flow: Stripe โ†’ Zoho CRM โ†’ Airtable โ†’ Mailgun โ†’

Stack

Next.jsReactTypeScript

Libraries

next-intlZodpdf-libPuppeteerStripe.js

Databases

Redis

Services

StripeZohoMailgunPostNordNetvisorVercelVercel BlobGoogle AnalyticsMeta CAPIAirtableUpstash

Topics

E-commercePaymentsShippingPDFPWACTOArchitectureSEOSchema.orgi18nPerformance

Related posts

Next.js SaaS Checklist: Launch Production-Ready in 8 Weeks
January 19, 2026ยท 17 min
Next.js SaaS Checklist: Launch Production-Ready in 8 Weeks

40+ point production SaaS checklist: auth, Stripe billing, PostgreSQL, rate limiting, email, monitoring, and security โ€” with honest 8-week build estimates.

Stack

Next.jsTypeScript

Libraries

Drizzle ORMBetter AuthBullMQZodReact Email

Databases

PostgreSQLRedis

Services

StripeVercelResendSentry

Topics

SaaSArchitectureAuth
Turborepo Monorepo: Next.js and Hono in One Repo With Shared Types
August 4, 2025ยท 13 min
Turborepo Monorepo: Next.js and Hono in One Repo With Shared Types

Turborepo monorepo with Next.js and Hono, sharing one set of TypeScript types across frontend and backend.

Stack

Next.jsTypeScriptHonoTurborepoNode.jsDocker

Libraries

Drizzle ORMZod

Databases

PostgreSQLRedis

Services

Vercel

Topics

ArchitectureMonorepoSaaS