CompanyBelgium

KBO/BCE API in Node.js and TypeScript: modern integration guide with examples

Integrate the BCE/KBO API into a modern Node.js / TypeScript stack. Full tutorial: typed client with native fetch, retry, LRU cache, Next.js and Express integration, webhook handling. All code compiles in strict mode.

May 7, 20268 min read

In brief

Integrating the BCE/KBO API in Node.js and TypeScript takes fewer than 200 lines: a typed client with native fetch, exponential retry, an LRU cache and HMAC-signed webhooks. This tutorial covers both Express and Next.js App Router, and all code compiles in strict mode on Node.js 22+.

The goal

By the end of this tutorial you will have:

  • A typed TypeScript client for the BCE/KBO API
  • An Express or Next.js Route Handler wrapper
  • A retry + LRU cache strategy
  • Webhook handling to react to changes
  • A clean error layer

The code uses Node.js 22+ and native fetch — no extra HTTP dependency.

1. Setup

Terminal
1
2
3
4
mkdir bce-node-tuto && cd bce-node-tuto
pnpm init
pnpm add typescript @types/node tsx
pnpm tsc --init

Minimal tsconfig.json:

JSON
1
2
3
4
5
6
7
8
9
10
11
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist"
  }
}
.env:
Terminal
1
2
3
COMPANY_BELGIUM_API_KEY=pk_live_xxxxxxxxxxxx
COMPANY_BELGIUM_API_SECRET=sk_live_xxxxxxxxxxxx
COMPANY_BELGIUM_BASE_URL=https://api.companybelgium.be/v1

2. The types

Create types.ts:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
export interface Address {
  street: string
  number: string
  postalCode: string
  municipality: string
  country: string
  fullAddress: string
}

export interface NaceCode {
  code: string
  label: string
  version: class="code-string">"2003" | class="code-string">"2008" | class="code-string">"2025"
}

export interface Company {
  enterpriseNumber: string
  vatNumber: string | null
  name: string
  legalForm: string
  status: class="code-string">"AC" | class="code-string">"ST"
  startDate: string
  endDate: string | null
  address: Address
  mainActivity: NaceCode | null
  establishmentsCount: number
}

export interface SearchResult {
  enterpriseNumber: string
  name: string
  status: class="code-string">"AC" | class="code-string">"ST"
  municipality: string
}

export interface ApiResponse<T> {
  success: boolean
  data: T
  error?: string
  timestamp: string
}

3. The typed client

Create bce-client.ts:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
import type { Company, SearchResult, ApiResponse } from class="code-string">"./types"

export interface BCEClientOptions {
  baseUrl?: string
  apiKey?: string
  apiSecret?: string
  timeoutMs?: number
}

export class BCEClient {
  private baseUrl: string
  private headers: Record<string, string>
  private timeoutMs: number

  constructor(opts: BCEClientOptions = {}) {
    this.baseUrl = opts.baseUrl ?? process.env.COMPANY_BELGIUM_BASE_URL!
    const key = opts.apiKey ?? process.env.COMPANY_BELGIUM_API_KEY!
    const secret = opts.apiSecret ?? process.env.COMPANY_BELGIUM_API_SECRET!
    this.headers = {
      class="code-string">"X-API-Key": key,
      class="code-string">"X-API-Secret": secret,
      class="code-string">"Accept": class="code-string">"application/json",
    }
    this.timeoutMs = opts.timeoutMs ?? 10_000
  }

  private async request<T>(path: string, init?: RequestInit): Promise<T> {
    const controller = new AbortController()
    const timer = setTimeout(() => controller.abort(), this.timeoutMs)
    try {
      const res = await fetch(class="code-string">`${this.baseUrl}${path}`, {
        ...init,
        headers: { ...this.headers, ...(init?.headers ?? {}) },
        signal: controller.signal,
      })
      if (!res.ok) {
        const body = await res.text()
        throw new BCEError(res.status, body, res.headers)
      }
      const json = (await res.json()) as ApiResponse<T>
      return json.data
    } finally {
      clearTimeout(timer)
    }
  }

