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.
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
| Field | Meaning |
|---|---|
name | Registered name |
address | Street, postcode and city |
legalForm | Legal form (BV, NV, VOF, ...) |
status | active or dissolved |
insolvency | none, bankrupt or surseance |
sbiCodes | Activity (SBI) codes |
directors | Directors, only when the data source provides them |
vat | VIES 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
| Plan | Watch slots |
|---|---|
| Free | 0 (not available) |
| Starter | 10 |
| Pro | 100 |
| Business | 1,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.
| Endpoint | Description |
|---|---|
GET /v1/monitor/watches | List 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/watches | Add 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/alerts | Alert history, returned as alerts plus nextCursor. Filter with kvkNumber and since; page with limit and cursor. |
GET /v1/monitor/settings | Read email, delivery, field and webhook settings. Returns emailEnabled, delivery, fields, webhookUrl, webhookConfigured, webhookDisabled and lang. The webhook secret is never returned. |
PUT /v1/monitor/settings | Update the settings. Saving a webhook URL for the first time also returns webhookSecret, once. |
POST /v1/monitor/settings/webhook-secret | Generate a new webhook secret. The response (webhookSecret) is the only time it is shown. |
POST /v1/monitor/webhook/test | Send 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. |
# 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
{
"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
| Header | Value |
|---|---|
X-KVKBase-Event | The event type |
X-KVKBase-Timestamp | Unix time in seconds when the request was signed |
X-KVKBase-Signature | v1= 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.
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
$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); 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
2xxwithin 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
idto deduplicate.
Errors
Monitoring uses the same error format as the rest of the API. The codes specific to this feature:
| Status | Code | Meaning |
|---|---|---|
| 403 | monitoring_requires_paid_plan | The Free plan has no watch slots. |
| 403 | monitoring_slot_limit | All watch slots of your plan are in use (single add). |
| 503 | monitoring_capacity | Monitoring is temporarily full. Retry later. |