Eviworx
Docs

Absences API

The Absences API manages absences (vacation, sick, training …) including substitutes and manager approval. Absences drive availability during assignment: absent agents are filtered out, and assignments to absent users are redirected to their substitute at assignment time.

🌴
Features
✓ 7 absence types (VACATION, SICK …)
✓ Approval workflow (PENDING → APPROVED/REJECTED)
✓ Manager approval (hierarchy + permissions)
✓ Substitute per absence (substituteId)
✓ Redirect to the substitute at assignment
✓ Full or partial day (allDay, startTime/endTime)
✓ Team view for managers (/team)
✓ Per-user overlap protection
✓ Type & reason only for HR roles + owner
✓ Type & reason in no notification

Types & Status

AbsenceType: VACATION, SICK, TRAINING, BUSINESS_TRIP, PARENTAL, COMPENSATORY, OTHER
AbsenceStatus: PENDINGAPPROVED / REJECTED, CANCELLED

If an already-approved absence is changed substantially (e.g. its dates), it reverts to PENDING and must be re-approved.

Endpoints /api/absences

MethodEndpointPermissionDescription
GET/absences.viewAllCalendar window, response { data } (filters: startDate/endDate/userId/groupId/type/status; the type filter additionally requires viewDetails)
GET/myabsences.viewOwnOwn absences, { data } (the 20 most recent)
GET/teamabsences.viewTeamTeam absences, each row with canEdit/canApprove (the 100 most recent)
GET/agentstickets.assignableSelection list for substitutes, { data }. Requires tickets.assignable (the right that qualifies someone as a substitute) rather than a create right, because the list contains names and email addresses of all active agents.
GET/groupsabsences.viewAllviewTeamAgent groups for the calendar filter, { data }
GET/user/:userId/statusauthentication onlyPresence signal for assignment surfaces — the ONLY route of this domain an API key may call as well. An unknown user ID returns 200 with isAbsent: false.
POST/absences.create / createForTeam / createForAllCreate absence
PATCH/:ideditOwn / editTeam / editAllPartial update (may revert to PENDING). The owner (userId) cannot be changed.
POST/:id/approveabsences.approve + manager relationApprove (only in status PENDING)
POST/:id/rejectabsences.reject + manager relationReject — { reason } is REQUIRED (empty or missing = 400)
DELETE/:idcancelOwn / cancelOwnApproved / cancelTeam / cancelAllCancel → 204. Cancelling your own ALREADY APPROVED absence requires cancelOwnApproved.

Manager logic: Deciding requires the matching right (approve for approving, reject for rejecting) AND either being the DIRECT manager of the requester or holding absences.editAll. The manager relation is deliberately direct and NOT transitive: the manager of a manager does not decide unless entered as manager themselves. Nobody can decide their own absence (403), not even with editAll. The same rules apply on the approval inbox path — both run through the same check. The basis is the manager hierarchy from Users & Roles.

Type and reason: restricted visibility

The TYPE of an absence is health data as soon as it can read SICK (Art. 9 GDPR), and the REASON is free text next to it. Both are therefore scoped more tightly than the absence itself, which the calendar and team tab show to everyone permitted. Type and reason are visible only to whoever meets at least one of these three conditions:

  • absences.viewDetails — organization-wide (administration/HR)
  • DIRECT manager of the row — only for their own reports
  • the owner themselves

Everyone else receives type: OTHER and reason: null on EVERY read path — calendar, team tab and status route. The fields are therefore no evidence of what is stored in the database. The team lead deliberately does NOT count here: that is a queue role for assignment and skills, not an HR role; they still see the team tab, but without type and reason.

The type filter is blocked, not silently ignored: GET /api/absences?type=SICK requires absences.viewDetails and answers 403 ABSENCE_VIEW_FORBIDDEN without it. If the filter were ignored instead, the masked type could be reconstructed by comparing result counts.

For the same reason type and reason do not leave the product through notifications either: neither email subject nor body, Webex, Teams or in-app message carries them. The rejection reason stays — it is the manager's message to the person concerned.

Create Absence

POST /api/absences
{
  "userId": "clx-user",
  "type": "VACATION",
  "startDate": "2026-02-10",
  "endDate": "2026-02-21",
  "allDay": true,
  "reason": "Annual vacation",
  "substituteId": "clx-substitute"
}

