vatnode — EU VAT Validation API
Developer-first SaaS API for EU VAT validation via VIES with Redis caching, change monitoring, and webhook notifications.
Tech Stack
Frontend
Backend
Database
Infrastructure
Integrations
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:
- Fast, cached VAT validation with fallback handling
- Continuous monitoring of important VAT numbers
- Instant notifications when something changes
- API access with predictable pricing
The Solution
I built a developer-first REST API with modern DX:
- Single endpoint
GET /v1/vat/:vatIdinstead 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:
| Metric | Value |
|---|---|
| Codebase | ~10,200 lines TypeScript |
| Database | 11 normalized tables |
| Cache hit rate | Up to 95% (15-60 min TTL) |
| Rate limiting | 30 req/min sliding window |
| Pricing tiers | Free (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.
AvailableNeed something similar?
I build custom solutions — from APIs to full products. Let's talk about your project.