Skip to content

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.

StatusMeaningWhat to do
200 OKRequest succeeded. Most Integration API creates, updates and deletes return 200 with the affected object; a project or work order DELETE returns the deleted objectProcess the response data
201 CreatedA new resource was created. Returned by the v5 employee create and create-employment calls, and by an absence upsert that created a new absenceProcess the response data
204 No ContentSuccess, 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 userNothing to read
400 Bad RequestInvalid request parameters or validation errorCheck your request body or query parameters
401 UnauthorizedToken missing, expired, issued for a different scope than the endpoint requires, or from the other environment; or the API client itself is disabled or expiredCheck the scope you asked for at the token endpoint, then refresh — see Authentication
403 ForbiddenToken is valid and accepted, but the client may not touch this dataCheck whether the project/company belongs to your client, and whether the integration is enabled for it
404 Not FoundEndpoint or resource doesn’t existCheck the URL and resource ID
429 Too Many RequestsRate limit exceededWait and retry with exponential backoff
500 Internal Server ErrorServer-side error. Some request errors also return 500; see 500 errors that are not transientRead message. Retry once if it looks transient; contact support if it persists
501 Not ImplementedThe endpoint exists but isn’t implementedDon’t retry; check the endpoint’s reference page

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.

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:

FieldDescription
fieldThe request field the error is about. Left out when the error isn’t about one field
categoryError category, e.g. User_InvalidInputError, User_NotFoundError, General_Error
codeMachine-readable error code, e.g. ValidationError, PhoneNumberIsAlreadyInUse
additionalDataExtra data for some errors, otherwise null
messageHuman-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.

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-…"
}

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-AuthenticateMeaningFix
Bearer error="invalid_token", error_description="The audience '<scope>' is invalid"Wrong scope — <scope> lists the scope(s) your token was issued forRe-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 serverFetch the token from the identity host that matches your API host
Bearer error="invalid_token", error_description="The token expired at '<timestamp>'"Expired tokenRefresh the token
Bearer error="invalid_token" (no description)Malformed or unparseable tokenCheck you are sending the access_token value, unmodified
Bearer (no error)No Authorization header sentSend 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:

Terminal window
# Wrong — the API request carries a scope header, the token doesn't have the scope
curl -H "Authorization: Bearer $TOKEN" -H "Scope: reportingapiv1" ...
# Right — ask for the scope when you fetch the token
curl -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.

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-scripts
if 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.

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.

For transient errors (429, 502, 503, 504), implement exponential backoff:

  1. Wait 1 second, retry
  2. Wait 2 seconds, retry
  3. Wait 4 seconds, retry
  4. Wait 8 seconds, retry
  5. 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 time
import 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 response

Don’t retry 400/404/501 — those indicate a problem with the request itself.