  async getCompany(enterpriseNumber: string): Promise<Company> {
    const clean = enterpriseNumber.replace(/[.\s]/g, class="code-string">"")
    return this.request<Company>(class="code-string">`/companies/${clean}`)
  }

  async search(query: string, limit = 20): Promise<SearchResult[]> {
    const params = new URLSearchParams({ q: query, limit: String(limit) })
    return this.request<SearchResult[]>(class="code-string">`/companies/search?${params}`)
  }
}

export class BCEError extends Error {
  constructor(
    public status: number,
    public body: string,
    public headers: Headers,
  ) {
    super(class="code-string">`BCE API error ${status}: ${body}`)
    this.name = class="code-string">"BCEError"
  }

  get retryAfterMs(): number | null {
    const v = this.headers.get(class="code-string">"Retry-After")
    if (!v) return null
    const n = Number(v)
    return Number.isFinite(n) ? n * 1000 : null
  }
}

4. Exponential retry

retry.ts:
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { BCEError } from class="code-string">"./bce-client"

const TRANSIENT_STATUSES = new Set([429, 500, 502, 503, 504])

export async function withRetry<T>(
  fn: () => Promise<T>,
  opts: { maxAttempts?: number; baseDelayMs?: number } = {},
): Promise<T> {
  const max = opts.maxAttempts ?? 4
  const base = opts.baseDelayMs ?? 500

  let lastError: unknown
  for (let attempt = 0; attempt < max; attempt++) {
    try {
      return await fn()
    } catch (e) {
      lastError = e
      const shouldRetry =
        (e instanceof BCEError && TRANSIENT_STATUSES.has(e.status)) ||
        (e instanceof TypeError && e.message.includes(class="code-string">"fetch")) ||
        (e instanceof Error && e.name === class="code-string">"AbortError")
      if (!shouldRetry || attempt === max - 1) throw e
      const explicit = e instanceof BCEError ? e.retryAfterMs : null
      const delay = explicit ?? base * 2 ** attempt
      await new Promise((r) => setTimeout(r, delay))
    }
  }
  throw lastError
}

5. In-memory LRU cache

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
export class LRUCache<K, V> {
  private store = new Map<K, { value: V; expires: number }>()
  constructor(
    private maxSize: number,
    private ttlMs: number,
  ) {}

  get(key: K): V | undefined {
    const item = this.store.get(key)
    if (!item) return undefined
    if (item.expires < Date.now()) {
      this.store.delete(key)
      return undefined
    }
    this.store.delete(key)
    this.store.set(key, item)
    return item.value
  }

  set(key: K, value: V): void {
    if (this.store.size >= this.maxSize) {
      const oldest = this.store.keys().next().value
      if (oldest !== undefined) this.store.delete(oldest)
    }
    this.store.set(key, { value, expires: Date.now() + this.ttlMs })
  }
}

6. Express wrapper

server.ts:
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import express from class="code-string">"express"
import { BCEClient, BCEError } from class="code-string">"./bce-client"
import { withRetry } from class="code-string">"./retry"

const app = express()
const bce = new BCEClient()

app.get(class="code-string">"/companies/:number", async (req, res) => {
  try {
    const company = await withRetry(() => bce.getCompany(req.params.number))
    res.json(company)
  } catch (e) {
    if (e instanceof BCEError && e.status === 404) {
      res.status(404).json({ error: class="code-string">"Company not found" })
      return
    }
    res.status(502).json({ error: class="code-string">"Upstream error" })
  }
})

app.listen(3000, () => console.log(class="code-string">"Listening on :3000"))

7. Next.js wrapper (App Router)

app/api/companies/[number]/route.ts:
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { NextResponse } from class="code-string">"next/server"
import { BCEClient, BCEError } from class="code-string">"@/lib/bce-client"
import { withRetry } from class="code-string">"@/lib/retry"

const bce = new BCEClient()

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ number: string }> },
) {
  const { number } = await params
  try {
    const company = await withRetry(() => bce.getCompany(number))
    return NextResponse.json(company)
  } catch (e) {
    if (e instanceof BCEError && e.status === 404) {
      return NextResponse.json({ error: class="code-string">"Not found" }, { status: 404 })
    }
    return NextResponse.json({ error: class="code-string">"Upstream error" }, { status: 502 })
  }
}

