SaaS platform for PDF authenticity verification with a public REST API.
Libraries
Databases
Services
Developer-first SaaS API for EU VAT validation via VIES with Redis caching, change monitoring, and webhook notifications.
Key Results

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.
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.
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.
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:
SoftwareApplication, Organization, FAQPage, and BreadcrumbList โ what an API SaaS actually needs to surface rich resultsllms.txt at the domain root pointing AI assistants at the docs, pricing, and quickstart โ the developer audience increasingly discovers tools through ChatGPT and PerplexityFor the technically curious, here is how the core pieces are built. The API is a developer-first REST interface with modern DX:
GET /v1/vat/:vatId instead of SOAP XMLInstead 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.
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.
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.
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).
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
AvailableNeed something similar?
I build custom solutions โ from APIs to full products. Let's talk about your project.
SaaS platform for PDF authenticity verification with a public REST API.
Libraries
Databases
Services
International e-commerce platform with 30 locales, product configurators, AI chatbot, and fully automated order flow: Stripe โ Zoho CRM โ Airtable โ Mailgun โ
40+ point production SaaS checklist: auth, Stripe billing, PostgreSQL, rate limiting, email, monitoring, and security โ with honest 8-week build estimates.
Stack
Databases
Topics
Turborepo monorepo with Next.js and Hono, sharing one set of TypeScript types across frontend and backend.
Libraries
Databases
Services
Topics