REST API Best Practices for Company Data Lookups
· KVKBase Team

REST API Best Practices for Company Data Lookups

Production-ready patterns for company data APIs: caching, retries, rate limiting, circuit breakers, and response normalization.

apibest-practicesarchitecture

REST API Best Practices for Company Data Lookups

Calling a company data API in development is easy. Making it reliable in production is where the real work begins. Network failures, rate limits, upstream outages, and stale caches can all degrade your user experience if you do not handle them properly.

This article covers production-tested patterns for integrating company data APIs like KVKBase, the Dutch KVK API, or the EU VIES service into your application.

1. Smart Caching

Company data changes infrequently. A company’s name, address, and KVK number are stable for months or years. This makes aggressive caching both safe and valuable.

Data typeTTLRationale
Active company profile24 hoursAddresses or names rarely change overnight
Inactive/dissolved company7 daysStatus will not change back
Search results1 hourNew companies register daily
Failed lookups (404)1 hourCompany may have just registered
VIES validation (valid)7 daysVAT status rarely changes
VIES validation (invalid)1 hourMay be temporary VIES downtime

Implementation

class CompanyDataCache {
  constructor(store) {
    this.store = store; // Redis, Memcached, or in-memory
  }

  async get(key) {
    const entry = await this.store.get(key);
    if (!entry) return null;

    const parsed = JSON.parse(entry);
    if (Date.now() > parsed.expiresAt) {
      await this.store.delete(key);
      return null;
    }

    return parsed.data;
  }

  async set(key, data, ttlSeconds) {
    const entry = {
      data,
      expiresAt: Date.now() + (ttlSeconds * 1000),
      cachedAt: Date.now()
    };

    await this.store.set(key, JSON.stringify(entry), ttlSeconds);
  }

  ttlForLookup(result) {
    if (!result) return 3600;                // Not found: 1 hour
    if (!result.isActive) return 604800;     // Inactive: 7 days
    return 86400;                            // Active: 24 hours
  }
}

Cache key design

Use structured, predictable cache keys:

function cacheKey(operation, ...parts) {
  return `company:${operation}:${parts.join(':')}`;
}

// Examples:
// company:lookup:12345678
// company:search:bol.com:limit=10
// company:vies:NL:123456789B01

2. Retry with Exponential Backoff

Transient failures happen. A retry strategy with exponential backoff handles them gracefully without overwhelming the upstream service.

async function withRetry(fn, options = {}) {
  const {
    maxRetries = 3,
    baseDelay = 1000,
    maxDelay = 30000,
    retryableErrors = [502, 503, 504, 429]
  } = options;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      const isRetryable = error.status
        ? retryableErrors.includes(error.status)
        : error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT';

      if (!isRetryable || attempt === maxRetries) {
        throw error;
      }

      const delay = Math.min(
        baseDelay * Math.pow(2, attempt) + Math.random() * 1000,
        maxDelay
      );

      // If rate limited, use Retry-After header
      if (error.status === 429 && error.retryAfter) {
        await sleep(error.retryAfter * 1000);
      } else {
        await sleep(delay);
      }
    }
  }
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

Usage

const result = await withRetry(
  () => kvkbase.lookup('12345678'),
  { maxRetries: 3, baseDelay: 1000 }
);

3. Rate Limit Handling

Most company data APIs enforce rate limits. Handling them properly means your application degrades gracefully instead of crashing.

Reading rate limit headers

class RateLimitAwareClient {
  constructor(baseUrl, apiKey) {
    this.baseUrl = baseUrl;
    this.apiKey = apiKey;
    this.remaining = Infinity;
    this.resetAt = 0;
  }

  async request(path) {
    // Proactively wait if we know we are out of quota
    if (this.remaining <= 0 && Date.now() < this.resetAt) {
      const waitTime = this.resetAt - Date.now();
      await sleep(waitTime);
    }

    const response = await fetch(`${this.baseUrl}${path}`, {
      headers: { 'Authorization': `Bearer ${this.apiKey}` }
    });

    // Update rate limit state from response headers
    this.remaining = parseInt(response.headers.get('X-RateLimit-Remaining') || 'Infinity');
    this.resetAt = parseInt(response.headers.get('X-RateLimit-Reset') || '0') * 1000;

    if (response.status === 429) {
      const retryAfter = parseInt(response.headers.get('Retry-After') || '60');
      throw { status: 429, retryAfter, message: 'Rate limited' };
    }

    if (!response.ok) {
      throw { status: response.status, message: response.statusText };
    }

    return response.json();
  }
}

Request queuing for bulk operations

When processing many lookups, use a queue to stay within rate limits:

class RequestQueue {
  constructor(requestsPerSecond = 5) {
    this.interval = 1000 / requestsPerSecond;
    this.lastRequest = 0;
    this.queue = [];
    this.processing = false;
  }

  async enqueue(fn) {
    return new Promise((resolve, reject) => {
      this.queue.push({ fn, resolve, reject });
      this.process();
    });
  }

