registercheckby openlaw group
Understand the data

Webhooks

What a delivery looks like when it reaches your endpoint, and how to handle it safely.

A monitor is only half of monitoring. The other half is where we send what it finds: a destination. This page is the contract for webhook destinations: how to set one up, what arrives, how to verify it, and what you must not assume.

Set up a destination

Destinations belong to your account, not to a single monitor: every event on every one of your monitors is sent to every active destination you set up yourself (its role is own, see Destination roles). Add one in the app under Benachrichtigungen, or through the API with POST /destinations and "channel": "webhook", with your URL as address. The same place takes e-mail addresses and Slack channels.

The response to creating a webhook destination carries its signing secret (whsec_…). It is shown this once and never again: store it, you need it to verify deliveries, and it is the only thing separating a real delivery from anyone who learns your URL. Lost it? Disable the destination and create a new one.

Disabling (DELETE) keeps the destination listed so you can turn it back on (PATCH with {"disabled": false}).

What arrives

A delivery, sent as Content-Type: application/json: the same object GET /deliveries/{delivery_id} lists, with the events it carries. An instant destination gets one delivery per event; a daily, weekly or monthly destination gets one per period, holding that period's events. Each event is the same object GET /events?monitor_id= lists, so one parser serves all three. The body is exactly what was signed:

{
  "object": "delivery",
  "id": "dlv_01k6yq3m0nfx8a4q2t7bw9c5ve",
  "destination_id": "dst_6hq30w96je9rnrmw1tknzhwb2n",
  "cadence": "instant",
  "status": "pending",
  "attempt_count": 0,
  "events": [
    {
      "object": "event",
      "id": "evt_1c9sn8p79z99e9wyrf38nkrkay",
      "type": "company.registered_office.changed",
      "subject": {
        "object": "company",
        "id": "cmp_1z3gn9wpvx9j59w8bd9w5rm72n",
        "name": "Beispiel Analytics GmbH"
      },
      "data": { "previous": "München", "current": "Augsburg" },
      "source": null,
      "effective_date": "2026-09-21",
      "created_at": "2026-09-21T10:14:02Z"
    }
  ],
  "created_at": "2026-09-21T10:14:05Z",
  "sent_at": null
}
FieldNotes
idThe delivery's id. It is also the svix-id header, and a retry carries it again: deduplicate on it.
cadenceThe destination's cadence when the delivery was made: instant for one event, else the digest's.
status / attempt_countWhere the delivery stood when this send was made: pending, and how many sends were tried before. GET /deliveries shows how it ended.
events[].typeThe event type your monitor subscribed to, e.g. company.role.added. Labels in English and German are on GET /event-types.
events[].subject.idThe company to re-read. This is the field you actually act on.
events[].dataWhat changed, shaped by the type: previous / current for a changed value, the holder and role_type for a role. Values from the register are verbatim, and some are a sentence of register text, so do not assume they are short.
events[].effective_dateFor a register entry, the date the register recorded it — not when we detected it, so an event about an old entry can arrive today. For shareholder lists and annual accounts, the day we first read them, since neither carries a register date. null when no date is known.

One change covered by two of your monitors is one event: its id is the same wherever it is listed. An event naming a company or person taken off the platform since it was decided is left out, and a delivery with no event left is not sent.

A dashboard monitor can deliver to a webhook of its own (a destination with role route). That webhook receives only the events of the dashboard monitors that deliver to it, each as its own delivery at the monitor's check time, listed by GET /deliveries with cadence instant. The e-mail and Slack summaries of dashboard monitors are not API deliveries and are not listed.

Saved searches

A saved search on the Ereignisse page can send what it finds to a webhook of its own. Every hit arrives as a request of its own, signed, retried and deduplicated as described below, with this body:

