Absences
Push absences that have already been granted in your payroll or HR system into Ditio, in one call. You send the identifiers you already hold — an employee number, a payroll code, a project number, a date range — and Ditio resolves its own internal ids, expands the period into working days, and runs the same validation an absence created in the app goes through.
You never look up a Ditio id.
POST /api/integration/absencesGET /api/integration/absences?fromDate=...&toDate=...GET /api/integration/absences/{externalId}DELETE /api/integration/absences/{externalId}Examples use the environment variables from Environments and default to the test environment.
Before you start
Section titled “Before you start”Two ordering dependencies, not code problems:
- The employees must exist in Ditio, with the same employee numbers your
payroll system uses, and be employed in the company your API client acts
for. An employee of another company in your structure is refused with
400; contact Ditio support if you need to push absences for more than one company. - The projects must exist, if your absences are booked against projects.
Absence types need no setup at all — absenceTypeCode matches the payroll code
they already carry.
Create or update
Section titled “Create or update”curl -X POST "$DITIO_API_BASE/api/integration/absences" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "externalId": "884213-1-7", "employeeNumber": "1042", "absenceTypeCode": "505", "startDate": "2026-09-14", "endDate": "2026-09-18", "projectNumber": "6040-110", "hoursPerDay": 7.5, "comment": "Innvilget i lønnssystemet" }'201 Created on the first push, 200 OK when an existing absence was updated.
This is an upsert, deliberately. Your system is the record of truth; it
should not have to track what Ditio has already seen. Pushing the same
externalId again updates the absence in place instead of creating a second
one, so replaying a feed is harmless and an edit on your side is reflected on
ours.
The response returns names as well as ids — userName, absenceTypeName,
projectName — so you can confirm the absence landed on the person and project
you meant without a second lookup.
Required fields
Section titled “Required fields”| Field | Type | Description |
|---|---|---|
externalId | string | Your own id for this absence. See below — this one matters |
employeeNumber | string | Must match the employment covering the absence |
absenceTypeCode or absenceTypeId | string | Send at least one |
startDate | date | First day, inclusive. Plain date, e.g. 2026-09-15 |
endDate | date | Last day, inclusive |
Optional fields
Section titled “Optional fields”| Field | Type | Default | Description |
|---|---|---|---|
projectNumber | string | Your project number — matched against every project key we hold | |
projectId | string | Ditio project id, if you happen to hold it. Wins over projectNumber | |
projectCompanyId | string | Which company in your structure owns the project | |
hoursPerDay | number | 7.5 | Hours booked on each day, before any days adjustments |
days | array | Per-day adjustments — only list days that differ | |
comment | string | Free text, shown with the absence | |
approved | bool | false | Create the absence already approved. Only honoured on create |
sickLeaveReason | string | Other | Other, WorkRelated or PregnancyRelated. Sick types only. An unrecognised value is ignored, not rejected, and Other is stored |
nameOfChild | string | Required by sick-child absence types | |
birthDateOfChild | date | Required by sick-child absence types |
externalId is the whole contract
Section titled “externalId is the whole contract”It is what makes a repeated push update rather than duplicate, and what lets you change or cancel the absence later. It is required; a request without one is rejected before anything else happens.
We store it verbatim and never parse it, so a composite key is fine — send it as
one string. Surrounding whitespace is trimmed; nothing else is changed,
including case, so ABC-1 and abc-1 are two different absences.
externalId is also what makes “only absences created through the integration
can be changed through it” true. An absence someone registered in the app
carries none, so it cannot be found, changed or deleted here — that falls out of
the key rather than a permission check. The period list below does include those
absences, with externalId: null, so you can read them but not change them.
Absence types
Section titled “Absence types”absenceTypeCode matches the payroll code the absence type already carries
— the wage type (lønnsart) your payroll system already uses. No translation
table on your side, and nothing extra for a Ditio administrator to configure.
Sending both absenceTypeCode and absenceTypeId is fine; the id wins.
Projects
Section titled “Projects”Send one projectNumber — whatever number you use for the project. A Ditio
project can be identified by several numbers (its own project number, an
external project number carried over from your system, or a secondary external
dimension), and which of those a given customer populated is a Ditio-side setup
detail you have no way of knowing. So we match your number against all of them
at once.
If it identifies more than one project, the request is refused rather than
resolved, and the error says which projects and which of those numbers were
involved. If it matches no project at all, the request returns 404. So does a
projectId that is not a project in your company structure.
In a parent / project-company structure the same number is commonly reused
across project companies. If it matches in more than one, send
projectCompanyId to say which you mean — or projectId, which is unambiguous
on its own.
When a key does not identify one thing
Section titled “When a key does not identify one thing”You send business keys — an employee number, a payroll code, a project number —
and a business key can legitimately point at more than one thing. When that
happens the request is refused rather than resolved. Booking an absence
against the wrong person, the wrong type or the wrong site is not something you
could detect from a 200.
There are three of them, and they are not the same kind of problem. Two are normal and have a way out you control. The third is a data error in Ditio.
An ambiguous absence type
Section titled “An ambiguous absence type”{ "message": "absenceTypeCode '505' matches 3 active absence types in company '999001', so it does not identify one: 'Dødsfall/begravelse i nær fam.' (65f1a2b3c4d5e6f7a8b9c0d1), 'Blodgivning' (65f1a2b3c4d5e6f7a8b9c0d2), 'Tannlege' (65f1a2b3c4d5e6f7a8b9c0d3). This is normal — a payroll code covers several absence types. Send absenceTypeId instead for this type."}Why it happens: by design. Ditio models finer absence types than payroll pays on, so “blood donation”, “dentist” and “funeral” can all export under one welfare-leave code. Your payroll system genuinely cannot say which one it meant.
The way out: the error lists the candidates with their ids. Pick the one
that matches the absence you are pushing, store that id against your own absence
type, and send it as absenceTypeId from then on:
{ "externalId": "884213-1-7", "employeeNumber": "1042", "absenceTypeId": "65f1a2b3c4d5e6f7a8b9c0d2", "startDate": "2026-09-14", "endDate": "2026-09-18"}You only need to do this for the codes that are actually shared. Keep sending
absenceTypeCode for the rest — it needs no setup on either side and it survives
ids changing between environments.
An ambiguous project
Section titled “An ambiguous project”{ "message": "projectNumber '6040-110' matches 2 projects across companies 999002, 999003 (by project number) and does not identify exactly one active project. Send projectCompanyId to say which company's project you mean, or projectId."}Why it happens: usually also by design. In a parent / project-company structure the same project number is deliberately reused across project companies. Less often, one number is one project’s project number and another project’s external dimension — the error names which of those were involved.
Two ways out. The first is usually right:
1. Say which company owns it. The number is fine; only the company is missing.
{ "projectNumber": "6040-110", "projectCompanyId": "999002"}2. Send the Ditio project id. Look it up through the Projects API, available to the same API client with the same token:
# Every project you can seecurl "$DITIO_API_BASE/api/v4/integration/projects" -H "Authorization: Bearer $TOKEN"
# Or straight to one by its numbercurl "$DITIO_API_BASE/api/v4/integration/projects/by-project-number/6040-110" \ -H "Authorization: Bearer $TOKEN"Then send the id as projectId, which is unambiguous on its own:
{ "projectNumber": "6040-110", "projectId": "65f1a2b3c4d5e6f7a8b9c0d1"}An ambiguous employee
Section titled “An ambiguous employee”{ "message": "employeeNumber '4711' matches 2 people employed under that number on 2026-03-10 in company '999001'. Refusing to guess which one the absence belongs to — resolve the duplicate in Ditio first."}Why it happens: unlike the other two, this one is not by design. Two people employed under the same employee number on the same date is a data error. An employee number is meant to identify one person at a point in time, and payroll depends on that being true.
There is no way out in the request. employeeNumber is the only way to name
a person here — there is deliberately no employee id field to fall back on,
because a duplicate number will cause the same trouble in payroll, exports and
reporting whether or not this endpoint works around it. Fix it in Ditio, then
re-push. The absence is not lost: your externalId means the retry updates
rather than duplicates.
A different error, worth not confusing with this one:
{ "message": "employeeNumber '4711' does not match the employment covering 2026-03-10."}That means the number resolved a person, but on a different employment period than the absence falls in — so it is not enough to register the absence against them. Check which employment the dates actually belong to.
The general shape
Section titled “The general shape”| You send | Resolves when | If it does not |
|---|---|---|
absenceTypeCode | exactly one active type carries that payroll code | Error lists candidates with ids → send absenceTypeId |
projectNumber | exactly one active project matches, on any project key | Error names the companies → send projectCompanyId, or look up projectId via the Projects API |
employeeNumber | exactly one person holds it on the absence’s dates | Fix the duplicate in Ditio — there is no request-level override |
Adjusting individual days
Section titled “Adjusting individual days”The period defines the absence: startDate..endDate is expanded to working
days, each at hoursPerDay. Send days only for the days that differ.
{ "startDate": "2026-09-14", "endDate": "2026-09-20", "days": [ { "date": "2026-09-15", "qty": 4 }, { "date": "2026-09-16", "qty": 0 }, { "date": "2026-09-19", "qty": 6 } ]}Monday the 14th through Friday the 18th book at the default hours, except: Tuesday is a half day, Wednesday is removed because the person worked, and Saturday the 19th is added at 6 hours.
| Entry | Effect |
|---|---|
| A date the period already books | Sets that day’s hours |
| A date the period skipped — a weekend inside the range | Adds that day |
"qty": 0 | Removes that day from the absence |
A date outside startDate..endDate | Ignored |
A day is { "date", "qty" } in both directions — the same shape you send comes
back in the response’s days, so there is nothing to translate.
Always check daysQty in the response against what you expected — a
Monday-to-Sunday absence is five days, not seven.
Approval
Section titled “Approval”approved: true marks the absence approved at the moment it is created.
On a later push the flag is ignored in both directions: it cannot approve an existing absence, and omitting it cannot revoke an approval granted in Ditio. Approval belongs to Ditio once the absence exists.
Read back and cancel
Section titled “Read back and cancel”curl "$DITIO_API_BASE/api/integration/absences/884213-1-7" \ -H "Authorization: Bearer $TOKEN"
curl -X DELETE "$DITIO_API_BASE/api/integration/absences/884213-1-7" \ -H "Authorization: Bearer $TOKEN"DELETE returns 204 No Content, or 404 if no absence is held under that id.
It returns 400 and leaves the absence in place when the absence is locked,
when any of its days has been approved for payroll or locked, or when it starts
on or before the company’s edit cutoff date. Handle the correction in payroll
instead.
List absences for a period
Section titled “List absences for a period”GET /api/integration/absences?fromDate=2026-10-01&toDate=2026-10-31GET /api/integration/absences?fromDate=2026-10-01&toDate=2026-10-31&externalOnly=truecurl -s "$DITIO_API_BASE/api/integration/absences?fromDate=2026-10-01&toDate=2026-10-31" \ -H "Authorization: Bearer $TOKEN"Returns every absence that overlaps the period, as an array of the same objects Read back returns, ordered by start date. Use it to reconcile what Ditio holds against your own records.
| Parameter | |
|---|---|
fromDate | Required. First day of the period, inclusive. A plain date, e.g. 2026-10-01 |
toDate | Required. Last day of the period, inclusive. At most 92 days after fromDate counted inclusively — read a longer history one period at a time |
externalOnly | Optional, default false. true returns only the absences pushed through this API |
Things to know:
- All absences, by default. An absence registered in the app or the backoffice
is included too, with
externalId: null. Those can be read here but not changed or cancelled through this API — only absences you pushed, under yourexternalId, can be. SendexternalOnly=trueto see only your own. - Overlap, not containment. An absence running from 28 September to 3 October
is returned for October, with all of its
days— filter the days yourself if you only want the ones inside the period. - Across your structure. When your API client is on a parent company with
project companies, absences in those project companies are included. Each
absence carries its
companyId, the company it belongs to; reading, changing or cancelling it byexternalIdhas to happen in that company. A project company sees only its own. - Deleted absences are not returned. A cancellation removes the absence, so an
externalIdmissing from a period it used to be in has either been cancelled — in Ditio or through this API — or had its dates moved outside that period.GET /api/integration/absences/{externalId}tells the two apart.
Days payroll has settled are frozen
Section titled “Days payroll has settled are frozen”A day that has been approved for payroll or locked has been paid on. It must come back from your push with the same date and the same hours, or the push is refused:
{ "message": "Absence '884213-1-7' cannot be changed: 2026-09-14 has been approved for payroll at 7.5 hours and this push would change it to 4. Handle the correction in payroll, or have the period reopened in Ditio."}Everything else stays editable. The comment, the project, hours on days payroll has not reached, extending the period, adding days — none of that is affected. A feed correcting the tail of a period is not blocked because the head of it has been paid.
Cancelling is different: DELETE removes every day, so an absence with any
settled day cannot be cancelled at all.
What to do: handle the correction in payroll, or have someone reopen the
period in Ditio and re-push. Your externalId is unchanged, so the retry updates
the existing absence rather than creating a second one.
What else can refuse a backdated push
Section titled “What else can refuse a backdated push”Employment is validated per day against the absence’s own dates, and any employment counts — past, future, or either side of a role change. So registering a leaver’s final month, or pre-registering for someone who has not started yet, both resolve normally.
Clearing that check does not on its own mean the absence is written. The same rules apply as to an absence registered in Ditio, and three of them bite on the past:
- Payroll already approved or locked for any day in the period. Retroactive pushes hit this most often, because a leaver’s final month is usually the month payroll has already closed.
- The absence itself is locked.
- The company’s edit cutoff. An absence that starts on or before the cutoff date set in Ditio can be neither written nor cancelled.
Absence-type thresholds (quota per year or period, maximum consecutive days) are evaluated on every push, backdated or not.
Errors
Section titled “Errors”Bad input returns 400 with a message naming the problem. A project that cannot
be found returns 404. The body shape is described in
Error Handling.
| Problem | Status | What it means |
|---|---|---|
externalId is required | 400 | Every absence needs one |
Could not resolve employeeNumber | 400 | The employee doesn’t exist in this company |
| Employee … belongs to company … | 400 | The employee is employed in another company than the one your API client acts for |
| … has no employment covering … | 400 | Part of the absence falls outside every employment |
employeeNumber matches N people | 400 | The number identifies more than one person on that date |
Could not resolve absenceTypeCode | 400 | No absence type carries that payroll code |
absenceTypeCode matches N active absence types | 400 | The payroll code is shared — send absenceTypeId. The error lists the candidates |
projectNumber … does not identify exactly one active project | 400 | Reused across your project companies, or it means two different things — send projectCompanyId or projectId |
projectNumber … did not match any project | 404 | No project in your company structure carries that number |
| Project … was not found in this company structure | 404 | The projectId is not a project in your company structure |
projectCompanyId … is not a company in this company structure | 400 | Send the id of a company in your own structure |
| Absence type is deactivated | 400 | The type has been archived and can no longer be used |
| Threshold exceeded | 400 | The employee has used up their quota for that absence type |
| Cannot register absence as one or more days … approved in payroll | 400 | Payroll for at least one day is approved or locked |
fromDate and toDate are required / toDate cannot be before fromDate / The period can be at most 92 days | 400 | Listing needs a bounded period — see List absences for a period |
fromDate / toDate must be a date without a time or timezone offset, or must be plain dates (yyyy-MM-dd) | 400 | Send a plain date such as 2026-10-01; externalOnly takes true or false |
| Start date before the edit cutoff | 400 | The absence starts on or before the company’s edit cutoff date, so it can be neither written nor cancelled |