  async process() {
    if (this.processing) return;
    this.processing = true;

    while (this.queue.length > 0) {
      const elapsed = Date.now() - this.lastRequest;
      if (elapsed < this.interval) {
        await sleep(this.interval - elapsed);
      }

      const { fn, resolve, reject } = this.queue.shift();
      this.lastRequest = Date.now();

      try {
        resolve(await fn());
      } catch (error) {
        reject(error);
      }
    }

    this.processing = false;
  }
}

4. Circuit Breaker for External Services

The VIES VAT validation service is known for intermittent outages. A circuit breaker prevents your application from wasting time on requests that will fail.

class CircuitBreaker {
  constructor(options = {}) {
    this.failureThreshold = options.failureThreshold || 5;
    this.resetTimeout = options.resetTimeout || 60000;
    this.failures = 0;
    this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
    this.lastFailure = null;
  }

  async execute(fn) {
    if (this.state === 'OPEN') {
      if (Date.now() - this.lastFailure > this.resetTimeout) {
        this.state = 'HALF_OPEN';
      } else {
        throw new Error('Circuit breaker is OPEN');
      }
    }

    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }

  onSuccess() {
    this.failures = 0;
    this.state = 'CLOSED';
  }

  onFailure() {
    this.failures++;
    this.lastFailure = Date.now();
    if (this.failures >= this.failureThreshold) {
      this.state = 'OPEN';
    }
  }
}

// Usage with VIES
const viesBreaker = new CircuitBreaker({
  failureThreshold: 3,
  resetTimeout: 120000 // 2 minutes
});

async function validateVat(countryCode, vatNumber) {
  try {
    return await viesBreaker.execute(() =>
      viesClient.validate(countryCode, vatNumber)
    );
  } catch (error) {
    if (error.message === 'Circuit breaker is OPEN') {
      return { valid: null, message: 'VAT validation temporarily unavailable' };
    }
    throw error;
  }
}

5. Response Normalization

Different company data APIs return data in different formats. Normalize responses into a consistent internal format:

function normalizeCompanyData(source, rawData) {
  switch (source) {
    case 'kvkbase':
      return {
        kvkNumber: rawData.kvkNumber,
        name: rawData.tradeName,
        legalForm: rawData.legalForm,
        address: {
          street: rawData.address?.street,
          houseNumber: rawData.address?.houseNumber,
          postalCode: rawData.address?.postalCode,
          city: rawData.address?.city,
          country: 'NL'
        },
        vatNumber: rawData.vatNumber,
        isActive: rawData.isActive,
        source: 'kvkbase',
        retrievedAt: new Date().toISOString()
      };

    case 'kvk-official':
      return {
        kvkNumber: rawData.kvkNummer,
        name: rawData.naam,
        legalForm: rawData.rechtsvorm,
        address: {
          street: rawData.straatnaam,
          houseNumber: rawData.huisnummer,
          postalCode: rawData.postcode,
          city: rawData.plaats,
          country: 'NL'
        },
        vatNumber: null, // Official KVK API does not include VAT
        isActive: rawData.actief === 'Ja',
        source: 'kvk-official',
        retrievedAt: new Date().toISOString()
      };

    default:
      throw new Error(`Unknown source: ${source}`);
  }
}

6. Timeouts and Fallbacks

Set explicit timeouts and define fallback behavior:

async function lookupWithFallback(kvkNumber) {
  // Try primary source with timeout
  try {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 5000);

    const result = await fetch(
      `https://api.kvkbase.nl/api/v1/lookup/${kvkNumber}`,
      {
        headers: { 'Authorization': `Bearer ${API_KEY}` },
        signal: controller.signal
      }
    );

    clearTimeout(timeout);
    return await result.json();
  } catch (error) {
    // Fall back to cache, even if stale
    const staleCache = await cache.getStale(kvkNumber);
    if (staleCache) {
      return { ...staleCache, _stale: true };
    }

    throw error;
  }
}

Putting It All Together

A production-grade company data client combines all these patterns:

class CompanyDataService {
  constructor(config) {
    this.client = new RateLimitAwareClient(config.baseUrl, config.apiKey);
    this.cache = new CompanyDataCache(config.cacheStore);
    this.queue = new RequestQueue(config.requestsPerSecond);
    this.viesBreaker = new CircuitBreaker({ failureThreshold: 3 });
  }

  async lookup(kvkNumber) {
    const cached = await this.cache.get(`lookup:${kvkNumber}`);
    if (cached) return cached;

    const result = await this.queue.enqueue(() =>
      withRetry(() => this.client.request(`/lookup/${kvkNumber}`))
    );

    const ttl = this.cache.ttlForLookup(result);
    await this.cache.set(`lookup:${kvkNumber}`, result, ttl);

    return result;
  }
}

These patterns are not specific to KVK data — they apply to any external API integration. But company data lookups, with their mix of stable data and unreliable upstream services, are a particularly good fit for this level of resilience engineering.