KausateKausate Docs
Guides

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"
}
FieldTypeContract
detailstringHuman-readable explanation. Wording is not part of the contract — do not match on it.
codestringStable dotted identifier. This is what you branch on. Always present on every error body.
request_idstringCorrelation 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 Accept header, an unknown path, the OAuth 401 challenge with its empty body and WWW-Authenticate header) 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 no code. 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 detail it 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

StatusMeaningRetry?
400Invalid request — malformed parameters or body, or the capability is not offered for that jurisdictionNo, fix the request
401No API key was suppliedNo, send X-API-Key
403The API key is invalid, or valid but not entitled to this resource or featureNo
404Company, document, order or dataset not found — including "no data for this company"No
405Wrong HTTP method for that pathNo, fix the request
408The upstream registry did not answer in time (sync mode only)Yes, or switch to async
409The resource is in a state that rejects the request — including a locked accountNo
410The referenced state has expired and cannot be recoveredNo, start a new order
422Well-formed but unprocessable — request validation, or a valid entity whose data cannot be servedNo
429An upstream registry rate-limited us — honour the Retry-After headerYes, after Retry-After
500Unexpected failure on our sideYes, with backoff; quote request_id to support
501The operation exists for that jurisdiction but is not wired up yet (rare)No
502Transient upstream-registry failureYes, shortly
503The upstream registry is degraded or unreachableYes, 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-Key header401 with auth.unauthorized.
  • A key that is present but not valid403 with auth.forbidden.
  • A valid key without entitlement to that jurisdiction, capability or feature → 403 with auth.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.

NamespaceMeaning
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:

CodeWhen you get it
company.does_not_existAn authoritative registry lookup proved that the company does not exist
company.not_foundNo such company in the registry or our index
company.not_found.handelsregisterThe German Handelsregister search returned no usable match
company.not_found.transparenzregisterFound in Handelsregister, absent from Transparenzregister
company.data_not_diffusableThe entity exists but the registry will not diffuse its data
company.temporarily_unavailableCompany data is momentarily unavailable — retry
company.legacy_register.registro_ditteItalian entity in the historic Registro Ditte; only historic reports
document.not_foundThe requested document does not exist
document.not_found.empty_treeThe registry's document tree is empty for this company
document.not_found.esefThe filing exists but is published in ESEF, readable only through the register's own viewer
document.not_found.handelsregisterHandelsregister has no such document
document.not_found.unternehmensregisterUnternehmensregister has no such document
document.temporarily_unavailableThe document exists but is momentarily unavailable
document.temporarily_unavailable.empty_treeThe document tree was empty on this attempt — retry
document.processing_pendingThe registry is still preparing the document
document.processing_pending.basketThe registry is still preparing a batched document request
document.processing_failedWe retrieved the document but could not process it
document.extraction_failedWe could not extract valid structured data from the document
document.retrieval_failedThe document could not be retrieved from the registry
document.too_largeThe registry served a document too large for us to process
shareholders.not_availableNo shareholder data for this company
shareholders.not_available.joint_stockJoint-stock company — shares are not tracked by the registry
shareholders.not_available.listed_entityListed / large holding company — cap table not reconstructable
shareholders.not_available.no_registry_dataNo ownership could be extracted from the available filings
shareholders.not_available.sole_proprietorshipSole proprietorship — it has no shareholders
shareholders.nothing_to_extendThe graph has no expandable nodes matching the request
shareholders.extension_not_priceableNone of the requested nodes' jurisdictions offer extension yet
shareholders.resolution_failedWe could not resolve the ownership graph
shareholders.source_unresolvableA recorded data source could not be attributed to a known register
financials.not_availableNo financial data for this company
financials.not_available.no_accounts_filedThe registry has no financial account filings for this company
financials.not_available.unparseable_filingsAccount filings exist, but none could be converted to structured financial data
capability.not_availableNot offered for the requested jurisdiction
discrepancy.entity_ineligibleThe entity cannot be the subject of a discrepancy report
discrepancy.evidence_unavailableAn evidence attachment is gone or is not a valid PDF — nothing filed
discrepancy.identity_mismatchThe resolved register entity does not match the asserted identity
discrepancy.submission_outcome_unknownThe filing may have gone through — verify before resubmitting
request.invalidInvalid request parameters
request.invalid.too_many_keywordsToo many keywords in the search query
request.invalid.webhook_destinationThe webhook URL is unreachable or resolves to a private address
request.session_expiredThe encrypted continuation payload has expired
request.timeoutThe request timed out
request.method_not_allowedWrong HTTP method for that path
request.too_largeRequest body too large
request.unsupported_media_typeUnsupported Content-Type
auth.unauthorizedNo API key was supplied
auth.forbiddenThe API key is invalid, or valid but not entitled
auth.account_lockedThe account or resource is locked
auth.customer_credentials_not_configuredYour organization's data-source credentials are missing or invalid
billing.credit_limit_exceededYour credit balance or postpaid limit is exhausted — top up
order.continuation_state_not_foundThe referenced order's continuation state is gone — order a new graph
order.paid_but_undeliverableThe order was placed but its result could not be retrieved
resource.not_foundGeneric not-found
resource.conflictGeneric conflicting state
resource.goneThe resource existed and is permanently gone
source.unavailableThe upstream registry is unavailable
source.unavailable.doresThe New Jersey registry is unreachable
source.unavailable.handelsregisterThe German Handelsregister is unavailable
source.unavailable.krsThe Polish KRS is unavailable
source.unavailable.lbrThe Luxembourg LBR portal is unavailable
source.unavailable.secpThe Pakistan SECP registry is unavailable
source.unavailable.unternehmensregisterThe German Unternehmensregister is unavailable
source.maintenance.krsThe Polish KRS is in a scheduled maintenance window
source.network_errorNetwork failure reaching the registry
source.network_error.unternehmensregisterNetwork failure reaching the Unternehmensregister
source.rate_limitedThe registry rate-limited us — see Retry-After
source.rate_limited.companies_houseCompanies House rate-limited us
source.server_errorThe registry returned a server error
source.server_error.companies_houseCompanies House returned a server error
source.rejected.transparenzregisterThe Transparenzregister portal rejected the request
source.response_unprocessableThe registry's response could not be parsed
source.temporaryA transient upstream condition — retry
webhook.delivery_failedYour webhook endpoint rejected the delivery — we keep retrying
internal.unexpectedAn 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":

statusTerminal?error / codeWhat happened
runningnoabsentStill processing — keep polling, or wait for the webhook
completedyesabsentSuccess; the payload is in result
failedyespresentThe order ran and failed — branch on code
canceledyespresentThe order was canceled and stopped cleanly
terminatedyespresentThe order was forcefully stopped
timedOutyespresentThe 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 surfaceWhere the code appears
get_order_resultthe 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

On this page