Errors
The error body, the full status set, and the stable dotted error codes to branch on
The error body
Every non-2xx response from the public REST API (api.kausate.com/v2) has the
same shape:
{
"detail": "Company not found in Transparenzregister",
"code": "company.not_found.transparenzregister",
"request_id": "req_01J8Z5V4Q2X9N7B3C6D1E0F2G4"
}| Field | Type | Contract |
|---|---|---|
detail | string | Human-readable explanation. Wording is not part of the contract — do not match on it. |
code | string | Stable dotted identifier. This is what you branch on. Always present on every error body. |
request_id | string | Correlation handle, also returned as the X-Request-Id header. Quote it to support to have the exact request looked up. |
On a 422 from request validation, detail is a list of per-field errors
instead of a string (FastAPI's standard validation shape). code and
request_id are unchanged.
Error bodies carry code on every API version, including 2025-04-01. It
is an additive sibling key — the same way request_id was added — so existing
parsers that read detail and ignore unknown keys keep working untouched.
The async order response and the webhook payload are a different
surface: they are versioned response models, so their code field exists from
2026-05-01 onward and is absent for a client pinned to 2025-04-01. See
Async orders and webhooks.
Two things deliberately fall outside this shape:
- The MCP server's HTTP surface. MCP is JSON-RPC over HTTP, not REST, so its
transport-level failures (a bad
Acceptheader, an unknown path, the OAuth401challenge with its empty body andWWW-Authenticateheader) follow the MCP and OAuth specifications rather than this body. MCP publishes the code through its own mechanisms instead — see MCP below. - The infrastructure probes (
/healthz,/readyz,/v1/health/check,/v1/health/ready), which answer with{"status": …}and nocode. They are not part of the customer API and are excluded from the OpenAPI spec.
Branch on code, not on detail
Codes are hierarchical, dot-separated, and published at full leaf depth. A
Transparenzregister miss is company.not_found.transparenzregister, not
company.not_found. Handle a whole family at once by matching a prefix:
// Handle every "company not found" variant, however specific.
if (error.code.startsWith("company.not_found")) {
showCompanyNotFound();
}
// Or split into segments and switch on the namespace.
const [namespace] = error.code.split(".");
if (namespace === "source") {
scheduleRetry();
}if error["code"].startswith("document.not_found"):
mark_document_unavailable()Two rules make prefix matching safe:
- Codes are many-to-one by design. Several internal conditions share one
code whenever they are indistinguishable to you. A code is never more
revealing than the
detailit accompanies. - Published codes are immutable. Changing or removing a code is treated as a breaking API change and is blocked by an append-only ledger in our build. New codes may be added, so always keep a default branch.
An unclassified failure emits internal.unexpected rather than omitting the
field, so error.code is never undefined.
HTTP statuses
| Status | Meaning | Retry? |
|---|---|---|
400 | Invalid request — malformed parameters or body, or the capability is not offered for that jurisdiction | No, fix the request |
401 | No API key was supplied | No, send X-API-Key |
403 | The API key is invalid, or valid but not entitled to this resource or feature | No |
404 | Company, document, order or dataset not found — including "no data for this company" | No |
405 | Wrong HTTP method for that path | No, fix the request |
408 | The upstream registry did not answer in time (sync mode only) | Yes, or switch to async |
409 | The resource is in a state that rejects the request — including a locked account | No |
410 | The referenced state has expired and cannot be recovered | No, start a new order |
422 | Well-formed but unprocessable — request validation, or a valid entity whose data cannot be served | No |
429 | An upstream registry rate-limited us — honour the Retry-After header | Yes, after Retry-After |
500 | Unexpected failure on our side | Yes, with backoff; quote request_id to support |
501 | The operation exists for that jurisdiction but is not wired up yet (rare) | No |
502 | Transient upstream-registry failure | Yes, shortly |
503 | The upstream registry is degraded or unreachable | Yes, after a longer backoff |
404 deliberately covers "this company has no shareholder data" and "this
company has no filed financials" — routine negative results, not faults.
A capability a jurisdiction does not offer is a 400, not a 501 — the
request names a combination that does not exist, and detail lists the
jurisdictions that do support it. Check
/v2/platform/jurisdictions
for what is offered rather than discovering it from an error. A capability that is coming
soon answers 403 instead. 501 is reserved for the rare case where the
jurisdiction does offer the operation but a specific path is not implemented yet;
neither status is worth retrying.
5xx responses are the only ones that indicate something is wrong on our side;
502 and 503 mean the registry is having problems, not Kausate.
Three further statuses are mapped but rare in day-to-day use: 413
(request.too_large), 415 (request.unsupported_media_type) and 504
(source.unavailable). Handle them through code like any other error.
Authentication: 401 vs 403
- No
X-API-Keyheader →401withauth.unauthorized. - A key that is present but not valid →
403withauth.forbidden. - A valid key without entitlement to that jurisdiction, capability or
feature →
403withauth.forbidden.
The last two share one code on purpose: distinguishing "that key does not
exist" from "that key exists but may not do this" to an unauthenticated caller
would confirm which keys are real. If your client needs to tell them apart —
for example to decide between "re-read the key from the vault" and "ask us for
access" — use detail for the human hint and treat any 403 as "do not
retry with this key".
Credit exhaustion is a 403, not a 402. It carries the dedicated
billing.credit_limit_exceeded code, so it is distinguishable from an
entitlement refusal without branching on the status. Kausate emits no 402.
Statuses and codes are orthogonal
The status tells you whether to retry. The code tells you what
happened. They are independent, so a code may legitimately appear at more than
one status — document.extraction_failed is a 500 when we extracted the
document and the data was unusable, and a 503 when the extraction run itself
failed and a retry is worth making; request.invalid covers both a malformed
request (400) and a well-formed one that fails validation (422).
Branch on the code for what to tell your user, and on the status class for what to do next. Never assume one implies the other.
Code vocabulary
Namespaces first — matching a namespace prefix is usually all a client needs.
| Namespace | Meaning |
|---|---|
company. | Something about the company itself (absent, not diffusable, …) |
document. | A specific document could not be produced |
shareholders. | Ownership data could not be produced |
financials. | Financial statements could not be produced |
capability. | The operation is not offered for that jurisdiction |
discrepancy. | A Transparenzregister discrepancy report could not be filed |
request. | Something about your request |
auth. | Credentials, entitlement, or a locked resource |
billing. | Credits and payment |
order. | The lifecycle of an async order |
resource. | Generic resource state (not found, conflict, gone) |
source. | The upstream registry (unavailable, rate-limited, network, …) |
internal. | An unclassified failure on our side |
The codes currently published:
| Code | When you get it |
|---|---|
company.does_not_exist | An authoritative registry lookup proved that the company does not exist |
company.not_found | No such company in the registry or our index |
company.not_found.handelsregister | The German Handelsregister search returned no usable match |
company.not_found.transparenzregister | Found in Handelsregister, absent from Transparenzregister |
company.data_not_diffusable | The entity exists but the registry will not diffuse its data |
company.temporarily_unavailable | Company data is momentarily unavailable — retry |
company.legacy_register.registro_ditte | Italian entity in the historic Registro Ditte; only historic reports |
document.not_found | The requested document does not exist |
document.not_found.empty_tree | The registry's document tree is empty for this company |
document.not_found.esef | The filing exists but is published in ESEF, readable only through the register's own viewer |
document.not_found.handelsregister | Handelsregister has no such document |
document.not_found.unternehmensregister | Unternehmensregister has no such document |
document.temporarily_unavailable | The document exists but is momentarily unavailable |
document.temporarily_unavailable.empty_tree | The document tree was empty on this attempt — retry |
document.processing_pending | The registry is still preparing the document |
document.processing_pending.basket | The registry is still preparing a batched document request |
document.processing_failed | We retrieved the document but could not process it |
document.extraction_failed | We could not extract valid structured data from the document |
document.retrieval_failed | The document could not be retrieved from the registry |
document.too_large | The registry served a document too large for us to process |
shareholders.not_available | No shareholder data for this company |
shareholders.not_available.joint_stock | Joint-stock company — shares are not tracked by the registry |
shareholders.not_available.listed_entity | Listed / large holding company — cap table not reconstructable |
shareholders.not_available.no_registry_data | No ownership could be extracted from the available filings |
shareholders.not_available.sole_proprietorship | Sole proprietorship — it has no shareholders |
shareholders.nothing_to_extend | The graph has no expandable nodes matching the request |
shareholders.extension_not_priceable | None of the requested nodes' jurisdictions offer extension yet |
shareholders.resolution_failed | We could not resolve the ownership graph |
shareholders.source_unresolvable | A recorded data source could not be attributed to a known register |
financials.not_available | No financial data for this company |
financials.not_available.no_accounts_filed | The registry has no financial account filings for this company |
financials.not_available.unparseable_filings | Account filings exist, but none could be converted to structured financial data |
capability.not_available | Not offered for the requested jurisdiction |
discrepancy.entity_ineligible | The entity cannot be the subject of a discrepancy report |
discrepancy.evidence_unavailable | An evidence attachment is gone or is not a valid PDF — nothing filed |
discrepancy.identity_mismatch | The resolved register entity does not match the asserted identity |
discrepancy.submission_outcome_unknown | The filing may have gone through — verify before resubmitting |
request.invalid | Invalid request parameters |
request.invalid.too_many_keywords | Too many keywords in the search query |
request.invalid.webhook_destination | The webhook URL is unreachable or resolves to a private address |
request.session_expired | The encrypted continuation payload has expired |
request.timeout | The request timed out |
request.method_not_allowed | Wrong HTTP method for that path |
request.too_large | Request body too large |
request.unsupported_media_type | Unsupported Content-Type |
auth.unauthorized | No API key was supplied |
auth.forbidden | The API key is invalid, or valid but not entitled |
auth.account_locked | The account or resource is locked |
auth.customer_credentials_not_configured | Your organization's data-source credentials are missing or invalid |
billing.credit_limit_exceeded | Your credit balance or postpaid limit is exhausted — top up |
order.continuation_state_not_found | The referenced order's continuation state is gone — order a new graph |
order.paid_but_undeliverable | The order was placed but its result could not be retrieved |
resource.not_found | Generic not-found |
resource.conflict | Generic conflicting state |
resource.gone | The resource existed and is permanently gone |
source.unavailable | The upstream registry is unavailable |
source.unavailable.dores | The New Jersey registry is unreachable |
source.unavailable.handelsregister | The German Handelsregister is unavailable |
source.unavailable.krs | The Polish KRS is unavailable |
source.unavailable.lbr | The Luxembourg LBR portal is unavailable |
source.unavailable.secp | The Pakistan SECP registry is unavailable |
source.unavailable.unternehmensregister | The German Unternehmensregister is unavailable |
source.maintenance.krs | The Polish KRS is in a scheduled maintenance window |
source.network_error | Network failure reaching the registry |
source.network_error.unternehmensregister | Network failure reaching the Unternehmensregister |
source.rate_limited | The registry rate-limited us — see Retry-After |
source.rate_limited.companies_house | Companies House rate-limited us |
source.server_error | The registry returned a server error |
source.server_error.companies_house | Companies House returned a server error |
source.rejected.transparenzregister | The Transparenzregister portal rejected the request |
source.response_unprocessable | The registry's response could not be parsed |
source.temporary | A transient upstream condition — retry |
webhook.delivery_failed | Your webhook endpoint rejected the delivery — we keep retrying |
internal.unexpected | An unclassified failure on our side |
Async orders and webhooks
For an async order the HTTP response is a 200 — the failure lives in the
payload. Check status, then read error and code the same way:
{
"status": "failed",
"error": "Data source temporarily unavailable",
"code": "source.unavailable"
}status is one of six values. Four of them are terminal failures, and all
four carry error and code — so switch on the full set, and treat anything
you do not recognise as terminal rather than as "still running":
status | Terminal? | error / code | What happened |
|---|---|---|---|
running | no | absent | Still processing — keep polling, or wait for the webhook |
completed | yes | absent | Success; the payload is in result |
failed | yes | present | The order ran and failed — branch on code |
canceled | yes | present | The order was canceled and stopped cleanly |
terminated | yes | present | The order was forcefully stopped |
timedOut | yes | present | The order exceeded its time limit |
switch (order.status) {
case "running":
return poll();
case "completed":
return use(order.result);
default:
// failed | canceled | terminated | timedOut — all carry code + error
return handle(order.code, order.error);
}Webhook payloads carry the same status, error and code.
code on this surface is versioned. It is present from 2026-05-01
onward on async order responses and webhook payloads, and absent for a client
pinned to 2025-04-01 — reading payload.code.startsWith(…) there throws.
Send Kausate-Version: 2026-05-01 (or upgrade your pinned default) to use it;
until then, branch on status and display error. The sync error body is
not versioned and carries code on every version.
MCP
The MCP server publishes the same vocabulary, as data wherever the protocol has somewhere to put it:
| MCP surface | Where the code appears |
|---|---|
get_order_result | the code field of the returned object, beside error |
tasks/get | _meta["com.kausate/error"] = { "code": … } |
tasks/result | _meta["com.kausate/error"], beside the related-task pointer |
a JSON-RPC error (tasks/* failures) | error.data = { "code": … } |
A failing tool call is the one exception. MCP delivers it as a plain text block with no structured channel, so the code is emitted as a delimited marker at the very start of the message:
[code=document.temporarily_unavailable] Document is temporarily unavailable — please reach out to the Kausate support team at support@kausate.com so we can help. Quote Order ID: ord_…; Request ID: req_….Match it anchored — /^\[code=([a-z0-9_.]+)\] / — and treat the rest of
the string as display text. The marker is always first when present; it is
absent only when there is no code to report.
One MCP failure precedes the marker: argument validation. If a tools/call
names an argument the tool does not declare, or sends one of the wrong type, the
MCP framework rejects it before our code runs and returns its own validation
text with no [code=…] prefix. Treat an unmarked tool error as a malformed
call — the message names the offending argument.
Why upstream failures happen
Kausate fetches directly from official government registries. Those systems are
inherently unreliable: they rate-limit, they take scheduled and unscheduled
maintenance windows, and some queries take 30+ seconds under load. That is why
source.* codes and 429/502/503 statuses exist as a distinct family from
internal.unexpected — they tell you the retry is worth making. For anything
long-running, prefer asynchronous requests with webhooks (or polling by
orderId): our retry logic absorbs the transient failures for you.
Last updated on