Monitoring & Webhooks
Detect changes to companies, classify them into a generic event taxonomy, and receive webhook notifications
Overview
A monitor watches one company across one or more sources and emits
events when it detects a change. Each event carries a generic
event_code (e.g. COMPANY_DISSOLVED, INSOLVENCY_OPENED) plus a broader
category (e.g. disappeared, status). When you provide a webhookUrl,
events are POSTed to your endpoint with an at-least-once delivery
guarantee.
The taxonomy is jurisdiction-agnostic: a customer who routes on
INSOLVENCY_OPENED automatically benefits when new insolvency feeds for
other jurisdictions land — no client-side mapping table to maintain.
How it works
Two pipelines run independently and converge at the same delivery mechanism. Knowing which one your sources use explains why latency and filtering behave the way they do.
Per-company pull pipeline
[your monitor]
│ scheduleCron fires (UTC)
▼
[PerCompanyMonitorWorkflow]
│ for each per_company_pull source:
│ 1. invoke the registry capability (e.g. company_report)
│ 2. persist the response as an Observation
│ 3. diff against the previous Observation using DiffRules
│ 4. emit one ChangeEvent per detected diff
▼
[DeliveryWorkflow] (one per event)
│ POST to your webhookUrl with retries
▼
[your endpoint]- One tick = one full registry call per per-company source per monitor, even if no changes were detected.
- Diffs are computed in canonical Kausate model space (
CompanyReport,ShareholderGraph), so the same event codes fire across jurisdictions. - The first tick after monitor creation establishes a baseline — no events fire until a second observation exists to diff against.
Global feed pipeline
[upstream publisher] (e.g. neu.insolvenzbekanntmachungen.de)
│
▼
[SourceFeedWorkflow] (one Schedule per registered feed, runs centrally)
│ 1. crawl new publications since last run
│ 2. atomically dedup via UNIQUE(source, external_id)
│ 3. persist every new item as an Observation
│
▼ (per Observation)
[FeedMatcherWorkflow]
│ resolve Observation → kausate_id(s) via the source's matcher
│ (e.g. join publication's (court, register_type, register_number)
│ against the DE Handelsregister index)
│ classify into EventCode + Category via EventDetector rules
│ for each monitor watching this kausate_id + source:
│ emit one ChangeEvent
│
▼
[DeliveryWorkflow] (same as above)- Feed sources have no per-monitor tick — they run centrally, whenever the upstream publishes.
- Cron is ignored for feed sources — the upstream publishes when it publishes.
- A feed publication that doesn't match any monitored company is still persisted (so a future monitor can replay against it via the matcher), but no event fires.
Where filters apply
categoriesFilter and eventCodesFilter decide what gets delivered, and
changing a filter never backfills. But where the filter is applied differs
by pipeline, which decides whether a filtered-out event is recoverable:
| Pipeline | Filter applied | A filtered-out event is… |
|---|---|---|
per_company_pull | After the event is written to the log | Persisted — queryable via GET /v2/monitors/{id}/events and replayable |
global_feed | Before the event is written | Not persisted — unrecoverable |
So a filter is not a safe "I'll widen it later" knob for feed sources: a feed event you filtered out never existed. If you might want DE insolvency events later, subscribe to the category now and discard them on your side.
Concepts
Sources
A source is a unit of change observation. Two flavors:
- Per-company pull — calls a registry endpoint on a cron schedule and
diffs the result. Examples:
company_report,shareholder_graph. Works for every jurisdiction whose integration provides the matching capability. - Global feed — continuously ingests an upstream publication stream
and matches each item to companies in our index. Example:
de_insolvenzbekanntmachungen.feed(the official §9 InsO publication portal). The cron schedule is ignored for feed sources — they run centrally.
Discover sources with GET /v2/events/sources
or filter by company with GET /v2/monitors/sources?kausateId=….
Categories and event codes
Every event is classified into one of seven categories:
| Category | Meaning |
|---|---|
status | Company status changes (active → liquidating, insolvency proceedings) |
address | Registered or business address changes |
ownership | Shareholder or UBO additions, removals, percentage changes |
financial | Share-capital changes, financial publications |
legalRepresentatives | Director / authorized signatory changes |
other | Name, legal form, activity changes |
disappeared | Company dissolved, struck off, liquidated, or no longer found |
Each category bundles ~3–9 specific event_codes. Get the full mapping
with GET /v2/events/taxonomy.
Route on whichever level fits your workflow. Use category for broad
fan-out (e.g. "alert ops on every disappeared event for any monitored
company"). Use event_code when you need to act on a specific transition
(e.g. only INSOLVENCY_OPENED, not other insolvency-procedure
publications).
Filters
Filters apply at the delivery boundary — no webhook is fired for an event
that doesn't match. For per_company_pull sources the event is still persisted
(so it remains replay-able and visible via
GET /v2/monitors/{id}/events); for
global_feed sources the filter is applied before the write, so a filtered-out
feed event is never persisted at all. See Where filters apply.
| Filter | Effect |
|---|---|
categoriesFilter | Only deliver events whose category is in this list (default: all) |
eventCodesFilter | Only deliver events whose code is in this list (default: all) |
autoDeactivateCategories | Categories whose events flip the monitor's is_active to false (default: ["disappeared"]) |
Both filters apply if both are set — an event must pass each independently to be delivered.
Auto-deactivation
By default, any event in the disappeared category flips the monitor's
is_active to false and writes a human-readable deactivationReason.
The intent is to stop ticking for a company that no longer exists — you
keep the audit trail, and the monitor stops making registry calls that
won't surface anything new.
Override at create time:
// extend default to also auto-deactivate on insolvency openings
"autoDeactivateCategories": ["disappeared", "status"]
// opt out entirely (note: distinct from omitting the field, which uses the default)
"autoDeactivateCategories": []A deactivated monitor is preserved (you can still fetch it, list its
events, replay past events) but no longer ticks. Delete the monitor with
DELETE /v2/monitors/{id} if you want to fully tear it down.
What's supported today
Per-company pull sources
These sources work for any jurisdiction whose integration declares the
matching capability LIVE. The producesCategories column tells you
which categories of event the source can emit — combine with
categoriesFilter if you want to subscribe only to a subset.
| Source | Required capability | Produces categories |
|---|---|---|
company_report | COMPANY_REPORT | status, address, legalRepresentatives, financial, other, disappeared |
shareholder_graph | SHAREHOLDER_GRAPH | ownership |
The set of jurisdictions where each capability is live changes as new
integrations ship — query
GET /v2/monitors/sources?kausateId=…
at runtime to get the authoritative list for a specific company. As of
today, company_report is live in 30+ jurisdictions including DE, GB,
FR, NL, AT, IT, CH, BE, ES, PL, PT, SE, NO, FI, DK, IE, LU, CZ, GR, HU,
RO, BG, HR, SI, EE, LT, LV, IS, EC, IL, BA, BH, SI, VN, XK; shareholder_graph
is live on the subset that publishes ownership data, including DE, GB,
FR, NL, IT, AT, BE, CH, CZ, GR, IS, NO, PL, PT, BG, BH, EC, GE, HU, IE,
RO, XK.
Global feed sources
Feed sources are jurisdiction-pinned by definition (they ingest a specific upstream publication stream).
| Source | Jurisdiction | Upstream | Frequency | Produces categories |
|---|---|---|---|---|
de_insolvenzbekanntmachungen.feed | de | neu.insolvenzbekanntmachungen.de (§9 InsO) | Hourly | status, financial, other |
More feed sources are on the roadmap (FR BODACC, IT Bollettino, GB
Insolvency Service, etc.) — they'll appear in GET /v2/events/sources
without you needing to change client-side code, as long as you didn't
hardcode the source list.
Full event-code → category mapping
| Event code | Category | Description |
|---|---|---|
NAME_CHANGED | other | Legal name changed |
LEGAL_FORM_CHANGED | other | Legal form (GmbH → AG, Ltd → PLC, etc.) |
ACTIVITIES_CHANGED | other | Declared business activities |
REGISTERED_ADDRESS_CHANGED | address | Registered office address |
BUSINESS_ADDRESS_CHANGED | address | Operating / business address |
JURISDICTION_CHANGED | address | Registry jurisdiction (e.g. cross-border seat transfer) |
COMPANY_STATUS_CHANGED | status | Status field on the registry record |
REGISTRATION_DATE_CHANGED | status | Registration date (rare; usually a registry correction) |
SHARE_CAPITAL_CHANGED | financial | Share-capital amount |
COMPANY_DISSOLVED | disappeared | Status went to dissolved |
COMPANY_STRUCK_OFF | disappeared | Struck off / removed from register |
COMPANY_LIQUIDATED | disappeared | Liquidation completed |
COMPANY_NOT_FOUND | disappeared | Registry no longer returns the company |
DIRECTOR_ADDED | legalRepresentatives | Director / officer appointed |
DIRECTOR_REMOVED | legalRepresentatives | Director / officer left |
DIRECTOR_ROLE_CHANGED | legalRepresentatives | Existing director's role changed |
AUTHORIZED_SIGNATORY_CHANGED | legalRepresentatives | Signatory powers (Prokura etc.) changed |
UBO_ADDED | ownership | New UBO declared |
UBO_REMOVED | ownership | UBO removed |
UBO_PERCENTAGE_CHANGED | ownership | Existing UBO's stake changed |
SHAREHOLDER_ADDED | ownership | New shareholder added to graph |
SHAREHOLDER_REMOVED | ownership | Shareholder left the graph |
SHAREHOLDING_PERCENTAGE_CHANGED | ownership | Existing shareholder's stake changed |
INSOLVENCY_PROTECTIVE_MEASURES | status | Sicherungsmaßnahmen / protective measures ordered |
INSOLVENCY_DISMISSED_FOR_LACK_OF_ASSETS | status | Application dismissed (Abweisung mangels Masse) |
INSOLVENCY_OPENED | status | Insolvency proceedings formally opened |
INSOLVENCY_DECISION | status | Decision in proceedings (Entscheidungen im Verfahren) |
INSOLVENCY_LIFTED | status | Proceedings lifted / closed |
INSOLVENCY_DISTRIBUTION_PUBLISHED | financial | Distribution to creditors published |
INSOLVENCY_DISCHARGE_DECISION | status | Discharge / Restschuldbefreiung decision |
INSOLVENCY_PLAN_SUPERVISED | status | Insolvency plan / supervised proceedings |
INSOLVENCY_OTHER | other | Insolvency event not fitting any of the above |
OTHER | other | Catch-all for events without a specific code |
Always fetch the live taxonomy via
GET /v2/events/taxonomy
rather than copying this table — new event codes added in future
releases will then be picked up automatically.
Setting up a monitor
1. Discover applicable sources
curl https://api.kausate.com/v2/monitors/sources?kausateId=co_de_2tT6getO1qTAvO3iaGnju6 \
-H "X-API-Key: your_api_key"Returns only the sources valid for that company's jurisdiction + capability matrix:
{
"sources": [
{
"name": "company_report",
"mode": "per_company_pull",
"producesCategories": [
"status",
"address",
"legalRepresentatives",
"financial",
"other",
"disappeared"
]
},
{
"name": "shareholder_graph",
"mode": "per_company_pull",
"producesCategories": ["ownership"]
},
{
"name": "de_insolvenzbekanntmachungen.feed",
"mode": "global_feed",
"producesCategories": ["status", "financial", "other"]
}
]
}2. Create the monitor
curl -X POST https://api.kausate.com/v2/monitors \
-H "X-API-Key: your_api_key" \
-H "Content-Type: application/json" \
-d '{
"kausateId": "co_de_2tT6getO1qTAvO3iaGnju6",
"sources": [
"company_report",
"shareholder_graph",
"de_insolvenzbekanntmachungen.feed"
],
"scheduleCron": "0 9 * * *",
"webhookUrl": "https://your-server.com/webhooks/kausate",
"categoriesFilter": ["status", "ownership", "disappeared"],
"metadata": { "caseId": "CASE-12345", "team": "compliance" }
}'The optional metadata object is stored on the monitor and echoed back under
the metadata key everywhere a change event surfaces:
- on the monitor read responses (create, list, and
GET /v2/monitors/{id}), it's your object exactly as you sent it; and - on every webhook delivery and each event from
GET /v2/monitors/{id}/events(and therecentEventsin the monitor detail), your keys are merged into the event'smetadataalongside Kausate's own annotations (source,api_url,native_publication_type, …).
Use it to correlate change events with your own records — e.g. an internal
case ID — without maintaining a separate monitor-ID→case-ID lookup. It must
be a JSON object no larger than 4 KB when serialized. On the event/webhook
payloads, Kausate's annotation keys take precedence, so avoid reserved names
like source, external_id, jurisdiction, native_publication_type, and
api_url if you need those values back unchanged.
The cron drives per-company sources only; feed sources fire whenever the upstream publishes, regardless of cron. The 422 you'll see if a source doesn't apply to the company is precise:
{
"detail": "Source 'de_insolvenzbekanntmachungen.feed' is not applicable to company 'co_gb_…'. Use GET /v2/monitors/sources?kausateId=co_gb_… to see valid sources for this company."
}3. Receive events
Each detected change is POSTed to webhookUrl:
{
"event": "monitor.change_detected",
"monitor_id": "04522989-7e75-4927-98d4-ddee56986a77",
"kausate_id": "co_de_19IubOgcrWVQ7pRpNIBaDX",
"event_id": "b2fa69b8-1c3d-4e5f-9a0b-7c8d9e0f1a2b",
"event_code": "INSOLVENCY_DECISION",
"category": "status",
"severity": "info",
"detected_at": "2026-05-04T22:00:00Z",
"diff_path": null,
"before": null,
"after": {
"aktenzeichen": "9 IN 1137/24",
"publicationDate": "2026-05-02",
"court": "Heilbronn",
"publicationType": "Entscheidungen im Verfahren",
"registerType": "HRB",
"registerNumber": "793063"
},
"metadata": {
"caseId": "CASE-12345",
"team": "compliance",
"source": "de_insolvenzbekanntmachungen.feed",
"external_id": "9 IN 1137/24|2026-05-02|Entscheidungen im Verfahren",
"jurisdiction": "de",
"native_publication_type": "Entscheidungen im Verfahren",
"api_url": "https://api.kausate.com/v2/monitors/04522989-7e75-4927-98d4-ddee56986a77/events/b2fa69b8-1c3d-4e5f-9a0b-7c8d9e0f1a2b"
}
}metadata here carries the object you set on the monitor (caseId, team)
merged with Kausate's per-event annotations (source, api_url, …). Your
keys are returned on every delivery; if you set none, metadata is just the
Kausate annotations. On a key clash the Kausate annotation wins, so pick key
names outside that set (source, external_id, jurisdiction,
native_publication_type, api_url).
The event_code is the public contract customers route on. The
metadata.native_publication_type carries the original upstream label
(here, the German §9 InsO publication category) for human-readable
debugging — it's not a stable contract, but useful when investigating an
event in the upstream registry.
For per-company-pull events, before and after carry the field values
that changed and diff_path points at the location in the canonical
CompanyReport shape — e.g. companyReport.basicInformation.status.normalized.
For identity-keyed array diffs (directors, shareholder-graph nodes/edges) the
path carries the identifying key of the element that changed, e.g.
companyReport.relationships.legalRepresentatives[name='Jane Doe'].
For feed events, before is null (the publication is the change itself)
and after carries the full publication payload.
Webhook security
Monitor webhooks are delivered to the webhookUrl you configured at
monitor-creation time. Two headers are sent:
Content-Type: application/jsonKausate-Version: <version>
There is no HMAC signature on the monitor webhook payload today. To authenticate the request:
- Recommended: host the receiver on an unguessable HTTPS path
(e.g.
https://your-app.example.com/webhooks/kausate-monitors/<random-token>) and reject any other path. Treat the path as a shared secret. - Stronger: front the receiver with a reverse proxy that injects a
static
Authorization: Bearer …header for any request matching the monitor-webhook path, and validate it in your handler. The Kausate worker doesn't currently support custom headers on monitor webhooks (unlike the order-completion/v2/webhookschannel), so this has to happen in your infrastructure rather than in our subscription record.
Both approaches keep the secret out of your application code. Don't rely on source IP allowlisting — Kausate workers run on rotating cloud infrastructure.
If you need cryptographic delivery proof, fall back to fetching the
event yourself: every webhook payload includes
metadata.api_url, which authenticates with your X-API-Key and
returns the same event from the trusted API surface. Compare the
two before acting on the payload.
Delivery semantics
- At-least-once. Webhook delivery retries up to 50 times with
exponential backoff (1 second initial, 2× multiplier, 4 hours max
interval). Total budget exceeds a calendar week, so a brief outage of
your endpoint won't drop events. Make your endpoint idempotent by
deduping on
event_id. - Acknowledge fast. Return 2xx within 30 seconds. Long-running work belongs in your queue, not in the webhook handler — the request will trip retries needlessly otherwise.
- No backfill on subscription changes. If you change
webhookUrlon an existing monitor, only future events go to the new URL — past events stay queryable via the events list.
Replay & event log
Every per-company-pull event is persisted on the monitor and queryable, including events that were filtered out of webhook delivery. (Feed events that a filter excluded are never written — see Where filters apply.) Useful when:
- Your endpoint was down past the 50-retry budget.
- You changed
categoriesFilter/eventCodesFilterand want to push previously-filtered events to your handler. - You're rebuilding a downstream system from history.
# Paginated list — supports filtering by eventCode, category, since, until
curl "https://api.kausate.com/v2/monitors/$MONITOR_ID/events?limit=100&category=status&since=2026-05-01T00:00:00Z" \
-H "X-API-Key: your_api_key"
# Re-trigger delivery for a single event (returns 202 {"status":"queued"})
curl -X POST "https://api.kausate.com/v2/monitors/$MONITOR_ID/events/$EVENT_ID/replay" \
-H "X-API-Key: your_api_key"Replay re-uses the original event_id, so an idempotent receiver
deduplicates correctly.
Routing patterns
Route on category for fan-out
match payload["category"]:
case "disappeared":
ops_alert(payload)
case "ownership":
compliance_review(payload)
case "status" if payload["event_code"].startswith("INSOLVENCY_"):
risk_team(payload)
case _:
archive(payload)Route on event_code for specific transitions
TRIGGERS = {
"INSOLVENCY_OPENED",
"COMPANY_DISSOLVED",
"AUTHORIZED_SIGNATORY_CHANGED",
}
if payload["event_code"] in TRIGGERS:
...Discover the taxonomy at runtime
If you don't want to hardcode the event-code list, fetch it once at
startup via GET /v2/events/taxonomy
and build your routing table from the response. New event codes added
in future releases will then be picked up automatically.
Tuning how often a monitor ticks
Each tick of a per-company source is a full registry call, so the cron is the main lever on how much work a monitor does:
- Use a less-frequent cron (
0 9 * * 1for weekly Monday-morning runs). A monitor ticks once per schedule fire regardless of how many per-company sources it has, so adding a second source doesn't add ticks. - A monitor with only feed sources never ticks — feed sources run centrally, whenever the upstream publishes.
- Let auto-deactivation stop ticking on dissolved companies (default behavior).
Troubleshooting
My webhook isn't firing
Walk this checklist in order:
- Is the monitor active?
GET /v2/monitors/{id}and checkisActive. Auto-deactivation may have flipped it after adisappearedevent —deactivationReasonwill explain why. - Did the event happen?
GET /v2/monitors/{id}/eventswithsinceset to your last-known-delivery time. If the event is there, it was detected and either filtered or failed delivery. - If the event exists but no delivery happened, check your filters:
GET /v2/monitors/{id}returnscategoriesFilterandeventCodesFilter. An event matching neither won't fire a webhook. - If the filters allow the event, your endpoint may be returning
non-2xx or timing out. Replay the event with
POST /v2/monitors/{id}/events/{eventId}/replayand inspect your server logs for the new delivery attempt. - If replay also fails, the issue is on your side (DNS, TLS, firewall, slow handler). The Kausate worker treats any non-2xx (and any timeout > 30 s) as a retry trigger.
I'm getting duplicate events
Expected. Delivery is at-least-once. Dedupe on event_id in your
handler — every event has a stable UUID for its lifetime, including
across replays.
A feed event matched the wrong company
Feed matchers join on whatever identifier the upstream provides (court +
register-type + register-number for DE insolvency). False positives are
possible when two companies share the same identifier in two different
sub-courts, or when the upstream uses an old identifier after a transfer.
Inspect metadata.external_id and the publication's raw fields in
after to confirm. Open a ticket if the misclassification is
systematic.
Webhook fired but before is null
Expected for feed events (the publication itself is the change —
there's no "before" state). Also expected for the first event from a
per-company source after monitor creation — the diff baseline is the
first observation, so the very first detected change will have before
limited to whatever was in that baseline (often partial).
My monitor was auto-deactivated and I want it back
Either delete and re-create with autoDeactivateCategories: [], or
contact support to reactivate the existing monitor (rare; usually
re-creating is faster).
Limitations
addressesdiffs are still order-sensitive. Directors and shareholder-graph nodes/edges are diffed by identity key, so reordering them between ticks does not fire spurious events. ButcompanyReport.basicInformation.addresseshas no identity key — it's compared as a whole list — so a registry that returns the same addresses in a different order across two ticks can still fire a spuriousREGISTERED_ADDRESS_CHANGED. Treat that one as a trigger to re-read the report and reconcile, not as proof the address actually changed.- Some event codes are defined but not yet emitted.
UBO_ADDED,UBO_REMOVED, andUBO_PERCENTAGE_CHANGEDare reserved for a future UBO source — no source emits them today. Ownership changes currently surface fromshareholder_graphasSHAREHOLDER_ADDED,SHAREHOLDER_REMOVED, andSHAREHOLDING_PERCENTAGE_CHANGED. The same applies toBUSINESS_ADDRESS_CHANGED,JURISDICTION_CHANGED, andCOMPANY_NOT_FOUND. Handle them defensively if they appear, but don't build a feature that depends on them firing. - Cron is optional and defaults to monthly. Omit
scheduleCronto get0 0 1 * *(midnight UTC on the 1st), or pass any valid expression (e.g.0 0 * * *) — it's ignored when no per-company source is present. - No org-wide monitor webhook. Each monitor specifies its own
webhookUrl. To route all monitors to one endpoint, set the same URL on every monitor at create time. - No webhook signing. See the Webhook security section above for the recommended workarounds.
- Replay is one event at a time. There's no bulk replay endpoint; if you need to replay 1000 events, script the loop yourself with rate-limit-aware backoff.
Common errors
| Status | Reason | Fix |
|---|---|---|
404 Company not found | The kausateId doesn't exist in our index | Run a search first via /v2/companies/search/indexed |
422 Unknown monitoring source | Typo or made-up source name | Discover via /v2/events/sources |
422 Source not applicable to company | The source is for a different jurisdiction or the company's integration doesn't support the required capability | Use /v2/monitors/sources?kausateId=… to list valid sources |
422 Invalid cron expression | Cron syntax | Use a valid 5-field cron (e.g. 0 9 * * *) |
422 eventCodesFilter value not in enum | Stale or misspelled code | Fetch current codes from /v2/events/taxonomy |
Last updated on