KausateKausate Docs
Guides

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-event AAKEVT charge 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}/events and 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:

CategoryMeaning
statusCompany status changes (active → liquidating, insolvency proceedings)
addressRegistered or business address changes
ownershipShareholder or UBO additions, removals, percentage changes
financialShare-capital changes, financial publications
legalRepresentativesDirector / authorized signatory changes
otherName, legal form, activity changes
disappearedCompany 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.

FilterEffect
categoriesFilterOnly deliver events whose category is in this list (default: all)
eventCodesFilterOnly deliver events whose code is in this list (default: all)
autoDeactivateCategoriesCategories 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.

SourceRequired capabilityProduces categories
company_reportCOMPANY_REPORTstatus, address, legalRepresentatives, financial, other, disappeared
shareholder_graphSHAREHOLDER_GRAPHownership

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).

SourceJurisdictionUpstreamFrequencyProduces categories
de_insolvenzbekanntmachungen.feeddeneu.insolvenzbekanntmachungen.de (§9 InsO)Hourlystatus, 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 codeCategoryDescription
NAME_CHANGEDotherLegal name changed
LEGAL_FORM_CHANGEDotherLegal form (GmbH → AG, Ltd → PLC, etc.)
ACTIVITIES_CHANGEDotherDeclared business activities
REGISTERED_ADDRESS_CHANGEDaddressRegistered office address
BUSINESS_ADDRESS_CHANGEDaddressOperating / business address
JURISDICTION_CHANGEDaddressRegistry jurisdiction (e.g. cross-border seat transfer)
COMPANY_STATUS_CHANGEDstatusStatus field on the registry record
REGISTRATION_DATE_CHANGEDstatusRegistration date (rare; usually a registry correction)
SHARE_CAPITAL_CHANGEDfinancialShare-capital amount
COMPANY_DISSOLVEDdisappearedStatus went to dissolved
COMPANY_STRUCK_OFFdisappearedStruck off / removed from register
COMPANY_LIQUIDATEDdisappearedLiquidation completed
COMPANY_NOT_FOUNDdisappearedRegistry no longer returns the company
DIRECTOR_ADDEDlegalRepresentativesDirector / officer appointed
DIRECTOR_REMOVEDlegalRepresentativesDirector / officer left
DIRECTOR_ROLE_CHANGEDlegalRepresentativesExisting director's role changed
AUTHORIZED_SIGNATORY_CHANGEDlegalRepresentativesSignatory powers (Prokura etc.) changed
UBO_ADDEDownershipNew UBO declared
UBO_REMOVEDownershipUBO removed
UBO_PERCENTAGE_CHANGEDownershipExisting UBO's stake changed
SHAREHOLDER_ADDEDownershipNew shareholder added to graph
SHAREHOLDER_REMOVEDownershipShareholder left the graph
SHAREHOLDING_PERCENTAGE_CHANGEDownershipExisting shareholder's stake changed
INSOLVENCY_PROTECTIVE_MEASURESstatusSicherungsmaßnahmen / protective measures ordered
INSOLVENCY_DISMISSED_FOR_LACK_OF_ASSETSstatusApplication dismissed (Abweisung mangels Masse)
INSOLVENCY_OPENEDstatusInsolvency proceedings formally opened
INSOLVENCY_DECISIONstatusDecision in proceedings (Entscheidungen im Verfahren)
INSOLVENCY_LIFTEDstatusProceedings lifted / closed
INSOLVENCY_DISTRIBUTION_PUBLISHEDfinancialDistribution to creditors published
INSOLVENCY_DISCHARGE_DECISIONstatusDischarge / Restschuldbefreiung decision
INSOLVENCY_PLAN_SUPERVISEDstatusInsolvency plan / supervised proceedings
INSOLVENCY_OTHERotherInsolvency event not fitting any of the above
OTHERotherCatch-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/json
  • Kausate-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/webhooks channel), 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 webhookUrl on 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 / eventCodesFilter and 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:

SKUCharged whenCreditsRefund
AAKMONEach scheduled tick of a per-company source5 per tickNo — the registry call already happened
AAKEVTEach delivered webhook event1 per deliveryYes — 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 * * 1 for weekly Monday-morning runs).
  • Drop shareholder_graph if 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:

  1. Is the monitor active? GET /v2/monitors/{id} and check isActive. Auto-deactivation may have flipped it after a disappeared event — deactivationReason will explain why.
  2. Did the event happen? GET /v2/monitors/{id}/events with since set to your last-known-delivery time. If the event is there, it was detected and either filtered or failed delivery.
  3. If the event exists but no delivery happened, check your filters: GET /v2/monitors/{id} returns categoriesFilter and eventCodesFilter. An event matching neither won't fire a webhook.
  4. 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}/replay and inspect your server logs for the new delivery attempt.
  5. 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 DiffDetector uses Python == comparison on array values. If a registry returns directors in a different order on two consecutive ticks, the diff may fire DIRECTOR_ADDED/DIRECTOR_REMOVED events even when the set of directors didn't change. We dedupe via the canonical model where possible, but for shareholder graphs this can cause spurious SHAREHOLDER_ADDED events. 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

StatusReasonFix
404 Company not foundThe kausateId doesn't exist in our indexRun a search first via /v2/companies/search/indexed
422 Unknown monitoring sourceTypo or made-up source nameDiscover via /v2/events/sources
422 Source not applicable to companyThe source is for a different jurisdiction or the company's integration doesn't support the required capabilityUse /v2/monitors/sources?kausateId=… to list valid sources
422 Invalid cron expressionCron syntaxUse a valid 5-field cron (e.g. 0 9 * * *)
422 eventCodesFilter value not in enumStale or misspelled codeFetch current codes from /v2/events/taxonomy

Last updated on

On this page