Partial day: allDay=false → startTime and endTime (HH:MM) are then required. Rules: endDate ≥ startDate; substituteId ≠ userId; overlap with the same user's existing absences is rejected. createForTeam/createForAll allow creating for others.

// allDay=false (partial day)
{ "userId": "clx-user", "type": "OTHER", "startDate": "2026-01-30", "endDate": "2026-01-30",
  "allDay": false, "startTime": "08:00", "endTime": "12:00", "reason": "Doctor appointment" }

Input rules

  • Unknown fields are rejected: an unknown body field or a foreign parameter on the calendar query returns 400 VALIDATION_ERROR with a field path. That includes userId on PATCH: the owner of an absence cannot be changed.
  • Clearing works with null: on PATCH, reason and substituteId clear the field when sent as null; a missing key leaves it unchanged. On create, simply omit an empty field.
  • Dates are ISO strings — an unreadable date reports the field path, not a database error.
  • Concurrent calls: create, update, cancel and decide are protected against races. Of two concurrent creates in the same period one wins (the other gets 409), likewise for two concurrent cancels or decisions (400) — there is never a second "success".

Error codes

errorCodeHTTPMeaning
ABSENCE_OVERLAP409The period overlaps an existing absence of the same user. The colliding row id is in details.overlappingAbsenceId; the message deliberately names NO type — the collision partner may be a sick leave.
ABSENCE_NOT_EDITABLE400Editing is possible only in PENDING and APPROVED.
ABSENCE_NOT_CANCELLABLE400Cancelling is possible only in PENDING and APPROVED.
ABSENCE_NOT_PENDING400Only what is still PENDING can be decided.
REASON_REQUIRED400A rejection without a reason — on both paths (domain route and approval inbox). Whitespace alone does not count as a reason.
ABSENCE_VIEW_FORBIDDEN403The type filter was used without absences.viewDetails.
ABSENCE_MANAGE_FORBIDDEN403Create, update or cancel without the matching permission (own / team / all).
ABSENCE_APPROVE_FORBIDDEN403Deciding without the right or without a manager relation — including the attempt to decide your own absence.
ABSENCE_NOT_FOUND404No absence with this id.
VALIDATION_ERROR400Schema violation with a field path in details — unknown field, unreadable date, endDate before startDate, substitute equal to owner, or missing times with allDay: false.

Availability & Substitution

Absences take effect at two points, each at the moment of assignment:

  • Filtering: /api/users/assignable and the assignment engine exclude currently absent agents.
  • Substitute redirect: If an absent agent is assigned anyway (e.g. directly), the system redirects to their substitute. If that person is absent as well, it follows the substitution chain onwards — across at most three stations and without cycles. Deliberately assigning to an absent person is possible via ignoreSubstitution=true (see assign endpoints in tickets/incidents/problems/changes). Whoever ends up with the record is verified as available: the substitute must clear the same hurdles as a directly chosen agent (mailbox, agent group, assignability), and the chain has to end on someone who is not absent themselves. Otherwise the record stays with the absent original — visibly sitting there instead of appearing to be worked on. On tickets the history records the fact that the substitute could not take over. The REASON appears only in the audit log (SUBSTITUTE_BLOCKED when the substitute lacks a permission; SUBSTITUTE_UNAVAILABLE when the chain ends on an absent person): that a third person lacks a permission does not belong in a record everyone involved reads. Both entries are filed on the person concerned, not on the record — in the audit log you look them up via the substitute.

GET /api/absences/user/:userId/status returns a compact { isAbsent, absenceType, absenceEndDate, substituteId, substituteName } — e.g. for UI hints. The fields are tiered: isAbsent goes to every authenticated caller, substitute and end date (ISO 8601) require absences.viewStatus, and absenceType additionally the permission for type and reason (see above) — otherwise it reads OTHER.

🌴
Core Principles
  • ✓ 7 types, workflow PENDING→APPROVED/REJECTED
  • ✓ Approval by the direct manager
  • ✓ Redirect to the substitute at assignment time
  • ✓ Overlap protection, re-approval after changes
🔐
Permissions (RBAC)
  • absences.viewOwn / viewTeam / viewAll / viewDetails / viewStatus
  • absences.create / createForTeam / createForAll
  • absences.editOwn / editTeam / editAll
  • absences.cancelOwn / cancelOwnApproved / cancelTeam / cancelAll
  • absences.approve / reject – only as direct manager or with editAll

Auth/role model: Permissions & RBAC

Related Documentation