Signals
20 credits per successful call · catalog free • 60/min
A signal is one normalized change from the commercial register. Search signals across all companies, filter them by topic, or fetch a single signal by ID.
Endpoints
| Method | Path | Description |
|---|---|---|
| GET | /v1/signals | List and filter signals. Up to 20 per page, paged by cursor. |
| GET | /v1/signals/catalog | All public topic codes with labels, descriptions and tiers. Free. |
| GET | /v1/signals/{signal_id} | Fetch one signal by its ID. |
A signal ID is 1 to 255 characters of A-Z, a-z, 0-9, dot, underscore, tilde and hyphen, and starts with a letter or digit. Anything else gets a plain JSON 404 with no credit or request-id headers. Methods other than GET and HEAD return 405.
Topics
You filter signals by topic. There are seven codes. They are case-sensitive: NEW_REGISTRATIONS works, new_registrations does not.
| Code | Name | Description |
|---|---|---|
| NEW_REGISTRATIONS | New registrations | Companies newly registered in the commercial register. |
| MASTER_DATA_CHANGES | Master data changes | Changes of company name, seat, business address, or register identity. |
| CLOSURES | Closures & dissolutions | Dissolution, liquidation, deletion, or extinguishment of companies. |
| ROLE_HOLDER_CHANGES | Management changes | Entries, exits, and changes of managing directors, board members, and holders of procura. |
| CAPITAL_CHANGES | Capital changes | Changes to share, liable, conditional, or authorized capital. |
| INSOLVENCIES | Insolvencies Pro | Openings, protective measures, and terminations of insolvency proceedings. |
| TRANSFORMATIONS | Transformations & M&A Max | Mergers, splits, conversions, asset transfers, enterprise agreements, and squeeze-outs. |
Topics and your plan
Five topics are open to every account, including accounts with no subscription and accounts on Plus. INSOLVENCIES needs Pro or Max. TRANSFORMATIONS needs Max. Ask for a topic above your plan and you get 403 PLAN_REQUIRED at 0 credits, on the list endpoint and the detail endpoint alike.
Leave topics out and you get only the topics your plan includes: five without a plan or on Plus, six on Pro, seven on Max. Nothing in the response says so — filters.topics comes back as an empty list either way. Send topics explicitly whenever the exact scope matters.
The OTHER topic
event.topic is one of the seven codes above or OTHER. OTHER covers every register event outside the seven, such as purpose changes, branch records and corrections. The detail endpoint returns it routinely; on the list endpoint it is rare. OTHER is not plan-gated, so any account can read it, but you cannot filter on it: topics=OTHER returns 422.
Authentication
All three endpoints need an authenticated account. There is no anonymous access, and no particular token ability is required.
| Credential | Notes |
|---|---|
| x-api-key: YOUR_API_KEY | An active API key from your dashboard. Use this one — it keeps the secret out of URLs and logs. |
| ?api_key=YOUR_API_KEY | Accepted on Signals. The value is scrubbed from the query string and the logs before routing. The header wins if you send both. |
| Authorization: Bearer YOUR_TOKEN | Any valid, unexpired personal access token. |
A missing or invalid credential returns 401 UNAUTHORIZED. An unverified email address and a blocked account both return 403 UNAUTHORIZED with the same detail: "Signals access is not available for this account." You cannot tell them apart, by design.
Parameters
List parameters
| Name | Type | Description |
|---|---|---|
| cursor | string | The next_cursor from your previous response. Send it back unchanged and do not inspect it. Maximum 8192 characters. |
| topics | string list | One or more codes from the table above. topics=A&topics=B, topics[]=A&topics[]=B and topics=A,B all mean the same thing. Values are de-duplicated and sorted. Codes are case-sensitive. |
| organization_ids | string list | Entity IDs of the companies the signals refer to, in the same three list forms. At most 5 per request, each 1 to 128 characters of A-Z, a-z, 0-9, dot, underscore, tilde and hyphen. |
| from / to | date | Start and end of the period. Use YYYY-MM-DD, or a timezone-naive YYYY-MM-DDTHH:MM:SS with an optional fraction of 1 to 6 digits. A trailing Z or an offset such as +02:00 is rejected with 422. to must be the same as or later than from; a single day is valid. |
Unknown parameters are rejected
The list endpoint accepts cursor, topics, organization_ids, from and to. Every other query parameter returns 422 with the message "Unknown Signals parameter." Only the first unknown parameter is reported, so fix them one at a time. You cannot change the page size, the sort order or the field set.
GET /api/v1/signals?topics=NEW_REGISTRATIONS&limit=5
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
X-Credits-Charged: 0
X-Request-Id: req_01k1c2...
{
"type": "https://handelsregister.ai/problems/invalid-request",
"title": "Invalid request",
"status": 422,
"code": "INVALID_REQUEST",
"detail": "One or more Signals parameters are invalid.",
"invalid_params": {
"limit": ["Unknown Signals parameter."]
},
"meta": {
"request_id": "req_01k1c2...",
"request_credit_cost": 0,
"credits_remaining": 95
}
}
The detail endpoint accepts no query parameters. The ID in the path is enough, and anything you add returns 422.
The catalog returns the same seven topics in machine-readable form: code, German and English label and description, the required tier (ALL, PRO or MAX) and an observed flag for topics that already carry data. All seven are listed whatever your plan. It is free, and it accepts no query parameters — any parameter returns 422.
Validation messages
A validation error returns 422 with an invalid_params object keyed by parameter name. These are the exact messages you can get:
| Message | When |
|---|---|
| Unknown Signals parameter. | A query parameter outside the accepted set. Only the first one is reported. |
| Unknown topics value. | A topic code that is not one of the seven public codes. Lowercase spellings count as unknown. |
| organization_ids accepts at most 50 values. | More than 5 organization_ids in one request. |
| organization_ids contains an invalid identifier. | An ID with an unsupported character or more than 128 characters. |
| from must use YYYY-MM-DD or a timezone-naive ISO datetime with seconds. | from or to is neither YYYY-MM-DD nor a timezone-naive datetime with seconds. A trailing Z or an offset lands here too. |
| to must be the same as or later than from. | to lies before from. |
| cursor is invalid. | cursor is empty, longer than 8192 characters, or contains control characters. |
| cursor does not match these filters. | The cursor was not issued for these filters. Start again without one. |
| signal_id is invalid. | The signal ID in the path is empty or longer than 255 characters. |
Example
Example list request
NEW_REGISTRATIONS and CAPITAL_CHANGES work on any account. Swap in INSOLVENCIES (Pro or Max) or TRANSFORMATIONS (Max) and a lower plan gets 403 PLAN_REQUIRED.
# NEW_REGISTRATIONS and CAPITAL_CHANGES are open to every plan.
# INSOLVENCIES needs Pro or Max, TRANSFORMATIONS needs Max.
curl --get 'https://handelsregister.ai/api/v1/signals' \
-H 'x-api-key: YOUR_API_KEY' \
-H 'Accept: application/json' \
--data-urlencode 'topics=NEW_REGISTRATIONS,CAPITAL_CHANGES' \
--data-urlencode 'from=2026-07-01'
Example response
{
"signals": [
{
"event": {
"id": "0123456789abcdef0123456789abcdef",
"topic": "CAPITAL_CHANGES",
"topic_name": {
"de": "Kapitaländerungen",
"en": "Capital changes"
},
"occurred_on": "2026-07-25",
"announced_on": "2026-07-25",
"date_basis": "REGISTER_ENTRY"
},
"organization": {
"type": "ORGANIZATION",
"entity_id": "0123456789abcdef0123456789abcdef",
"current_profile": {
"name": "Beispiel Handel GmbH",
"status": {
"code": "ACTIVE"
}
}
},
"register_entry": {
"entry_number": "7",
"entry_date": "2026-07-25",
"phase": "INCREASED",
"description_text": "Das Stammkapital wurde erhöht.",
"context": "PRIMARY",
"flags": []
},
"source": {
"kind": "COMMERCIAL_REGISTER"
},
"details": {
"schema": "CAPITAL_CHANGE",
"capital_kind": "SHARE_CAPITAL",
"resulting_amount": {
"amount": "50000.00",
"currency": "EUR",
"source_text": "50.000,00 EUR"
}
}
}
],
"pagination": {
"mode": "CURSOR",
"limit": 20,
"returned": 1,
"has_more": true,
"next_cursor": "sgc1.eyJiIjoiYTNmMmVkZWJlNWU5Iiw..."
},
"filters": {
"topics": ["CAPITAL_CHANGES", "NEW_REGISTRATIONS"],
"organization_ids": [],
"from": "2026-07-01"
},
"warnings": [],
"meta": {
"schema_version": "1.0",
"request_id": "req_01k1c2...",
"request_credit_cost": 20,
"credits_remaining": 95
}
}
filters echoes what you sent, normalized: topics comes back de-duplicated and sorted, and from appears only because it was supplied.
Response shape
A list response always has these five members. Enum values are UPPERCASE. Fields with no value are left out instead of being sent as null, and an object that loses all its members disappears too. Empty lists stay, so an empty signals list is a real value.
| Field | Contents |
|---|---|
| signals | Up to 20 items, newest announcement date first. An empty list is a valid page. |
| pagination | mode is always CURSOR. limit is always 20, and 20 is the maximum, not a promise. returned counts this page, has_more tells you whether to keep going, and next_cursor is absent on the last page. |
| filters | Your filters, normalized. topics and organization_ids are always there, as empty lists when unset; from, to and cursor appear only when you sent them. |
| warnings | Empty, or one entry with code SOURCE_CATALOG_WARNING when a filter is missing from the current source catalog. |
| meta | schema_version (1.0), request_id (quote it in support tickets), request_credit_cost, credits_remaining after the charge, and generated_at and profiles_resolved_at where available. |
Inside a signal
Every item has event, organization and source. parties, register_entry and details appear only when the source has something to say.
| Field | Contents |
|---|---|
| event.id | Pass it to the detail endpoint, and use it to skip duplicates. |
| event.occurred_on, event.announced_on | Plain YYYY-MM-DD dates. Either can be missing, so never assume they are there. |
| event.date_basis | Which kind of date the source used: REGISTER_ENTRY, SOURCE_EVENT, ROLE_HOLDER_EVENT, COURT_PUBLICATION, PUBLICATION or OTHER. |
| organization, parties | Each has a current_profile with name, registration, legal_form, seat and status. |
| details | Extra fields that depend on the event type, tagged by details.schema. Money uses amount, currency and source_text. |
The detail endpoint returns the same item shape under a signal key, and it can return an event the list endpoint would not have shown.
Event types: event.type
Some events carry a finer-grained type in event.type, with a German and English label in event.type_name. Only role-holder events have one today, so most signals have neither field. Where no type has been released both are omitted, never null — check that event.type exists before reading it.
"event": {
"id": "0123456789abcdef0123456789abcdef",
"topic": "ROLE_HOLDER_CHANGES",
"topic_name": {
"de": "Führungswechsel",
"en": "Management changes"
},
"type": "ROLE_HOLDER_ENTRY",
"type_name": {
"de": "Funktionsträger eingetreten",
"en": "Role holder joined"
},
"occurred_on": "2026-07-25",
"announced_on": "2026-07-25",
"date_basis": "ROLE_HOLDER_EVENT"
}
These are the values emitted today. All of them sit under ROLE_HOLDER_CHANGES, which every account can read:
| event.type | What it means |
|---|---|
| ROLE_HOLDER_ENTRY | A role holder joined: a new appointment, an extra position, or a return. type_name: "Role holder joined". |
| ROLE_HOLDER_EXIT | A role holder left a position, including exits with no stated reason. type_name: "Role holder left". |
| ROLE_HOLDER_TRANSITION | One role holder replaced another in the same register entry. type_name: "Role holder transition". |
| ROLE_HOLDER_CHANGE | The details of an existing role holder changed, with nobody joining or leaving. type_name: "Role holder changed". |
- event.type_name only appears alongside event.type. It is a label object with a de and an en member — show it to people, but branch on event.type.
- event.type is not a filter. There is no type parameter, and sending one returns 422. Filter by topics and narrow down in your own code.
- A signal with no event.type today can carry one later without its id, topic or anything else changing.
What is never returned
- Null values, and any object left empty once they are gone. Empty lists survive.
- Source evidence: evidence_excerpt, evidence_text, evidence_redactions and evidence_text_status are always stripped.
- Internal detail: the fine-grained source code behind a topic, first_seen_at, detail_completeness and observed_identity.
- Detail fields outside the set defined for their schema, and signals about blocked companies. This runs after paging, so a page can be short while has_more stays true.
Pagination
Pagination is cursor-only, newest announcement date first. To continue, send pagination.next_cursor back unchanged together with exactly the same filters.
- 20 is the maximum page size, not a guarantee. Short and even empty pages are normal — unresolved profiles and blocked entities are dropped from a page.
- Keep paging while pagination.has_more is true, even right after an empty page. Stop when it is false — next_cursor is then absent rather than null.
- Delivery is at-least-once, so the same signal can turn up again on a later page. Skip duplicates using event.id.
- Every page is its own successful call at 20 credits, empty pages included. There is no total count and no jumping to page N.
Cursors belong to their query
Treat a cursor as opaque: do not build one, do not parse one, keep it under 8192 characters. Changing a filter while paging is unsupported. Usually you get 422 INVALID_CURSOR at 0 credits, but some swaps are accepted and then return a wrongly-offset page that still costs 20 credits. To change filters, start a fresh query without a cursor.
Paging safely
import requests
URL = "https://handelsregister.ai/api/v1/signals"
HEADERS = {"x-api-key": "YOUR_API_KEY", "Accept": "application/json"}
FILTERS = {"topics": "NEW_REGISTRATIONS,CAPITAL_CHANGES"}
seen = set()
cursor = None
while True:
params = dict(FILTERS) # identical filters on every page
if cursor:
params["cursor"] = cursor
response = requests.get(URL, headers=HEADERS, params=params)
response.raise_for_status() # 20 credits per successful page
page = response.json()
for signal in page["signals"]: # 0 to 20 items, short pages are normal
event_id = signal["event"]["id"]
if event_id in seen:
continue # at-least-once: repeats are expected
seen.add(event_id)
handle(signal)
if not page["pagination"]["has_more"]:
break # last page: next_cursor is absent
cursor = page["pagination"]["next_cursor"]
Credits
- A list page costs 20 credits, and so does a detail call. The catalog is free and never even checks your balance.
- Credits come off just before the response is sent, so meta.credits_remaining and X-Credits-Charged describe what actually happened.
- Below 20 credits you get 402 INSUFFICIENT_CREDITS before any work is done. If the deduction itself fails you get 402 BILLING_FAILED, nothing is charged and the data is discarded — retry the call.
- Failed requests are never charged: 401, 402, 403, 404, 422, 429 and 503 all cost 0 credits.
- The ledger records topics, organization_ids, from, to and whether a cursor was used. Detail calls record none.
Errors
Signals errors come back as JSON with a machine-readable code you can branch on. Next to it you get type, title, status, detail and meta. invalid_params is there only for validation errors. The 429 is the one exception, see below.
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
X-Credits-Charged: 0
X-Request-Id: req_01k1c2...
{
"type": "https://handelsregister.ai/problems/plan-required",
"title": "Plan upgrade required",
"status": 403,
"code": "PLAN_REQUIRED",
"detail": "Your current plan does not include the requested topic(s): INSOLVENCIES requires the Pro plan.",
"meta": {
"request_id": "req_01k1c2...",
"request_credit_cost": 0,
"credits_remaining": 95
}
}
| Status | code | When |
|---|---|---|
| 401 | UNAUTHORIZED | No credential, or an invalid one. credits_remaining is null and there is no X-Request-Id. |
| 402 | INSUFFICIENT_CREDITS | Your balance is below 20 credits. Nothing was fetched. The catalog never returns this. |
| 402 | BILLING_FAILED | The data was fetched but the deduction failed. Nothing was charged and no data comes back — retry the call. |
| 403 | UNAUTHORIZED | Unverified email address or a blocked account, indistinguishable on the wire. credits_remaining is null and there is no X-Request-Id. |
| 403 | PLAN_REQUIRED | You asked for a topic your plan does not include; detail names each locked topic and the plan it needs. The detail endpoint applies the same rule to the topic of the signal you fetched. |
| 403 | EVIDENCE_NOT_AVAILABLE | Reserved for source-evidence requests. No public parameter can trigger it. |
| 404 | NOT_FOUND | Signals is not available for this account at all: all three endpoints answer like an unknown route. |
| 404 | SIGNAL_NOT_FOUND | Detail endpoint only: no signal with this ID, or its subject is not served. |
| 422 | INVALID_REQUEST | Validation failed and invalid_params names the parameter, the catalog was called with a query parameter, or the source rejected the filter combination. |
| 422 | INVALID_CURSOR | The cursor cannot be used for this request. Start again without one. |
| 429 | — | You hit the shared limit of 60 requests per minute. This is the one Signals response with a different shape — see Rate limits below. |
| 503 | SIGNALS_UNAVAILABLE | The signals source is temporarily unavailable, or cursor pagination is. Safe to retry; Retry-After may be present. |
Response headers
- X-Credits-Charged: 5 or 0 — the authoritative statement of what the call cost.
- X-Request-Id: req_… matches meta.request_id. Quote it in support tickets. It is missing on the 401 and 403 above, and on the 429.
- Retry-After: only on 503, only when the source supplied a value, and capped at 5 seconds.
- Cache-Control: private, no-store, max-age=0, plus Pragma: no-cache and Vary: x-api-key. Every Signals response is private — never put one in a shared cache. The 429 is the exception and comes back as no-cache, private.
Rate limits
Authenticated requests share one bucket of 60 per minute with /v1/get-organization, /v1/search-organizations and /v1/fetch-document. Using it up on any of them limits the rest, including the free catalog.
Rate limiting happens before your request reaches Signals, so a 429 looks different from every other error: no code, no detail, no meta, and a body that depends on the Accept header you sent.
What a throttled request looks like
GET /api/v1/signals?topics=NEW_REGISTRATIONS
Accept: application/json
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Cache-Control: no-cache, private
{
"message": "Too Many Attempts."
}
- With Accept: application/json you get JSON with a short message. Branch on the 429 status code and do not parse the body.
- Without that header you get an HTML error page instead. Send Accept: application/json on every Signals request so a throttled one stays machine-readable.
- Do not depend on Retry-After or the X-RateLimit-* headers arriving. Back off on your own schedule; an immediate retry only burns the next window.
- Neither X-Credits-Charged nor X-Request-Id is present. A 429 still costs 0 credits, but nothing in the response says so.