{
  "object": "search_delivery",
  "id": "5d3b1f0c-8e2a-5c41-9b7d-0f6e4a2c8d19",
  "test": false,
  "search": {
    "object": "event_search",
    "id": "8c3f0e2a-1b4d-4c6e-9f80-123456789abc",
    "name": "Insolvenzen Bayern"
  },
  "change": {
    "id": "3f1c2a9e-5b7d-4c8a-9e21-6d4f0b8a1c55:14:insolvency:2026-10-14",
    "kind": "insolvency",
    "types": ["company.insolvency.recorded"],
    "effective_date": "2026-10-14",
    "created_at": "2026-10-14T09:12:03.284117Z",
    "previous": null,
    "current": null,
    "text": "Über das Vermögen der Gesellschaft ist das Insolvenzverfahren eröffnet. Die Gesellschaft ist aufgelöst.",
    "roles": [],
    "company": {
      "object": "company",
      "id": "cmp_1z3gn9wpvx9j59w8bd9w5rm72n",
      "name": "Beispiel Bau GmbH",
      "legal_form": "gmbh",
      "register": {
        "court": "Amtsgericht München",
        "type": "HRB",
        "number": "123456",
        "formatted": "Amtsgericht München HRB 123456"
      },
      "registered_office": "München",
      "state": "DE-BY"
    }
  },
  "credits": 1,
  "created_at": "2026-10-14T09:13:00.418203Z"
}
FieldNotes
idThe request's id, an opaque string, also its svix-id header. A retry carries it again: deduplicate on it.
testtrue for "Test senden" in the app: an invented company, no register data, no credits.
searchThe saved search that found the change, as the app names it. Its id is an opaque string.
changeOne register entry's change of one kind, as the Ereignisse list shows it. Its id is an opaque string: compare it, never parse it.
change.typesThe event types the change records, as GET /event-types lists them. A management or prokura change carries company.role.added, company.role.changed or company.role.removed, and so does a liquidation that names its liquidator, beside company.liquidation.recorded when the entry also records the dissolution. Other kinds carry their own type, e.g. company.insolvency.recorded.
change.effective_dateThe date the register recorded the entry, not when we detected it, so a hit about an old entry can arrive today.
change.created_atWhen we first recorded the change, in UTC, with a fraction of a second when there is one.
change.previous / current / textRegister text, verbatim, or null. previous and current are the old and new value for registered_office, name, purpose and capital; text is the entry's own wording for articles, conversion, liquidation, insolvency and deregistration, and for a seat move that the register wrote only in words. management and prokura name their people in roles, and a liquidation names its liquidator there too, beside its text.
change.rolesThe people or companies the entry names for a role, each with object (person or company), name, role_type and change (appointed, changed, removed). role_type is one of the codes GET /role-types lists, read from the function the entry names: "Geschäftsführer" is managing_director, "Liquidator" liquidator, a Prokura authorized_signatory. An entry that names no function is representative; a function our rules do not recognise is other, as is the rare role stored without a reading. No birth dates.
change.company.idThe company to re-read through the API: the one API id in this body (cmp_…).
creditsWhat the hit cost: a saved search costs 1 credit per hit, charged when the hit is found, or when you unlock a hit that a paused search missed. A hit that could not be delivered is refunded.
created_atWhen the hit was handed over for sending, in UTC, with a fraction of a second when there is one. For a hit it is the same on every retry, and so is the whole body.

These requests are not monitor deliveries: GET /deliveries does not list them, and they go only to the webhook the saved search names. If every attempt at a hit fails, the hit is refunded, and the change stays on the saved search's tab on the Ereignisse page: after an outage, catch up there. The app shows a new webhook's signing secret (whsec_…) once, when you save the search or change where it delivers; a URL you already receive deliveries at keeps its secret.

Verifying a delivery

Deliveries follow the Standard Webhooks specification. Three headers come with every request:

HeaderMeaning
svix-idThe delivery's id. A retry carries the same id.
svix-timestampWhen this attempt was signed, in Unix seconds. Reject old ones to stop replays.
svix-signaturev1,<base64>: HMAC-SHA256 over {svix-id}.{svix-timestamp}.{raw body}, keyed with your secret base64-decoded after its whsec_ prefix.

The header names are the ones Svix uses, so any Standard Webhooks library verifies them. Use one rather than hand-rolling it — it does the constant-time comparison and the timestamp tolerance for you:

import { Webhook } from 'svix';   // or the `standardwebhooks` package

const wh = new Webhook(process.env.REGISTERCHECK_WEBHOOK_SECRET!);   // the whsec_… secret

app.post('/hooks/registercheck', express.raw({ type: 'application/json' }), (req, res) => {
  let delivery;
  try {
    delivery = wh.verify(req.body, {                 // the RAW body, not the parsed object
      'svix-id': req.headers['svix-id'] as string,
      'svix-timestamp': req.headers['svix-timestamp'] as string,
      'svix-signature': req.headers['svix-signature'] as string,
    });
  } catch {
    return res.status(400).end();                 // unverified: do not process
  }

  res.status(200).end();                          // acknowledge, then work
  void handle(delivery);                          // delivery.id === the svix-id header
});

Signature verification needs the raw request body. A JSON body-parser mounted earlier will re-serialise it and the signature will not match.

Retries, duplicates and order

  • Answer 2xx within 30 seconds. A 5xx, a 429, no answer in time or no connection is retried: six attempts in all, with randomised backoff, over roughly a quarter of an hour. Any other 4xx is final. Reject with 4xx only what you never want again (a bad signature), and answer 5xx or 429 when you are merely busy.
  • A change is never lost with its delivery. If every attempt fails, the delivery is listed by GET /deliveries as failed, and its events stay listed by GET /events?monitor_id=. After an outage longer than the retry window, read them to catch up.
  • Deduplicate on the delivery id (the svix-id header). Delivery is at least once, and a retry carries the same id. Within a delivery, an event's id is the same one every list shows.
  • Do not rely on order. For register entries, effective_date is the register's own chronology.
  • A failing destination is switched off. After ten failed deliveries in a row we disable it and say why (disabled_reason on the destination). Fix the receiver, then turn it back on.

Treat the payload as a signal

An event's data tells you what the register said at detection time. It is not a substitute for the company record, and it may no longer be current when you process it. Re-read the company and act on that:

delivery arrives  →  verify  →  dedupe on delivery.id  →  for each event: GET /companies/{event.subject.id}  →  apply

The track changes guide walks through this end to end.

On this page