Error Handling
The Ditio API uses standard HTTP status codes. This page covers common errors and how to handle them.
HTTP status codes
Section titled “HTTP status codes”| Status | Meaning | What to do |
|---|---|---|
200 OK | Request succeeded | Process the response data |
201 Created | Resource created successfully | Applies to POST endpoints |
204 No Content | Success, no response body | Applies to DELETE and some PATCH endpoints |
400 Bad Request | Invalid request parameters or validation error | Check your request body or query parameters |
401 Unauthorized | Token missing, expired, or issued for a different scope than the endpoint requires | 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 | Retry after a short delay. Contact support if persistent |
Error response formats
Section titled “Error response formats”Simple errors include a message explaining what went wrong:
{ "error": "ArgumentIsNull", "message": "The 'ModifiedSince' parameter is required."}Validation errors from the v5 Integration API carry a structured errors
array:
{ "message": "Validation failed", "errors": [ { "category": "UserInvalidInputError", "code": "ValidationError", "field": "Phone", "message": "The Phone field is required." } ]}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 issuer '<host>' is invalid" | The token came from the other environment — e.g. an identity.ditio.dev token sent to core-api.ditio.app | 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=$CLIENT_ID" \ -d "client_secret=$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”Access tokens are short-lived (about 30 minutes by default). When one expires, request a new token and retry:
if response.status_code == 401: token = refresh_access_token() response = requests.get(url, headers={"Authorization": f"Bearer {token}"})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.
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 support@ditio.no if you need it added.
400 Bad Request — deletion blocked
Section titled “400 Bad Request — deletion blocked”Integration API DELETE endpoints refuse to delete resources with dependent
data (e.g. a project with time registrations). Deactivate instead
(active: false), or use the is-*-deletable / deletable check endpoints
first.
400 Bad Request — invalid incremental-sync window
Section titled “400 Bad Request — invalid incremental-sync window”Extraction endpoints validate the sync window: ModifiedBefore requires
ModifiedSince and must be strictly after it.
Retry strategy
Section titled “Retry strategy”For transient errors (429, 500, 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
import timeimport requests
def api_call_with_retry(url, headers, max_retries=4): for attempt in range(max_retries + 1): response = requests.get(url, headers=headers) if response.status_code in (429, 500, 502, 503, 504): if attempt < max_retries: time.sleep(2 ** attempt) continue return response return responseDon’t retry 400/404 — 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