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.
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
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:
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:
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:
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:
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:
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
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:
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:
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:
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
AbortError — without timeout, workers block.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-typescriptto 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.
Comments
Related articles

Accountant: automating the financial analysis of your customer portfolio
An accountant manages 50 to 300 client files. Manually analysing annual accounts, computing ratios, spotting red flags and preparing a report for each yearly meeting takes hundreds of hours a year. Here is how to automate 80% of that work while raising service quality.

Calculating the credit limit of a B2B customer: 4 proven methods
How much credit exposure can you safely grant a B2B customer? Discover the four methods used by credit insurers and finance departments of Belgian SMEs: equity cap, turnover cap, asset cap and financial scoring — with worked examples.

Searching Belgian companies in natural language: the complete /recherche guide
Type "accountants in Liège", "restaurants 1000 Brussels" or a BCE number: the /recherche bar turns your sentence into NACE, postcode and name filters, then returns a page enriched with sector statistics. Tour of use cases, URL shortcuts and linguistic subtleties.
