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, billing, 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
▼ (success: AAKEVT charged; failure: refunded after final retry)
[your endpoint]- One tick = one full registry call per per-company source per monitor.
Costs
AAKMON(5 credits) per scheduled tick of those sources, 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 do not consume
AAKMON— there's no per-monitor tick. Only the per-eventAAKEVTcharge applies on delivery. - 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 are evaluated just before
webhook delivery, after the event has already been written to the
monitor's event log. That means:
- Filtered-out events are queryable via
GET /v2/monitors/{id}/eventsand replayable on demand. - Changing a filter doesn't backfill — past events that were filtered out still didn't fire a webhook. Use replay if you need to push them.
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 — events that don't match are
still persisted (so they remain replay-able and visible via
GET /v2/monitors/{id}/events),
but no webhook is fired for them.
| 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 (and stop billing AAKMON) for a company
that no longer exists — you keep the audit trail, you stop paying for
ticks 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"]
}'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": "mon_5d4241a1",
"kausate_id": "co_de_19IubOgcrWVQ7pRpNIBaDX",
"event_id": "evt_b2fa69b8",
"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": {
"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/mon_5d4241a1/events/evt_b2fa69b8"
}
}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.companyStatus). 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.
- Per-event billing. Each delivered event consumes one credit
(
AAKEVT). Refunded if all 50 retry attempts fail (so a permanent outage of your endpoint costs you nothing besides missed events). - 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 event is persisted on the monitor and queryable, including events that were filtered out of webhook delivery. 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. Replay also consumes one AAKEVT credit per
delivery attempt with the same refund-on-final-failure rule.
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.
Billing
Two SKUs are charged on monitoring activity:
| SKU | Charged when | Credits | Refund |
|---|---|---|---|
AAKMON | Each scheduled tick of a per-company source | 5 per tick | No — the registry call already happened |
AAKEVT | Each delivered webhook event | 1 per delivery | Yes — refunded if every retry attempt fails |
Worked example for a single monitor with scheduleCron: "0 9 * * *",
two per-company sources (company_report + shareholder_graph), and
one feed source (de_insolvenzbekanntmachungen.feed):
AAKMON: 10 credits/day (5 credits × 2 per-company sources × 1 tick/day). Feed sources don't tick per-monitor, so they don't add to this number.AAKEVT: 1 credit per delivered event. Average is heavily workload-dependent — a stable company with no changes generates zero events; an active company in insolvency proceedings might generate a handful per week.
Monthly total for a steady-state monitor: ~300 AAKMON (10/day × 30
days) + actual AAKEVT consumption. Track via
GET /v2/analytics/breakdowns?groupBy=sku.
To reduce AAKMON cost:
- Use a less-frequent cron (
0 9 * * 1for weekly Monday-morning runs). - Drop
shareholder_graphif you don't need ownership tracking. - 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
- Array diffs are order-sensitive. The current
DiffDetectoruses Python==comparison on array values. If a registry returnsdirectorsin a different order on two consecutive ticks, the diff may fireDIRECTOR_ADDED/DIRECTOR_REMOVEDevents even when the set of directors didn't change. We dedupe via the canonical model where possible, but for shareholder graphs this can cause spuriousSHAREHOLDER_ADDEDevents. Treat ownership-change events as a trigger to fetch the full graph and reconcile, not as a definitive signal of a specific change. - Cron is required even for feed-only monitors. Use 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