Skip to content

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/absences
GET /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.

Two ordering dependencies, not code problems:

  1. 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.
  2. 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.

Terminal window
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.

FieldTypeDescription
externalIdstringYour own id for this absence. See below — this one matters
employeeNumberstringMust match the employment covering the absence
absenceTypeCode or absenceTypeIdstringSend at least one
startDatedateFirst day, inclusive. Plain date, e.g. 2026-09-15
endDatedateLast day, inclusive
FieldTypeDefaultDescription
projectNumberstringYour project number — matched against every project key we hold
projectIdstringDitio project id, if you happen to hold it. Wins over projectNumber
projectCompanyIdstringWhich company in your structure owns the project
hoursPerDaynumber7.5Hours booked on each day, before any days adjustments
daysarrayPer-day adjustments — only list days that differ
commentstringFree text, shown with the absence
approvedboolfalseCreate the absence already approved. Only honoured on create
sickLeaveReasonstringOtherOther, WorkRelated or PregnancyRelated. Sick types only. An unrecognised value is ignored, not rejected, and Other is stored
nameOfChildstringRequired by sick-child absence types
birthDateOfChilddateRequired by sick-child absence types

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.

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.

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.

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.

{
"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.

{
"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:

Terminal window
# Every project you can see
curl "$DITIO_API_BASE/api/v4/integration/projects" -H "Authorization: Bearer $TOKEN"
# Or straight to one by its number
curl "$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"
}
{
"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.

You sendResolves whenIf it does not
absenceTypeCodeexactly one active type carries that payroll codeError lists candidates with ids → send absenceTypeId
projectNumberexactly one active project matches, on any project keyError names the companies → send projectCompanyId, or look up projectId via the Projects API
employeeNumberexactly one person holds it on the absence’s datesFix the duplicate in Ditio — there is no request-level override

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.

EntryEffect
A date the period already booksSets that day’s hours
A date the period skipped — a weekend inside the rangeAdds that day
"qty": 0Removes that day from the absence
A date outside startDate..endDateIgnored

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.

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.

Terminal window
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.

GET /api/integration/absences?fromDate=2026-10-01&toDate=2026-10-31
GET /api/integration/absences?fromDate=2026-10-01&toDate=2026-10-31&externalOnly=true
Terminal window
curl -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
fromDateRequired. First day of the period, inclusive. A plain date, e.g. 2026-10-01
toDateRequired. Last day of the period, inclusive. At most 92 days after fromDate counted inclusively — read a longer history one period at a time
externalOnlyOptional, 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 your externalId, can be. Send externalOnly=true to 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 by externalId has to happen in that company. A project company sees only its own.
  • Deleted absences are not returned. A cancellation removes the absence, so an externalId missing 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.

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.

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.

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.

ProblemStatusWhat it means
externalId is required400Every absence needs one
Could not resolve employeeNumber400The employee doesn’t exist in this company
Employee … belongs to company …400The employee is employed in another company than the one your API client acts for
… has no employment covering …400Part of the absence falls outside every employment
employeeNumber matches N people400The number identifies more than one person on that date
Could not resolve absenceTypeCode400No absence type carries that payroll code
absenceTypeCode matches N active absence types400The payroll code is shared — send absenceTypeId. The error lists the candidates
projectNumber … does not identify exactly one active project400Reused across your project companies, or it means two different things — send projectCompanyId or projectId
projectNumber … did not match any project404No project in your company structure carries that number
Project … was not found in this company structure404The projectId is not a project in your company structure
projectCompanyId … is not a company in this company structure400Send the id of a company in your own structure
Absence type is deactivated400The type has been archived and can no longer be used
Threshold exceeded400The employee has used up their quota for that absence type
Cannot register absence as one or more days … approved in payroll400Payroll 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 days400Listing 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)400Send a plain date such as 2026-10-01; externalOnly takes true or false
Start date before the edit cutoff400The absence starts on or before the company’s edit cutoff date, so it can be neither written nor cancelled