> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ontime.hosai.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Export attendance to an external payroll system

> Pull a month of attendance for every employee and shape it for an external payroll import.

If payroll runs somewhere other than OnTime, this is the integration you need: a month of day records, per employee, in whatever shape your payroll system imports.

## What you are pulling

`GET /api/public/v1/attendance?date=YYYY-MM-DD` returns **one date, all employees**:

```json theme={null}
{
  "meta": { "count": 3 },
  "data": [
    {
      "employeeId": "3f1c9b2a-5d84-4e7a-9c11-2b6f8d0a4e73",
      "employeeCode": "EMP-0421",
      "date": "2026-06-15",
      "status": "Present",
      "workedMinutes": 525,
      "otMinutes": 45
    },
    {
      "employeeId": "8a2e4d61-7c93-4b05-8f2d-1e5c9a3b7d40",
      "employeeCode": "EMP-0518",
      "date": "2026-06-15",
      "status": "Absent",
      "workedMinutes": 0,
      "otMinutes": 0
    },
    {
      "employeeId": "c05b7f38-1a92-4e6d-b840-9f3c2e1a5d67",
      "employeeCode": "EMP-0733",
      "date": "2026-06-15",
      "status": "Leave",
      "workedMinutes": 0,
      "otMinutes": 0
    }
  ]
}
```

<Warning>
  **There is no date-range endpoint.** A month means 28–31 requests, one per date. At 600 requests per minute this is comfortable, but do it sequentially with a small delay rather than firing thirty concurrent requests.
</Warning>

## Timing matters more than the code

<Warning>
  **Only export closed days.** A day is finalized by the nightly close, which runs after the day is over. Crucially, `Absent` is never written to a day still in progress — so exporting a month that includes today under-counts absences silently.

  Export a **completed month**, and do it at least a day after the month ends.
</Warning>

<Tip>
  Better still, export after the OnTime payroll cycle for that month has been **frozen**. A frozen day cannot re-derive, so your export is reproducible: re-running it produces the same numbers.
</Tip>

## The script

```typescript theme={null}
const BASE = "https://app.ontime.hosai.app";
const KEY = process.env.ONTIME_API_KEY!;

type AttendanceRow = {
  employeeId: string;
  employeeCode: string;
  date: string;
  status: "Present" | "Absent" | "Half" | "Leave" | "WeeklyOff" | "Holiday";
  workedMinutes: number;
  otMinutes: number;
};

async function get<T>(path: string): Promise<T> {
  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await fetch(BASE + path, {
      headers: { Authorization: `Bearer ${KEY}` },
    });
    if (res.ok) return res.json() as Promise<T>;
    if (res.status === 429 || res.status >= 500) {
      await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
      continue;
    }
    throw new Error(`OnTime ${res.status} on ${path}: ${await res.text()}`);
  }
  throw new Error(`OnTime: gave up on ${path}`);
}

function datesIn(month: string): string[] {
  const [y, m] = month.split("-").map(Number);
  const days = new Date(Date.UTC(y, m, 0)).getUTCDate();
  return Array.from(
    { length: days },
    (_, i) => `${month}-${String(i + 1).padStart(2, "0")}`,
  );
}

/** Aggregate a month into one row per employee. */
async function monthSummary(month: string) {
  const byEmployee = new Map<
    string,
    { code: string; paidDays: number; lopDays: number; otMinutes: number }
  >();

  for (const date of datesIn(month)) {
    const { data } = await get<{ data: AttendanceRow[] }>(
      `/api/public/v1/attendance?date=${date}`,
    );

    for (const row of data) {
      const acc = byEmployee.get(row.employeeId) ?? {
        code: row.employeeCode,
        paidDays: 0,
        lopDays: 0,
        otMinutes: 0,
      };

      // Paid: worked, on approved leave, on a holiday, or a rest day.
      // Half days count as half. Absent is the only unpaid case.
      if (row.status === "Half") acc.paidDays += 0.5;
      else if (row.status === "Absent") acc.lopDays += 1;
      else acc.paidDays += 1;

      acc.otMinutes += row.otMinutes;
      byEmployee.set(row.employeeId, acc);
    }

    await new Promise((r) => setTimeout(r, 150)); // stay well under 600/min
  }

  return [...byEmployee.entries()].map(([employeeId, v]) => ({
    employeeId,
    employeeCode: v.code,
    month,
    paidDays: v.paidDays,
    lopDays: v.lopDays,
    otHours: Math.round((v.otMinutes / 60) * 100) / 100,
  }));
}

const rows = await monthSummary("2026-06");
console.log("employeeCode,month,paidDays,lopDays,otHours");
for (const r of rows) {
  console.log(
    `${r.employeeCode},${r.month},${r.paidDays},${r.lopDays},${r.otHours}`,
  );
}
```

## Getting the paid-days rule right

The aggregation above treats everything except `Absent` as paid. That is the common case, but check it against your payroll rules:

| Status      | Usually   | Watch out for                                                                                                                                                           |
| ----------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Present`   | Paid      | —                                                                                                                                                                       |
| `Half`      | Half paid | Whether your system wants 0.5 or a separate half-day count.                                                                                                             |
| `Leave`     | Paid      | **Unpaid leave types also produce `Leave`.** The public endpoint does not tell you which type it was, so you cannot distinguish paid leave from loss-of-pay leave here. |
| `WeeklyOff` | Paid      | —                                                                                                                                                                       |
| `Holiday`   | Paid      | —                                                                                                                                                                       |
| `Absent`    | Unpaid    | —                                                                                                                                                                       |

<Warning>
  **The unpaid-leave gap is the one to design around.** `status: "Leave"` covers both paid leave and Loss of Pay, and the public attendance endpoint does not expose the leave type.

  If unpaid leave types are in use, this export will over-count paid days. Either restrict LOP handling to OnTime's own payroll, or reconcile against leave data pulled separately.
</Warning>

<Note>
  There is also a known boundary quirk in how the last calendar day of a month is captured by OnTime's own attendance-window query. Spot-check month-end totals against a day report the first time you run this.
</Note>

## Making it repeatable

<Steps>
  <Step title="Export after the month is frozen">
    Reproducible numbers.
  </Step>

  <Step title="Store the raw daily responses, not just the summary">
    When payroll disputes a figure, you want the day it came from.
  </Step>

  <Step title="Re-export rather than accumulate">
    An approved regularization rewrites a past day. A monthly re-read picks that up; a running daily total does not.
  </Step>

  <Step title="Subscribe to regularization.approved">
    So you know a past day changed and can re-export that month. See [Events](/developers/webhooks/events).
  </Step>
</Steps>
