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
}| Field | Notes |
|---|---|
id | The delivery's id. It is also the svix-id header, and a retry carries it again: deduplicate on it. |
cadence | The destination's cadence when the delivery was made: instant for one event, else the digest's. |
status / attempt_count | Where the delivery stood when this send was made: pending, and how many sends were tried before. GET /deliveries shows how it ended. |
events[].type | The event type your monitor subscribed to, e.g. company.role.added. Labels in English and German are on GET /event-types. |
events[].subject.id | The company to re-read. This is the field you actually act on. |
events[].data | What 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_date | For 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"
}| Field | Notes |
|---|---|
id | The request's id, an opaque string, also its svix-id header. A retry carries it again: deduplicate on it. |
test | true for "Test senden" in the app: an invented company, no register data, no credits. |
search | The saved search that found the change, as the app names it. Its id is an opaque string. |
change | One 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.types | The 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_date | The date the register recorded the entry, not when we detected it, so a hit about an old entry can arrive today. |
change.created_at | When we first recorded the change, in UTC, with a fraction of a second when there is one. |
change.previous / current / text | Register 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.roles | The 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.id | The company to re-read through the API: the one API id in this body (cmp_…). |
credits | What 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_at | When 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:
| Header | Meaning |
|---|---|
svix-id | The delivery's id. A retry carries the same id. |
svix-timestamp | When this attempt was signed, in Unix seconds. Reject old ones to stop replays. |
svix-signature | v1,<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
2xxwithin 30 seconds. A5xx, a429, no answer in time or no connection is retried: six attempts in all, with randomised backoff, over roughly a quarter of an hour. Any other4xxis final. Reject with4xxonly what you never want again (a bad signature), and answer5xxor429when you are merely busy. - A change is never lost with its delivery. If every attempt fails, the delivery is listed
by
GET /deliveriesasfailed, and its events stay listed byGET /events?monitor_id=. After an outage longer than the retry window, read them to catch up. - Deduplicate on the delivery
id(thesvix-idheader). Delivery is at least once, and a retry carries the same id. Within a delivery, an event'sidis the same one every list shows. - Do not rely on order. For register entries,
effective_dateis 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_reasonon 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} → applyThe track changes guide walks through this end to end.