Reports
Two routes, the same engine. Nothing is stored: every call computes the range from the marks, the schedules and the decisions of that moment, so a mark approved this morning changes this afternoon's answer.
Dates and the timezone
date_from and date_to are civil dates in the company's timezone, both included, up to 92 days apart. Every answer says which timezone that is in company_timezone (an IANA name such as America/Guatemala); the times inside the detail are wall-clock times in it.
A work day is the day a shift starts: a night shift from 22:00 on the 31st to 06:00 on the 1st belongs to the 31st, and its exit mark, taken on the 1st, counts there.
Filters
Both routes take the same query parameters as the reports page of the app:
| Parameter | What it does |
|---|---|
date_from, date_to | Required. The range, both days included. |
location | Employees of these sites. Repeat it for several: location=<id>&location=<id>. |
department, position, employee | The same, by department, position or employee. |
include_inactive | true adds the employees who have left. Without it, only the employees active today come back. |
Without filters you get every active employee of every site. The ids to filter by come in the rows themselves (employee, location, department, position). A site that is not the company's is a 400 location_forbidden, never an empty report.
For a monthly close, pass include_inactive=true: someone who left on the 20th still has most of the month to account for. The days outside a person's employment are not counted.
The summary
GET /api/v1/reports/summary/ answers one row per employee, ordered by code, not paginated. A company of 2 000 people gets about 1.2 MB.
Each row starts with who the person is (employee, code, name, and the id and name of their location, department and position) and then the totals of the range. Days are counts; minutes are whole minutes.
| Column | What it counts |
|---|---|
expected_days | Days with an expected shift, the open and future ones included. |
expected_minutes | What those shifts asked for, net of their deducted breaks. |
worked_days | Days the person came: present, incomplete or still in progress. |
late_days, late_minutes | Late entries past the shift's tolerance, and their minutes. |
early_leave_days, early_leave_minutes | Exits before the end of the shift past its tolerance, and their minutes. |
late_break_count, late_break_minutes | Pauses that lasted longer than their minutes, and the minutes they went over. |
absent_days | Expected days with no mark once the marking window closed. |
incomplete_days | Days with a mark missing once the marking window closed. |
worked_minutes | Everything worked, ordinary and extra. |
ordinary_minutes | Worked minus extra. |
extra_minutes | Over what the shift asked for, plus a day off worked, plus time inside a holiday the company counts as extra. |
no_schedule_days | Days without a schedule: nothing was expected, marks still count. |
vacation_days, sick_days, permission_days, other_leave_days | Days with a shift that a leave freed, by reason. |
has_pending | A mark of the range waits for a review: the numbers may still change. |
A late arrival or an early leave that the company approved as justified is already counted from the start or to the end of the shift: it does not show in late_minutes or early_leave_minutes, and it does not lower ordinary_minutes.
What a person owes
owed = expected_minutes - ordinary_minutes
On every day with a shift, ordinary minutes never pass what the shift asked for, so this subtraction is exactly the sum of what was missing each day: late minutes, early leaves, absences, a pause that ran long. Extra time on one day never pays back a missing hour on another; that is a rule your system can apply on top if it wants to.
Read it with three cautions:
- It holds when
no_schedule_daysis 0. On a day without a schedule nothing was expected and everything worked is ordinary, so those minutes would lower the debt. - An incomplete day owes its whole shift until the company adds the missing mark: Kabal does not guess when someone left. Look at
incomplete_days. - A row with
has_pending: truecan still change when someone reviews the mark that waits.
About holidays: when a schedule does not give a holiday off (a shop that opens every day) and the company counts holiday hours as extra time, the part of a shift inside the holiday asks for nothing, so it adds nothing to expected_minutes, whether the person came or not. If they came, those hours are extra_minutes. If they did not, the day still counts in expected_days and absent_days, and owes nothing.
The detail
GET /api/v1/reports/daily/ answers every day of every employee, grouped by employee and paginated by employee: 100 people per page by default, up to 200 with page_size, ordered by code.
curl -H "Authorization: Bearer $KABAL_API_KEY" \
"https://app.kabal-attendance.com/api/v1/reports/daily/?date_from=2026-10-01&date_to=2026-10-31&page=1"
{
"date_from": "2026-10-01",
"date_to": "2026-10-31",
"company_timezone": "America/Guatemala",
"count": 230,
"page": 1,
"pages": 3,
"page_size": 100,
"results": [
{
"employee": "0193a4b2-7c1e-7d40-9a51-3f2c8b6e1d07",
"code": "E001",
"name": "Ana López",
"days": [{ "date": "2026-10-01", "state": "present", "late_minutes": 12 }]
}
]
}
Ask for page=1, then up to pages. A page past the last one is a 404 not_found. The order is stable while nobody is hired in the middle of the download; if count changes between pages, start again.
Each day says what was expected (expected: the shift with its hours, what decided it in source, such as the schedule, a holiday, a leave or an approved week plan, the holiday's name, and in time_off_reason why a day with a shift was freed), what the engine read (marks, every mark it looked at, counted or not, with the role it gave it), the numbers of the day and a state. A day carries no internal ids and none of the notes the company writes on an exception or a leave: the reason is what travels.
The day's state:
state | Meaning |
|---|---|
upcoming | No mark yet, and the shift's tolerance has not run out. |
late_no_mark | No mark yet, past the tolerance, with the marking window still open. |
in_progress | An entry or an exit is still missing, with the marking window open. |
present | Came, with an entry and an exit. |
incomplete | An entry or an exit is missing and the marking window closed. |
absent | No mark and the marking window closed. |
rest | A day off: a rest day, a holiday the schedule respects, a leave. |
extra_day | Worked on a day off: everything is extra. |
no_schedule | The person had no schedule that day. |
labels adds what the engine noticed without changing the state, such as duplicate marks, a pause that ran long, a justified late arrival or a holiday worked; the reference lists them all. Each day row weighs about 1 KB.
When to pull a month
There is no "month closed" signal. Pull when the company's HR has closed the month in Kabal, and keep in mind:
- The last work day of the month closes with its marking window: a night shift of the 31st ends on the morning of the 1st. Pulling on the 1st at 08:00 can still see it
in_progress. upcomingandin_progressdays already add theirexpected_minutes, as they add toexpected_days. A month pulled before it ends shows a debt that goes down by itself.- A mark approved, a missing mark added or a request decided after your pull changes the numbers. Pull again the rows with
has_pending: true, or the whole month, before closing payroll.
A whole month for 2 000 people is the summary plus 20 pages of the detail, well inside the limits.