signals
Endpoints

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.

Two things that catch people out: Leaving out topics does not give you every topic — you silently get only the topics your plan includes. And an unknown query parameter fails the whole request with 422 instead of being ignored.

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 is an open enum. More values will appear over time, with no version bump and no warning. Never switch exhaustively on event.type, and never drop a signal because its type is unfamiliar. Map what you know and fall back to event.topic, a fixed list that will not change. AUTHORITY_CHANGE is defined but not emitted today.
  • 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.