Error Handling
The Ditio API uses standard HTTP status codes. This page covers the error bodies of the Integration API and the Data Extraction API, the errors integrators hit most often, and when to retry.
HTTP status codes
Section titled “HTTP status codes”| Status | Meaning | What to do |
|---|---|---|
200 OK | Request succeeded. Most Integration API creates, updates and deletes return 200 with the affected object; a project or work order DELETE returns the deleted object | Process the response data |
201 Created | A new resource was created. Returned by the v5 employee create and create-employment calls, and by an absence upsert that created a new absence | Process the response data |
204 No Content | Success, no response body. Returned by some deletes and actions, for example deleting a document, a machine, an absence or a v5 employee, and enabling or disabling a v4 user | Nothing to read |
400 Bad Request | Invalid request parameters or validation error | Check your request body or query parameters |
401 Unauthorized | Token missing, expired, issued for a different scope than the endpoint requires, or from the other environment; or the API client itself is disabled or expired | Check the scope you asked for at the token endpoint, then refresh — see Authentication |
403 Forbidden | Token is valid and accepted, but the client may not touch this data | Check whether the project/company belongs to your client, and whether the integration is enabled for it |
404 Not Found | Endpoint or resource doesn’t exist | Check the URL and resource ID |
429 Too Many Requests | Rate limit exceeded | Wait and retry with exponential backoff |
500 Internal Server Error | Server-side error. Some request errors also return 500; see 500 errors that are not transient | Read message. Retry once if it looks transient; contact support if it persists |
501 Not Implemented | The endpoint exists but isn’t implemented | Don’t retry; check the endpoint’s reference page |
Error response formats
Section titled “Error response formats”The Integration API and the Data Extraction API return different error
bodies. Both are JSON with camelCase property names. Read message and
errors; branch your code on the status code, not on the message text.
Integration API
Section titled “Integration API”Errors from $DITIO_API_BASE carry a message, an errors array, and an
error object that repeats the message and status for older clients:
{ "error": { "message": "Project not found", "type": "NotFound", "code": 404, "validationErrors": [] }, "message": "Project not found", "errors": []}Each entry in errors has this shape:
| Field | Description |
|---|---|
field | The request field the error is about. Left out when the error isn’t about one field |
category | Error category, e.g. User_InvalidInputError, User_NotFoundError, General_Error |
code | Machine-readable error code, e.g. ValidationError, PhoneNumberIsAlreadyInUse |
additionalData | Extra data for some errors, otherwise null |
message | Human-readable description, or null |
Validation errors from the v5 Employees API list one entry per invalid
field. These responses have error set to null:
{ "error": null, "message": "Validation failed", "errors": [ { "field": "Phone", "category": "User_InvalidInputError", "code": "ValidationError", "additionalData": null, "message": "The Phone field is required." } ]}An unexpected error returns 500 with one entry whose category is
General_Error and whose code ends in _Unknown. The reason, when there is
one, is in message.
Data Extraction API
Section titled “Data Extraction API”Errors from $DITIO_REPORTING_BASE carry a message and an errors array
whose entries have code, message and, when relevant, field:
{ "message": "Only admin users can extract metadata", "errors": [ { "code": "Reporting_Forbidden", "message": "Only admin users can extract metadata" } ]}An unexpected error returns 500 with code Reporting_Unknown. The
reason, when there is one, is in the top-level message:
{ "message": "ModifiedBefore must be greater than ModifiedSince", "errors": [ { "code": "Reporting_Unknown", "message": "An unknown error occured in the reporting module. Please report to Ditio support" } ]}A query parameter that can’t be read as its type (for example
FromDateTime=yesterday) is rejected before the endpoint runs, with a
400 in the standard ASP.NET Core ValidationProblemDetails format.
Here errors is an object keyed by parameter name:
{ "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", "title": "One or more validation errors occurred.", "status": 400, "errors": { "FromDateTime": ["The value 'yesterday' is not valid for FromDateTime."] }, "traceId": "00-…"}Common errors
Section titled “Common errors”401 Unauthorized — wrong scope
Section titled “401 Unauthorized — wrong scope”Each API validates the scope as the token’s audience, so the token is
rejected during authentication rather than authorisation. That means you get
401, and the usual 401 advice — “refresh the token” — will loop forever,
because the fresh token has the same wrong scope as the old one.
Read the WWW-Authenticate response header to tell the cases apart:
WWW-Authenticate | Meaning | Fix |
|---|---|---|
Bearer error="invalid_token", error_description="The audience '<scope>' is invalid" | Wrong scope — <scope> lists the scope(s) your token was issued for | Re-request the token with the scope this endpoint needs |
Bearer error="invalid_token", error_description="The signature key was not found" | The token came from the other environment — e.g. an identity.ditio.dev token sent to integration.ditio.no/reporting: it was issued by the other environment’s identity server | Fetch the token from the identity host that matches your API host |
Bearer error="invalid_token", error_description="The token expired at '<timestamp>'" | Expired token | Refresh the token |
Bearer error="invalid_token" (no description) | Malformed or unparseable token | Check you are sending the access_token value, unmodified |
Bearer (no error) | No Authorization header sent | Send Authorization: Bearer <token> |
The fix is always at the token endpoint, never on the API request —
scope is a property of the token, and Ditio APIs ignore a Scope header on
the request itself:
# Wrong — the API request carries a scope header, the token doesn't have the scopecurl -H "Authorization: Bearer $TOKEN" -H "Scope: reportingapiv1" ...
# Right — ask for the scope when you fetch the tokencurl -X POST "$DITIO_IDENTITY_BASE/connect/token" \ -d "grant_type=client_credentials" \ -d "client_id=$DITIO_CLIENT_ID" \ -d "client_secret=$DITIO_CLIENT_SECRET" \ -d "scope=reportingapiv1"See Which scope for which host for the scope each base URL expects.
401 Unauthorized — token expired
Section titled “401 Unauthorized — token expired”Tokens expire; their lifetime is in Token Expiry. When one expires, request a new token and retry once:
# Fragment: `scope`, `url` and `response` come from your own request code.# get_token: see /getting-started/authentication/#getting-a-token-in-your-scriptsif response.status_code == 401: token = get_token(scope) # same scope (and credentials, if you pass them) as before response = requests.get(url, headers={"Authorization": f"Bearer {token}"}, timeout=30)If a retry with a brand-new token returns 401 again, stop retrying — the
token is being rejected for something other than expiry. Read
WWW-Authenticate to see which: wrong scope, wrong environment, or a
malformed token. If there is no WWW-Authenticate error but the body has a
message, see the next section.
401 Unauthorized — API client disabled or expired
Section titled “401 Unauthorized — API client disabled or expired”A 401 with a JSON body whose message says the client “either does not
exist or is disabled”, or that its emulation user “is not found or
disabled”, comes from the API client itself, not the token. Every call
fails the same way until an administrator fixes the client. The causes and
fixes are in When a client stops
working.
403 Forbidden — wrong company or integration not enabled
Section titled “403 Forbidden — wrong company or integration not enabled”A 403 means the token was accepted — right scope, valid signature, not
expired — but the client isn’t allowed to do what you asked. Common causes:
- The project or company doesn’t belong to your client.
- The integration isn’t enabled for that company or project.
- The API client is marked read-only and the request tried to write. This affects writes only; reads and extraction calls still work.
Requesting a different scope will not fix a 403.
400 invalid_scope — scope not granted to your client
Section titled “400 invalid_scope — scope not granted to your client”This one comes from the token endpoint, not from an API call:
{ "error": "invalid_scope" }It means your client isn’t granted one of the scopes you asked for. The
request is all-or-nothing — the identity server issues no token at all rather
than returning the scopes you are allowed. Ask only for scopes your client
has; reportingapiv1 in particular cannot be enabled self-service, so
contact [email protected] if you need it added.
500 errors that are not transient
Section titled “500 errors that are not transient”Some request errors return 500 today. The same request fails the same way
every time, so don’t retry them; read message and change the request.
Project delete blocked. DELETE /api/v4/integration/projects/{id}
returns 500 with a message such as
Kan ikke slette: Det finnes transaksjoner på prosjektet. (“cannot delete:
the project has transactions”). A project delete is refused while the project
has registrations (time registrations, load lists or terminal transactions);
otherwise it deletes the project with its work orders and plans. A delete on
a project another company shared with you only removes the share. To retire
a project that has registrations, deactivate it (active: false) instead.
Machines and users have check endpoints to call first:
is-machine-deletable, is-identity-deletable (Users v4) and deletable
(Employees v5).
Invalid sync window or date range. Data Extraction endpoints return
500 with code Reporting_Unknown when the filter breaks one of the rules
in Shared filter parameters.
The rule is named in message, for example
ModifiedBefore must be greater than ModifiedSince.
Retry strategy
Section titled “Retry strategy”For transient errors (429, 502, 503, 504), implement exponential
backoff:
- Wait 1 second, retry
- Wait 2 seconds, retry
- Wait 4 seconds, retry
- Wait 8 seconds, retry
- Give up and log the error
Retry a 500 at most once. If it comes back, it is most likely one of the
500 errors that are not transient;
read message instead of retrying again.
Retry automatically only GET requests and endpoints documented as upserts.
A create or a toggle (for example the weight-measure mark-* calls) may have
succeeded before the error reached you, so a blind retry can apply it twice.
import timeimport requests
TRANSIENT = (429, 502, 503, 504)
def api_call_with_retry(url, headers, max_retries=4): for attempt in range(max_retries + 1): response = requests.get(url, headers=headers, timeout=30) retry = (response.status_code in TRANSIENT or (response.status_code == 500 and attempt == 0)) # one retry for a 500 if not retry or attempt == max_retries: return response time.sleep(2 ** attempt) return responseDon’t retry 400/404/501 — those indicate a problem with the request itself.
Related
Section titled “Related”- Authentication — scopes and token refresh
- Pagination — resuming a paginated pull after an error