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.

Tech Stack

Frontend

Next.js 15React 19Tailwind CSSZustandTanStack QueryMDX

Backend

Hono 4Drizzle ORMZodBetter AuthBullMQPinoStripe SDK

Database

PostgreSQL 16Redis 7ioredis

Infrastructure

Turborepo + pnpmVercel (frontend)Vultr VPS + DockerCaddyCloudflare

Integrations

EU VIES SOAP APIStripe BillingResend (planned)

Key Results

  • ~10,200 lines TypeScript — fully typed codebase
  • 11 DB tables — normalized schema with Drizzle ORM
  • 15-60 min caching — 95% reduction in VIES load
  • 30 req/min rate limit — Redis sliding window protection

The Challenge

EU VIES — the official VAT number verification service — has an unstable SOAP API without caching, no API keys, and frequent country-specific downtimes. B2B SaaS companies must either write their own wrappers or pay $50-200/month for third-party services (vatstack, vatlayer) with limited functionality.

Companies need:

  1. Fast, cached VAT validation with fallback handling
  2. Continuous monitoring of important VAT numbers
  3. Instant notifications when something changes
  4. API access with predictable pricing

The Solution

I built a developer-first REST API 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), 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 stored as SHA-256 hashes, shown to 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).

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.

Iurii RoguliaAvailable

Need something similar?

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

View all projects