Company monitoring

Watch Dutch companies by KvK number and get notified when something changes: a new address, a changed name, a bankruptcy, a dissolved company. Alerts go out by email, by signed webhook, or both. You can manage everything in the dashboard or through the API.

Costs no tokens Monitoring requests are not metered. Instead, every paid plan includes a number of watch slots. Monitoring is not available on the Free plan: those requests return 403 monitoring_requires_paid_plan.

How it works

  • Each watched company is checked once a day against the same data as /v1/lookup.
  • The first check is a baseline and never raises an alert.
  • A change is reported only when the source answered with a value both times. A temporary gap in the source never shows up as a change.
  • An alert history is kept for 90 days.

Fields that can raise an alert

FieldMeaning
nameRegistered name
addressStreet, postcode and city
legalFormLegal form (BV, NV, VOF, ...)
statusactive or dissolved
insolvencynone, bankrupt or surseance
sbiCodesActivity (SBI) codes
directorsDirectors, only when the data source provides them
vatVIES validity of the known VAT number

You can limit alerts to a subset of fields in the settings. With none selected you get all of them.

Limits

PlanWatch slots
Free0 (not available)
Starter10
Pro100
Business1,000
  • A slot is one watched company, however many times it is checked.
  • If your plan drops below the number of companies you watch, the oldest ones keep running and the rest are paused, never deleted. They resume when you upgrade.
  • Bulk add takes at most 500 numbers per request.
  • At most 50 alerts per day are sent instantly by email. The rest roll into the daily digest.
  • Webhooks are sent at most once per second per account.
  • If monitoring capacity is temporarily full, adding a company returns 503 monitoring_capacity. Try again later.

Endpoints

All endpoints use your normal API key, Authorization: Bearer YOUR_API_KEY, and the usual per-minute rate limit.

EndpointDescription
GET /v1/monitor/watchesList watched companies, with limit and cursor. Returns watches (each with kvkNumber, label, name, paused, createdAt, lastCheckedAt), slots: { used, limit } and nextCursor (null on the last page).
POST /v1/monitor/watchesAdd one company (kvkNumber, optional label) or many (kvkNumbers, max 500). Both return results and slots. A bulk request answers 200 with a result per number (added, exists, invalid, over_limit or not_found). A single add instead returns an error for a failed number: 400 invalid, 404 not found, 403 monitoring_slot_limit when no slots are left. A single add that succeeds returns 201 (added) or 200 (exists).
PATCH /v1/monitor/watches/{kvk}Change the label.
DELETE /v1/monitor/watches/{kvk}Stop watching a company.
GET /v1/monitor/alertsAlert history, returned as alerts plus nextCursor. Filter with kvkNumber and since; page with limit and cursor.
GET /v1/monitor/settingsRead email, delivery, field and webhook settings. Returns emailEnabled, delivery, fields, webhookUrl, webhookConfigured, webhookDisabled and lang. The webhook secret is never returned.
PUT /v1/monitor/settingsUpdate the settings. Saving a webhook URL for the first time also returns webhookSecret, once.
POST /v1/monitor/settings/webhook-secretGenerate a new webhook secret. The response (webhookSecret) is the only time it is shown.
POST /v1/monitor/webhook/testSend a signed company.test event to your webhook URL. Answers { delivered, status, reason }: delivered is true only when your endpoint answered with a 2xx, otherwise reason says why.
bash
# Watch a company
curl -X POST https://api.kvkbase.nl/v1/monitor/watches \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"kvkNumber": "12345678", "label": "customer 1042"}'

# Watch many at once (max 500 per request)
curl -X POST https://api.kvkbase.nl/v1/monitor/watches \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"kvkNumbers": ["12345678", "87654321"]}'

Email alerts

Email goes to the address on your account, in Dutch or English depending on your dashboard language. Choose instant (one mail per detected change, up to 20 changes listed per mail) or digest (one mail a day at 07:00 UTC, grouped per company).

Webhooks

Set an HTTPS URL in the settings and KVKBase sends one POST per event. Only HTTPS on port 443 or 8443 is accepted. URLs with a literal private or loopback IP address, or a local hostname such as localhost or *.local, are rejected. Redirects are not followed.

Payload

json
{
  "id": "evt_7c1f0b6e2a",
  "type": "company.changed",
  "createdAt": "2026-10-04T05:23:11.000Z",
  "kvkNumber": "12345678",
  "name": "Acme B.V.",
  "changes": [
    { "field": "address", "from": "Herengracht 100, 1015 BS Amsterdam", "to": "Keizersgracht 5, 1015 CC Amsterdam" },
    { "field": "status", "from": "active", "to": "dissolved" }
  ]
}

type is company.changed for real changes and company.test for the test button. Each change is { field, from, to }, where from and to can be null.

Headers

HeaderValue
X-KVKBase-EventThe event type
X-KVKBase-TimestampUnix time in seconds when the request was signed
X-KVKBase-Signaturev1= followed by the hex HMAC-SHA256 of <timestamp>.<raw body> with your webhook secret

Verifying the signature

The secret starts with whsec_. It is shown once when it is created and again when you rotate it, so store it right away. Always verify against the raw request body, compare in constant time, and reject requests whose timestamp is more than a few minutes old.

javascript
import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody must be the exact bytes received, before any JSON parsing.
export function verify(rawBody, headers, secret) {
  const timestamp = headers["x-kvkbase-timestamp"];
  const received = (headers["x-kvkbase-signature"] || "").replace(/^v1=/, "");

  // Reject old requests so a captured one cannot be replayed.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const expected = createHmac("sha256", secret)
    .update(timestamp + "." + rawBody)
    .digest("hex");

  return (
    received.length === expected.length &&
    timingSafeEqual(Buffer.from(received), Buffer.from(expected))
  );
}
php
<?php
$secret    = getenv('KVKBASE_WEBHOOK_SECRET');
$rawBody   = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_KVKBASE_TIMESTAMP'] ?? '';
$received  = preg_replace('/^v1=/', '', $_SERVER['HTTP_X_KVKBASE_SIGNATURE'] ?? '');

$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

if (abs(time() - (int) $timestamp) > 300 || !hash_equals($expected, $received)) {
    http_response_code(401);
    exit;
}

$event = json_decode($rawBody, true);
http_response_code(200);
python
import hashlib
import hmac
import time


def verify(raw_body: bytes, headers: dict, secret: str) -> bool:
    timestamp = headers.get("X-KVKBase-Timestamp", "")
    received = headers.get("X-KVKBase-Signature", "").removeprefix("v1=")

    # Reject old requests so a captured one cannot be replayed.
    if abs(time.time() - int(timestamp or 0)) > 300:
        return False

    expected = hmac.new(
        secret.encode(),
        timestamp.encode() + b"." + raw_body,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, received)

Retries and auto-disable

  • Answer with any 2xx within 5 seconds. Anything else, a timeout or a redirect counts as a failed attempt.
  • A failed delivery is retried up to 3 attempts in total, with increasing delays between attempts.
  • After 20 failed deliveries in a row the webhook is switched off automatically and you get an email. Saving the URL again in the settings switches it back on. A single success resets the count.
  • Events can in rare cases arrive twice. Use the event id to deduplicate.

Errors

Monitoring uses the same error format as the rest of the API. The codes specific to this feature:

StatusCodeMeaning
403monitoring_requires_paid_planThe Free plan has no watch slots.
403monitoring_slot_limitAll watch slots of your plan are in use (single add).
503monitoring_capacityMonitoring is temporarily full. Retry later.