8. Webhook reception

Company Belgium sends a POST to your endpoint when a company changes. Verify the signature:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { createHmac } from class="code-string">"node:crypto"

const WEBHOOK_SECRET = process.env.COMPANY_BELGIUM_WEBHOOK_SECRET!

function verifySignature(rawBody: string, signature: string): boolean {
  const expected = createHmac(class="code-string">"sha256", WEBHOOK_SECRET)
    .update(rawBody)
    .digest(class="code-string">"hex")
  return signature === expected
}

app.post(
  class="code-string">"/webhooks/bce",
  express.raw({ type: class="code-string">"application/json" }),
  (req, res) => {
    const signature = req.headers[class="code-string">"x-signature"] as string
    const rawBody = req.body.toString(class="code-string">"utf-8")
    if (!verifySignature(rawBody, signature)) {
      return res.status(401).end()
    }
    const event = JSON.parse(rawBody) as {
      type: string
      enterpriseNumber: string
      data: unknown
    }
    console.log(class="code-string">"Event received:", event.type, event.enterpriseNumber)
    res.status(200).end()
  },
)

Typical events: company.updated, company.director.added, company.ubo.changed, company.accounts.filed.

9. Mistakes to avoid

  • Creating a client per request — loses keep-alive. Module-level instance.
  • Ignoring AbortError — without timeout, workers block.
  • Handling webhooks synchronously — sender times out. Queue, then 200 immediately.
  • Not verifying signature — anyone can spam your endpoint.
  • Hardcoded keys — always via env. See API security best practices.
  • How Company Belgium speeds up your Node.js development

    On pro plans, Company Belgium provides:

    • Official TypeScript SDK (@companybelgium/sdk) with full types and built-in retry/cache
    • OpenAPI consumable by openapi-typescript to generate up-to-date types
    • Webhooks with HMAC SHA-256 signature
    • Rate-limit headers: X-RateLimit-Remaining, X-RateLimit-Reset

    Further reading: see the BCE API comparison, the endpoints reference, the Python tutorial and the rate-limit / caching architecture.

    Bottom line

    The Node.js / TypeScript integration of the BCE/KBO API fits in 200 typed lines:

    • Native fetch + strict types
    • Exponential retry
    • LRU cache
    • Express or Next.js adapter
    • Signed webhooks

    Client reuse, timeout handling, verified HMAC signature — you have a production-grade integration.

    Frequently asked questions

    How do I authenticate requests to the BCE/KBO API in Node.js?

    You send two HTTP headers: X-API-Key with your public key (pk_live_...) and X-API-Secret with your secret key (sk_live_...). These keys are available in your Company Belgium dashboard. Never expose them on the client side: store them in server-side environment variables.

    What is the best caching strategy for the BCE/KBO API in TypeScript?

    An in-memory LRU cache with a 5-minute TTL is sufficient for most use cases. BCE data rarely changes in real time. For a distributed architecture, you can use Redis as a shared cache across multiple Node.js instances. The key is to avoid calling the API on every user request.

    How do I handle 429 (rate limit) errors from the BCE/KBO API in Node.js?

    Implement exponential retry: wait 500 ms on the first retry, 1,000 ms on the second, 2,000 ms on the third. The API returns a Retry-After header when you exceed your quota; use that value first. Also check the rate limits and caching article to properly size your architecture.

    How do I receive BCE/KBO webhooks and verify their signature in Node.js?

    Company Belgium signs each webhook with HMAC SHA-256 and sends the signature in the X-Signature header. On the Node.js side, use the native crypto module with createHmac to recompute the signature from the raw request body and compare it to the received value. Always respond with 200 immediately, then process the event asynchronously.

    Ready to get started?

    Create your free account and get your API keys in minutes.

    Comments

    Loading comments…

    Leave a comment

    Not published — used only to notify you.

    Comments are moderated before publication.

    Related articles