Error codes
| HTTP | error.code | Meaning | What to do |
|---|---|---|---|
| 401 | missing_token | No API token provided. Expected: Authorization: Bearer <token>. | Correct the input: Send the header Authorization: Bearer <token>. |
| 401 | invalid_authorization_scheme | The Authorization scheme is not supported. Expected: Authorization: Bearer <token>. | Correct the input: Use the Bearer scheme (not Basic or similar). |
| 401 | invalid_token | The API token is invalid or has been revoked. | Correct the input: Check the token for typos. If it was revoked or its creator deactivated, create a new token in the management app and replace it in your program. Do not retry: repeated attempts with a wrong token count as failed attempts per IP address. |
| 401 | token_expired | The API token has expired. Please create a new token. | Correct the input: Create a new token in the management app and replace it in your program. |
| 401 | wrong_environment | The API token belongs to a different environment. | Correct the input: Use the token of the matching environment (live, stg, dev) — every token only works in its own. |
| 400 | token_in_url_forbidden | Tokens must not appear in the URL. Please use the Authorization header. | Correct the input: Remove the token from the URL and send it in the Authorization header. Treat tokens that appeared in a URL as compromised: revoke them. |
| 403 | insufficient_scope | The API token lacks the required permission (scope). | Correct the input: The token needs the scope named in error.required_scope: create a new token with that scope (an existing token cannot be extended). |
| 403 | insufficient_permissions | The creator of the API token does not have (or no longer has) the required permission. | Correct the input: Give the token creator the missing permission in the management app (or create a token owned by a person who has it). The token then works again without any change. |
| 400 | validation_error | The request is invalid. | Correct the input: error.details lists the error codes per field: correct the request and send it again. An unchanged retry fails again. |
| 400 | invalid_cursor | The cursor is invalid. Please start again without a cursor. | Correct the input: Start again without a cursor. Only pass cursors back unchanged and only to the endpoint they came from. |
| 410 | cursor_expired | The change-sync cursor has expired (changes are kept for 90 days). Please get a new starting cursor and fetch everything once. | Correct the input: Get a new starting cursor from …/changes/cursor, fetch everything once through the list and continue syncing from there. |
| 404 | not_found | Not found. | Correct the input: Check the id (does it come from your organization and the right list call?). The resource may also have been deleted. Do not retry. |
| 404 | route_not_found | This address does not exist in the API. All endpoints are under /v1. | Correct the input: Check path and spelling; all endpoints are under /v1 (see the reference). |
| 400 | range_too_large | The period is too long (maximum span per endpoint in the documentation). Please split it into smaller parts. | Correct the input: Split the period or selection into smaller parts (maximum span per endpoint in the reference; for file output error.details.max_rows). |
| 400 | field_not_allowed | Bank details (iban, bic, account_holder) cannot be submitted via the API. | Correct the input: Remove the fields named in error.details (row and field) from the request; bank details are entered in the management app. |
| 400 | idempotency_key_required | The Idempotency-Key header is missing. Please send a unique key per operation (e.g. a UUID). | Correct the input: Send an Idempotency-Key (e.g. a UUID) per operation and reuse it for retries of the same operation. |
| 409 | idempotency_conflict | This Idempotency-Key has already been used with different content. | Correct the input: The key belongs to different content. Generate a new key for a new operation; for a retry, send exactly the first body. |
| 409 | readings_locked | The meter readings of your organization are being edited right now (another entry, import or billing run). This call was aborted; please resend in a few seconds (with the same Idempotency-Key for calls that carry one). If a call is processed in several blocks (import commit from 25 records on), earlier blocks may already have been taken over: they report `already_transferred` when you resend. | Try again: The meter readings of your organization are being edited right now. The call was aborted; resend in a few seconds (with the same Idempotency-Key if the call carries one). For the import commit in several blocks, earlier blocks may already have been taken over (`already_transferred`). |
| 409 | appointment_conflict | There is already an active appointment of this type for this lease. It cannot be rescheduled via the API. | Correct the input: An active appointment of this type already exists for the lease; it cannot be rescheduled via the API. Change or cancel it in the management app. |
| 409 | meter_exists | This unit already has an active meter of this kind (electricity or water). Another one cannot be created via the API. | Correct the input: The unit already has an active meter of this kind. Read existing meters with GET /v1/meters?unit_id=…; replacing or changing a meter is done in the management app. |
| 422 | unit_not_configured | The unit is not set up for this kind (electricity 230 V, electricity 400 V or water). Please enter the connection on the unit in the management app first. | Correct the input: Enter the connection (electricity 230 V, electricity 400 V or water) on the unit in the management app, then repeat the call with the same Idempotency-Key. |
| 413 | payload_too_large | The request is too large. The limit applies per endpoint and is given in error.details.max_bytes (in bytes). | Correct the input: Bring the body below the limit of this endpoint (error.details.max_bytes, in bytes), e.g. spread records over several calls. |
| 409 | session_has_transferred_records | The import already contains transferred records and cannot be cancelled (tenants and leases refer to it). | Correct the input: An import with transferred records cannot be cancelled. It can only be handled further in the management app. |
| 415 | unsupported_media_type | Expected: Content-Type: application/json. | Correct the input: Send the header Content-Type: application/json. |
| 403 | sandbox_unavailable | The sandbox is currently not usable: the API access of your organization is not active (trial ended, add-on module API access not booked, or blocked by the operator) or the sandbox has expired. Check the add-on module under Administration → API access; with active access the same token works again. | Correct the input: Sandbox tokens only: check the add-on module API access in the management app (trial, booking); if the sandbox has expired, create a new one. The token itself is valid and works again once access is active. |
| 422 | sandbox_limit_reached | The sandbox has reached its data volume (at most 3000 tenants, prospects, damages, meter readings, appointments and import records combined). Remove unneeded test data in the management app or replace the sandbox; unused sandboxes are deleted after 30 days. | Correct the input: Sandbox only: remove test data in the management app or replace the sandbox ("Create new sandbox" after expiry); unused sandboxes are deleted after 30 days. |
| 422 | import_limit_reached | The maximum number of open imports has been reached (limit in error.details). Take over or delete open imports in the management app — or ask support for a higher limit. | Correct the input: Take over open imports in the management app or cancel them with DELETE /v1/tenant-imports/{id}; or ask support for a higher limit. |
| 422 | import_total_limit_reached | Your organization already has 500 imports (file and API, including completed ones). Please delete completed imports in the management app. | Support: Delete completed imports in the management app; if that is not enough, contact support with the request_id. |
| 429 | import_rate_limited | This token has reached the maximum number of new imports per hour (limit in error.details). Please try again later or ask support for a higher limit. | Try again: Try again later (hourly limit per token, limit and count in error.details); or ask support for a higher limit. |
| 400 | period_conflict | Time filter: please use exactly one form (period, date or from/to). | Correct the input: Use exactly one form of the time filter: period, date or from/to. |
| 400 | range_invalid | The time range is invalid (from must be before to). | Correct the input: from must be before to; swap or correct the values. |
| 400 | unsupported_format | This output format is not supported by this endpoint. | Correct the input: Choose one of the formats in error.details.supported_formats (json, csv); or fetch fewer columns or shorter texts. |
| 404 | file_not_available | No file is available for this entry. | Correct the input: There is no file for this entry; do not retry. The list shows per entry whether a file exists. |
| 409 | export_not_ready | The export is not ready yet. Please try again later. | Try again: Fetch the export again after a short wait (GET /v1/datev/exports/{id}) until status = ready. |
| 400 | webhook_url_invalid | The webhook URL is invalid or not allowed (https on port 443 only, public addresses only). | Correct the input: Use a publicly reachable https address on port 443 without credentials that resolves through DNS. |
| 409 | webhook_limit_reached | The maximum number of webhooks has been reached (limit in error.details). | Correct the input: Delete a webhook you no longer need (DELETE /v1/webhooks/{id}) or change an existing one. |
| 409 | delivery_not_replayable | This delivery cannot be sent again: only failed or abandoned deliveries of the last 29 days, no test events (reason in error.details.reason). | Correct the input: error.details.reason names the cause (not_failed, test_event, expired, paused); do not retry. For test_event, trigger a new test event. |
| 429 | rate_limited | Too many requests. Please retry after the time given in Retry-After. | Try again: Wait at least the time in the Retry-After header (seconds), then repeat unchanged; double the wait on further 429 responses. |
| 503 | service_unavailable | The API is temporarily unavailable. | Try again: Try again with a growing wait (backoff). Repeat writing calls with the same Idempotency-Key. If the error persists, contact support with the request_id. |
| 500 | internal_error | Internal error. Please contact support and quote the request_id. | Try again: Retry once after a short wait (for writes with the same Idempotency-Key); if the error persists, contact support with the request_id. Do not retry in a tight loop. |
“Try again” means: the request was fine, a later attempt can succeed — for writes with the same Idempotency-Key. “Correct the input” means: an unchanged retry fails again; change the request, the token or the permissions. “Support”: cannot be fixed on your side — report it with the request_id (see Security & support and the threat model).
If the API answers with 500 internal_error or 503 service_unavailable, or requests come back unusually slowly, check the status page first: it shows the current state of the API and of webhook delivery as well as ongoing incidents.
The error.message field of a response is currently in German; the English explanations above are for reference. Programs should always evaluate error.code.
Unknown or foreign ids are always answered with 404 not_found — never with “no permission”. This way the API does not reveal whether an id exists elsewhere. An identifier in the path that is not a UUID at all (/v1/invoices/abc) is a format error instead: 400 validation_error with { "id": ["invalid_uuid"] } in error.details (until 2026-10-07 the API answered 404 here). An address that does not exist (/v1/foo) returns 404 route_not_found in the same error format.
What error.details contains
error.details is optional and absent for most errors. Where it is present it has a fixed shape per error code; the OpenAPI describes it accordingly, and the responses of the individual endpoints in the reference name which details they return.
| error.code | Form of error.details | Example |
|---|---|---|
validation_error | object: field name → list of codes (codes see below) | { "from": ["required"], "foo": ["unknown_parameter"] } |
field_not_allowed | list with row and field, at most 50 entries | [{ "row": 1, "field": "iban" }] |
payload_too_large | maximum size of this endpoint in bytes (differs per endpoint, e.g. 1048576 for the import, 65536 for the takeover, 4096 for the bulk replay) | { "max_bytes": 1048576 } |
import_limit_reached | limit and count of open imports | { "limit": 50, "open": 50 } |
import_rate_limited | limit and count per hour and token | { "limit": 20, "used": 20 } |
webhook_limit_reached | limit and current count of webhooks per organization (in a sandbox only the limit: { "limit": 3 }) | { "limit": 10, "used": 10 } |
delivery_not_replayable | delivery status and reason | { "status": "succeeded", "reason": "not_failed" } |
rate_limited | only for webhook replay, test events and prospect submissions: limit (replay additionally retry_after); otherwise no details | { "limit": 1000, "retry_after": 1800 } |
range_too_large | only for file output (CSV/Excel): maximum rows; otherwise no details | { "max_rows": 200 } |
unsupported_format | allowed formats; for an Excel cell that is too long additionally reason, column and maximum length | { "supported_formats": ["json", "csv", "xlsx"] } |
Details of validation_error
With 400 validation_error, error.message states the first problem in plain words. error.details maps every affected field to a list of codes — the key is the name of the query parameter or body field, for lists in the body with the position (records[2].lease_start). The codes are stable; the message may change.
{
"success": false,
"error": {
"code": "validation_error",
"message": "Parameter „from“ fehlt (erwartet: YYYY-MM-DD).",
"details": { "from": ["required"], "to": ["required"], "foo": ["unknown_parameter"] }
},
"request_id": "req_…"
}| Code | Meaning |
|---|---|
required | required value is missing |
invalid_type | wrong type, e.g. a number sent as text |
invalid_format | value does not match the expected format |
invalid_uuid | not a valid identifier (UUID) |
invalid_date | not a valid date (YYYY-MM-DD) or ISO datetime |
invalid_value | value not allowed (the message lists the allowed values) |
too_small | number, text or list below the minimum size |
too_big | number, text or list above the maximum size |
too_many_ids | more than 100 identifiers in ids |
duplicate_ids | the same identifier more than once in ids |
unknown_parameter | unknown query parameter (its name is the key) |
unknown_field | unknown field in the body |
before_from | to is before from — the error code is then range_invalid |
invalid_json | the body is not valid JSON |
invalid_characters | illegal character (U+0000) in the URL or body |
Earlier raw codes (VALIDATION_INVALID_UUID, date_format_yyyy_mm_dd, date_or_datetime_iso, status_code_or_class, path_prefix_format) are still listed behind the new code for the parameters that used to return them — for positions in lists (ids[1], records[2].lease_start) additionally under the top-level field (ids, records), where they used to appear. English default texts of the validation library (such as “Too small: expected number to be >=1”) are no longer returned; from after to is reported as range_invalid everywhere instead of validation_error.