Welcome to the Intempus REST API. This document contains how to read, create, update and delete data on Intempus using common server-side development environments. While Intempus' API supports all HTTP clients, we believe most integrations with Intempus will be data synchronization processes that are run as background processes.
All support requests have to be in English.
If you encounter any problems with the API, contact us at api@intempus.dk. We intend to answer within one working day. Please make sure to describe the problem along with the full url, headers and body sent and the response and statuscode received. Example:
GET intempus.dk/web/v1/work_category/
Headers:
Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
Content-Type: application/json
Body: empty
Statuscode 401
Body: Empty
To help us understand what you need, please also include a short description of your application.
If you received an error message with an incident ID (see Error responses below), please include the incident ID as text (not a screenshot).
In addition to our REST API, we are developing webhook support so Intempus can push events to your systems in real time. The feature is currently in beta and may change as we refine behavior and delivery but we will notify affected customers before making any breaking changes.
If you need help or have a use case that is not currently supported, contact us at api@intempus.dk with a short description and we will assist.
Follow these steps to receive webhook requests to your system:
First, see which events we support by calling the webhook event type endpoint. Please reach out to us if you need an event that is not supported.
For security reasons, we send webhook requests only to servers
allowing webhook requests coming from us. A server can opt-in by
creating a /.well-known/webhook-authorized-senders.json
file with the following content:
{
"authorized-senders": [
"intempus.dk"
]
}
For more technical details and explanation of the security benefits you can read the IANA registration and full webhook-authorized-senders.json specification.
Use our webhook configuration API endpoints to subscribe to events and to configure the URL we should send events to. Currently the requests we send contain the event metadata and the id of the object affected by the event. For example we will send the following request when the Case object with id 1 gets updated:
POST https://your-domain-name.test/your-receiving-endpoint/
Headers:
Content-Type: application/json
Body:
{
"event": {
"name": "Case.updated",
"uuid_v4": "a29ce5c4-de75-4d12-84be-0d5a850ef60a",
"timestamp": "2025-09-01T12:00:00.000000Z",
"logical_timestamp": 5436995
},
"data": {
"id": 1
}
}
Connect an external application to Intempus with OAuth2 authorization and public-key (private_key_jwt) authentication.
private_key_jwt to obtain API access tokens and call Intempus APIs.| ID | Created by | Purpose |
|---|---|---|
client_id |
Intempus support, when the integrator is registered | Identifies the integrator app during authorize and consent |
customer_client_id |
Intempus, during authorize and consent | Identifies this customer's grant to the integrator. Used for api_token and rotate_public_key |
customer_client_id is a different value from the integrator client_id.
| Token | Valid for | Purpose |
|---|---|---|
| Temporary access token | 2 minutes (expires_in: 120) |
Setup only. Returned by token (step 2), scoped to sync_public_key, and used once to register a public key (step 3) |
| API access token | 1 hour (expires_in: 3600) |
Accessing Intempus resources. Returned by api_token (step 4). Request a new one when it expires; no refresh token is issued |
Use the expires_in value in each token response (seconds) rather than hard-coding these durations.
Contact the Intempus Support team at support@intempus.dk to register your application. Please include the following details in your request:
Result: a client_id issued to the integrator. The redirect URI registered here must be the callback URL used in later steps. Intempus also configures the scopes shown to the customer during authorization.
The customer logs in to the integrator application. The integrator then sends the customer to Intempus.
PKCE is required (RFC 7636). Before redirecting, generate a random code_verifier (43–128 characters from [A-Za-z0-9-._~]) in your backend and keep it there. It must never be exposed to the browser. Compute:
code_challenge = BASE64URL(SHA256(code_verifier))
per RFC 7636 section 4.2. Send code_challenge with code_challenge_method=S256. You will present the original code_verifier when exchanging the code in step 2. This binds the token exchange to the same party that started authorization, so a code intercepted in transit cannot be redeemed by anyone else.
GET https://intempus.dk/oauth/?response_type=code&state={state}&client_id={client_id}&redirect_uri={redirect_uri}&code_challenge={code_challenge}&code_challenge_method=S256
Required query parameters:
response_type — must be codestate — integrator-generated value used to validate the callbackclient_id — integrator client_id from step 0redirect_uri — must match a redirect URI registered for the applicationcode_challenge — PKCE challengecode_challenge_method — only S256 is acceptedIf the customer is not logged in, they are redirected to the Intempus login page. After login, they see the authorization page and can grant access to the configured scopes.
After the customer clicks Authorize, Intempus redirects to the integrator redirect_uri with:
code — short-lived authorization code used to obtain a temporary access tokenstate — must match the value sent in the authorization requestGET {redirect_uri}?code={code}&state={state}
Validate state, then exchange the authorization code for a temporary access token used only for public key registration. Present the code_verifier generated in step 1. The request body must be application/x-www-form-urlencoded, per RFC 6749 section 4.1.3. A JSON body is rejected with invalid_request. The response is always JSON.
POST https://intempus.dk/api/v1/access_token/token/
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&client_id=<your_client_id>&code=<code>&redirect_uri=<redirect_uri>&code_verifier=<code_verifier>
redirect_uri must match the URI used to obtain the authorization code. code_verifier must be the exact value whose SHA-256 hash was sent as code_challenge in step 1. The exchange is rejected with invalid_grant if it is missing or does not match.
Response:
{
"access_token": "<temp_access_token>",
"token_type": "Bearer",
"expires_in": 120,
"scope": "sync_public_key"
}
This token is valid for 2 minutes, scoped only to sync_public_key, and is only used to register a public key.
Register an RSA public key for server-to-server authentication. Each key is referenced by a unique kid, which is later sent in the JWT header so Intempus can select the correct key.
RS256, RS384, RS512kid must be unique per public keyA newly registered public key has no expiry. It remains valid until it is rotated (see Rotate a public key).
The request body must be application/x-www-form-urlencoded. access_token is presented as a form field, per RFC 6750 section 2.2. JSON bodies are rejected.
POST https://intempus.dk/api/v1/access_token/register_public_key/
Content-Type: application/x-www-form-urlencoded
public_key=-----BEGIN PUBLIC KEY-----%0AMIIC...%0A-----END PUBLIC KEY-----&algorithm=RS256&client_id=<your_client_id>&kid=<unique_key_id>&access_token=<temp_access_token>
Response:
{ "customer_client_id": "<customer_client_id>" }
HTTP 200 means registration succeeded. Store customer_client_id together with the private key for later token requests.
Create a signed JWT (client_assertion) with the registered private key and the kid you registered. Then exchange it for an API access token. The request body is JSON.
POST https://intempus.dk/api/v1/access_token/api_token/
Content-Type: application/json
{
"customer_client_id": "<customer_client_id>",
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": "<jwt signed with the registered private key>"
}
Required JWT claims. client_id, iss, and sub must all be the integrator client_id.
client_id = integrator client_idiss = integrator client_idsub = integrator client_idaud = the token endpoint URL for the environment, including the trailing /iat (time in seconds since epoch)exp (time in seconds since epoch)jti (unique identifier for each request)| Environment | aud value |
|---|---|
| Production | https://intempus.dk/api/v1/access_token/api_token/ |
| Staging | https://staging-web.intempus.dk/api/v1/access_token/api_token/ |
Sign the JWT with the registered private key and include the matching kid in the JWT header.
from datetime import timedelta, timezone, datetime
import jwt
import requests
import uuid
private_key = load_private_key() # Your RSA private key (PEM as str/bytes)
now = datetime.now(timezone.utc)
payload = {
'client_id': 'your_client_id',
'iss': 'your_client_id',
'sub': 'your_client_id',
'aud': 'https://intempus.dk/api/v1/access_token/api_token/',
'iat': int(now.timestamp()),
'exp': int((now + timedelta(minutes=5)).timestamp()),
'jti': str(uuid.uuid4())
}
headers = { 'kid': 'your_kid' }
token = jwt.encode(payload, private_key, algorithm='RS256', headers=headers)
post_data = {
'customer_client_id': 'customer_client_id',
'client_assertion_type': 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
'client_assertion': token,
}
response = requests.post(
'https://intempus.dk/api/v1/access_token/api_token/',
json=post_data,
)
access_token = response.json().get('access_token')
const jwt = require('jsonwebtoken');
const fs = require('fs');
const axios = require('axios');
const { randomUUID } = require('crypto');
const privateKey = fs.readFileSync('path/to/private.key');
const now = Math.floor(Date.now() / 1000);
const payload = {
client_id: 'your_client_id',
iss: 'your_client_id',
sub: 'your_client_id',
aud: 'https://intempus.dk/api/v1/access_token/api_token/',
iat: now,
exp: now + 300,
jti: randomUUID()
};
const token = jwt.sign(payload, privateKey, {
algorithm: 'RS256',
header: { kid: 'your_kid' }
});
const data = {
customer_client_id: 'customer_client_id',
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: token
};
const response = await axios.post(
'https://intempus.dk/api/v1/access_token/api_token/',
data,
);
const accessToken = response.data.access_token;
Response:
{
"access_token": "<access_token>",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "read write"
}
Send the access token from step 4 in the Authorization header of every request to the Intempus REST API (https://intempus.dk/web/v1/...):
Authorization: Bearer <access_token>
The token acts on behalf of the customer who gave consent, so it only sees that customer's company data. Each resource requires the matching scope granted during consent:
read:<Model> for GET requests, for example read:WorkReport for /web/v1/work_report/write:<Model> for POST, PUT, PATCH and DELETE requestsFiltering, ordering, paging and incremental updates work the same way as described under General notes for the api.
Example: fetch work reports updated since the last sync, using cursor based pagination.
GET https://intempus.dk/web/v1/work_report/?pagination_type=cursor&limit=500&logical_timestamp__gt=5436995
Authorization: Bearer <access_token>
Accept: application/json
curl -H "Authorization: Bearer <access_token>" \
-H "Accept: application/json" \
"https://intempus.dk/web/v1/work_report/?pagination_type=cursor&limit=500"
import requests
BASE_URL = 'https://intempus.dk'
headers = {
'Authorization': f'Bearer {access_token}',
'Accept': 'application/json',
}
work_reports = []
url = f'{BASE_URL}/web/v1/work_report/?pagination_type=cursor&limit=500'
while url:
response = requests.get(url, headers=headers)
response.raise_for_status()
body = response.json()
work_reports.extend(body['objects'])
next_page = body['meta'].get('next')
url = f'{BASE_URL}{next_page}' if next_page else None
const axios = require('axios');
const BASE_URL = 'https://intempus.dk';
const headers = {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
};
const workReports = [];
let url = `${BASE_URL}/web/v1/work_report/?pagination_type=cursor&limit=500`;
while (url) {
const response = await axios.get(url, { headers });
workReports.push(...response.data.objects);
const nextPage = response.data.meta.next;
url = nextPage ? `${BASE_URL}${nextPage}` : null;
}
The access token is valid for 1 hour. When it expires, request a new one from api_token (step 4). Consent and key registration do not need to be repeated.
A request returns 401 Unauthorized when:
Authorization header is missingInvalid access token.)Access token has expired.)Insufficient scope.)After the initial registration, rotate keys without repeating consent. The request must be signed with the current private key and include the new public key. The request body is JSON.
POST https://intempus.dk/api/v1/access_token/rotate_public_key/
Content-Type: application/json
{
"customer_client_id": "<customer_client_id>",
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": "<jwt signed with the current private key>"
}
The JWT uses the same claims as step 4, plus the new key fields. aud is still the api_token URL for the environment.
{
"client_id": "<your_client_id>",
"iss": "<your_client_id>",
"sub": "<your_client_id>",
"aud": "https://intempus.dk/api/v1/access_token/api_token/",
"iat": 1230049,
"exp": 1230049,
"jti": "unique-rotation-request-id",
"new_public_key": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----",
"kid": "key-2",
"algorithm": "RS256"
}
Sign with the old private key. Set the JWT header kid to the current registered key id:
import jwt
client_assertion = jwt.encode(
jwt_payload,
old_private_key_pem,
algorithm='RS256',
headers={'kid': 'key-1'},
)
The new key must meet the same requirements as initial registration (RSA, minimum 4096 bits, RS256, RS384, or RS512).
Response:
{ "success": true }
After rotation:
Recommended sequence: generate a new key pair, call rotate_public_key, start signing api_token requests with the new private key and new kid, then retire the old private key after the 1-hour overlap.
You can also authenticate using an API key. To obtain an API key, you need an active Intempus account with a company. If you do not have one, you may create a demo company. To connect, include the following HTTP header in your request:
Authorization: ApiKey <username>:<api_key>
Intempus provides a Model Context Protocol (MCP) Server that lets AI agents work with the API on your behalf through a list of MCP Tools, which are:
list_work_reports: Fetch and filter work reportscreate_work_report: Create one or more work reportsupdate_work_report: Update an existing work reportbulk_work_report: Approve, delete, duplicate, or export work reports in bulkdelete_work_report: Delete a work report by IDlist_employees: Fetch and filter employeeslist_employee_balances: Fetch and filter employee balanceslist_balances: Fetch and filter balanceslist_cases: Fetch and filter casesThe MCP Server can be used with any MCP-compatible client, including Cursor, Claude Desktop, GitHub Copilot, and other AI coding assistants.
Add an MCP Server entry to your client's configuration file. The exact file path depends on the
tool you use (for example ~/.cursor/mcp.json in Cursor or
claude_desktop_config.json in Claude Desktop), but the server definition follows the
same structure:
{
"mcpServers": {
"intempus": {
"transport": "http",
"url": "https://mcp.intempus.dk/mcp",
"headers": {
"Authorization": "apikey <username>:<api_key>",
"X-Security-Key": "<security_key>"
}
}
}
}
The MCP Server requires two HTTP headers on every request. Both values are available from your Intempus profile under API Key:
Authorization: Your API Key, formatted as
apikey <username>:<api_key> (the same credentials used for REST API
authentication, with a lowercase apikey prefix).
X-Security-Key: Your MCP Security Key, available upon request to api@intempus.dk.
Keep both values secret and do not commit them to version control. If a key is compromised, revoke it from your profile and generate a new one.
The following will describe the Intempus model briefly in order to understand and use the api.
The main model in Intempus is the workreport. There are a number of models that are directly attached to a workreport.
To facilitate the identification of the appropriate endpoint, we recommend setting the application language of the administration interface to English This adjustment aligns with the English naming convention of our models and attributes, thereby simplifying the process of locating the correct endpoint.
A case that can be worked on. All cases have a customer attached.
The employee performing the work. An employee needs an open contract to be able to make a workreport. The contract also describes which workmodel the employee is working under.
A workreport can have a worktype attached, that describe the type of work performed WorkTypes are grouped by workcategories, which are grouped by workmodels.
We recommend getting a separate user account for your integration, to avoid the integration interfering with the admin normal usage of the system. Please add an email address to the user account, so we can contact you if we see any issues with the integration.
When integrating to another system a lot of data is expected to be sent back and forth between the two systems. For both systems too much can be detrimental to performance. The following are recommendations to reduce load and response sizes for the benefit of both systems.
Details on the mentioned features can be found below.
The api is throttled to 500 requests per minute per user, and 50.000 per day. If any limit is hit the user will receive a 429 status code along with a Retry-After header containing the number of seconds until the user can make another request.
Fields marked with an asterisk (*) are required when creating or updating a resource.
Parameters indicated as URL expect the id of a model formatted as their url. This can always be found in the "resource_uri" field in the response of GET calls to models. Example: "/web/v1/work_type/2983/".
If nothing else is indicated, dates are specified as "YYYY-MM-DD" and times are specified as "HH:MM:SS"
Strings are generally allowed to be 100 characters. Exceptions will be noted on the specific attribute.
When a resource is specified in the type column, that attribute is a nested resource. A nested resource can be manipulated directly through the parent resource. When editing an existing nested resource, the 'resource_uri' attribute must be included, otherwise the api will attempt to create a new instance of the nested resource.
Available filters are described in the description section of each item. To filter on an attribute, and a query parameter with the filter parameter. The filter parameter is specified as the attribute name and the filter type, separated by a double underscore.
The following types of filters are available:
Examples:
https://intempus.dk/web/v1/work_category/?id__in=204,205
https://intempus.dk/web/v1/case_upload/?case__number=12
All list endpoints are paged. The maximum page size is 10000, and the default is 1000 per page. The page size can be changed by setting the 'limit' parameter. Please note that using a limit below 50 might result in high response times.
We provide two options for changing to the next page. The default is using offset. But using offset is less performant for large datasets, so we also provide the option of using cursor based pagination. This is done by setting the query parameter 'pagination_type' to 'cursor'.
Changing to the next page is done by setting the 'offset' parameter. The following example sets the page size to 500 and requests the third page.
https://intempus.dk/web/v1/case/?limit=500&offset=1000
All list endpoints have a 'meta' attribute which contains information about paging. 'limit' and 'offset' shows the current settings, while 'next' and 'previous' contains links to the next/previous page. These are null when no such page exists. The final attribute 'total_count' shows how many objects exist in total for the current filtering. Example of a meta object below
{
"meta": {
"limit": 100,
"next": "/web/v1/employee/?limit=100&offset=100",
"offset": 0,
"previous": null,
"total_count": 250
}
To use cursor based pagination, set the query parameter 'pagination_type' to 'cursor'. The cursor based pagination will always be ordered by the id of the objects and return the newest first. The response will then contain a 'meta' attribute with a 'next' attribute containing a URI to the next page. The following example sets the page size to 500 and requests the next page.
https://intempus.dk/web/v1/case/?pagination_type=cursor&limit=500&after=eyJwYWdlIjoxLCJvZmZzZXQiOjB9
All list endpoints have a 'meta' attribute which contains information about paging. For the cursor based pagination it only contains the next URI.
{
"next": "/web/v1/employee/?pagination_type=cursor&limit=500&after=eyJwYWdlIjoxLCJvZmZzZXQiOjB9"
}
Available orderings are described in the description section of each item. If no description is present, 'id' will be the default ordering. To order by an attribute, add a query parameter 'order_by' with the name of the attribute. To perform the inverse ordering, add a minus before the attribute name
Examples:
https://intempus.dk/web/v1/work_type?order_by=-work_category
For applications synchronizing data from Intempus it is very valuable to only get objects that changed since last update. The 'logical_timestamp' attribute can be used to accomplish this, by filtering by the newest timestamp header received. Notice that deletions will not be shown in the list, and a full sync is needed to capture deleted objects. All responses to GET requests on list resources contains a 'Logical-Timestamp' header. This timestamp should be used for filtering the subsequent request. When filtering this way you are guaranteed to see each update at least once. An example of a logical_timestamp filter:
https://intempus.dk/web/v1/case/?logical_timestamp__gt=5436995
Currently implemented on the following models: workreport, case, customer, employee, product, planned_work_report, employee_department_case_rules, work_type_case_rules, employee_case_rules.
Notice that the logical timestamp is a 64 bit integer. It has no correspondence with a real timestamp, it is simply a counter that is incremented on each update. It has the sole purpose of allowing incremental updates without missing any updates. The Logical-Timestamp header contains the newest timestamp that is guaranteed to be committed to the database. It is therefore important to use this value, and not the logical_timestamp of an individual object.
Consider the following scenario, a classic problem with a basic REST API:
The Intempus API implements a mechanism to ensure such duplications are impossible.
Each of our database tables includes a uuid column, which can be filled in at creation
time by supplying a creation_id field in the body of a POST request.
This UUID defines a uniqueness constraint on the database table, and thus
can be used as a guarantee that duplication as described above is impossible,
provided the creation_id provided in the request's body is the same on
duplicate/retry requests).
The assigned UUID will be a version 5 name based UUID computed as follows:
uuid5(namespace, creation_id).So, as long as the client includes a creation_id parameter in the body of the POST requests, the
problematic scenario outlined above will now play out as before, with one crucial difference:
instead of a duplicate entry being created in the server database upon the request retry,
we return a "200-OK" HTTP response to indicate to the user that we did successfully receive and process the response,
but did not create a new item (since the new item would violate the uniqueness of the
UUID created in the first request).
A user creates a case object via a POST to /web/v1/case with the following request body:
{
"name": "my_case",
"number": "1",
"creation_id": "6",
"customer": "/web/v1/customer/1/"
}
Let's suppose the POST successfully creates the case object in the database, but the client does not receive the 201 response, as described in the problem scenario above. The client retries the same request again. This time, the client is met with a "200-OK" response. The body of the response contains the UUID, namespace and creation_id as described above (as well as all other fields which would usually be returned in a 201 response):
{
...
"creation_id": "6",
...
"namespace": "39138a56-85f0-5e96-85d8-2aa537978054",
...
"uuid": "71afbace-20b0-5803-be81-466aa94713cf",
...
}
It is possible to do creations, updates and deletions in bulk by sending a PATCH request to the relevant list endpoint. The body of the request should contain an 'objects' attribute containing a list of the objects to be created or updated. Any entry with a resource_uri attribute will be updated, while any entry without a resource_uri attribute will be created as a new object. The body can also contain a 'deleted_objects' attribute containing a list of the resource_uri of the objects to be deleted.
Send PATCH to /web/v1/work_report/. This will create a new workreport, update an existing workreport and delete an existing workreport.
{
"objects":
[
{
"creation_id": "1",
"employee": "/web/v1/employee/1/",
"start_date": "2024-01-01",
"end_date": "2024-01-01",
"worktype": "/web/v1/work_type/1/",
"start_time": "09:00:00",
"end_time": "10:00:00",
"amount": 1,
"remarks": "Create this object"
},
{
"resource_uri": "/web/v1/work_report/1/",
"remarks": "Update this field"
}
],
"deleted_objects":
[
"/web/v1/work_report/2/"
]
}
In addition to the appropriate HTTP status code, we strive to return relevant details about any error that occurs during the handling of requests. For 500 errors, we provide a fallback message when no specific details are provided (subject to translation):
Sorry, this request could not be processed. Please try again later.
It might be followed by an incident id:
Incident ID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
If you need to report an error to us, please include the incident ID as text (not a screenshot).