Attendance to payroll
A monthly job that reads every clock-in and clock-out from Agoo and writes a payroll-ready CSV with hours worked, lateness and flags per person per day.
This is designed and scheduled but not built yet. We document it now so you can plan your integration.
Goal
Ridge Academy pays some staff by the hours they work, and its payroll officer wants one spreadsheet each month: for each person and day, when they clocked in and out, how long they worked and how late they were, matched to the school's own employee numbers. You'll build a script that produces that file from the API, and run it automatically on the 1st of each month.
If you only need the standard export, the console's payroll export does this without code, including leave and overtime. Use this recipe when you need your own format, or want to load the data into another system.
What you need
| Need | Details |
|---|---|
| Plan | Pro or Enterprise for live use. Build and test on any plan in test mode. |
| API key scopes | attendance:read, people:read, sites:read |
| Data in Agoo | Staff with tracks_attendance on, and an employee_number that matches your payroll system |
| Runtime | Node.js 20 or later; optionally GitHub Actions (or any scheduler) for the monthly run |
How it works
- Read every clock-in and clock-out for the month from
GET /attendance/events, plus a day either side so night shifts that cross midnight pair up correctly. - For each person, pair each clock-in with the next clock-out. The time between them counts towards the day of the clock-in, in the site's time zone.
- Look up each person's name, department and employee number from
GET /people. - Write one CSV row per person per day.
Clock events made offline (during a network or power cut) sync later with their original time and offline: true, so run the job a day or two after the month ends, not at midnight.
Steps
Set up the project
npm i @ardent-africa/agoonpm i -D typescript tsx @types/nodenpm pkg set type=moduleAGOO_API_KEY=agoo_sk_test_…Write the script
import { writeFile } from "node:fs/promises"
import { Agoo, type AttendanceEvent, type Person } from "@ardent-africa/agoo"
const agoo = new Agoo({ apiKey: process.env.AGOO_API_KEY })
const DAY_MS = 86_400_000
// ---- Which month: the argument (YYYY-MM), or last month ----
function lastMonth(): string {
const now = new Date()
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1)).toISOString().slice(0, 7)
}
const month = process.argv[2] ?? lastMonth()
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month)) {
console.error("Usage: npx tsx --env-file=.env payroll.ts [YYYY-MM]")
process.exit(2)
}
const [year, monthNumber] = month.split("-").map(Number)
const monthStart = Date.UTC(year, monthNumber - 1, 1)
const monthEnd = Date.UTC(year, monthNumber, 1)
// ---- Local dates and times in each site's time zone ----
const formatters = new Map<string, Intl.DateTimeFormat>()
function local(iso: string, timeZone: string) {
let f = formatters.get(timeZone)
if (!f) {
f = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
})
formatters.set(timeZone, f)
}
const p = Object.fromEntries(f.formatToParts(new Date(iso)).map((part) => [part.type, part.value]))
return { date: `${p.year}-${p.month}-${p.day}`, time: `${p.hour}:${p.minute}` }
}
// ---- Sites (for names and time zones) and people ----
const sites = new Map<string, { name: string; timeZone: string }>()
for await (const site of agoo.sites.list({ limit: 100 }).autoPaginate()) {
sites.set(site.id, { name: site.name, timeZone: site.time_zone })
}
const people = new Map<string, Person>()
for await (const person of agoo.people.list({ limit: 100 }).autoPaginate()) people.set(person.id, person)
async function personFor(id: string): Promise<Person> {
// People deactivated during the month aren't in the default (active) list, so fetch them one by one.
if (!people.has(id)) people.set(id, await agoo.people.retrieve(id))
return people.get(id)!
}
// ---- Clock events for the month, with a day either side ----
const eventsByPerson = new Map<string, AttendanceEvent[]>()
for await (const event of agoo.attendance.events
.list({
occurred_after: new Date(monthStart - DAY_MS).toISOString(),
occurred_before: new Date(monthEnd + DAY_MS).toISOString(),
limit: 100,
})
.autoPaginate()) {
const list = eventsByPerson.get(event.person_id) ?? []
list.push(event)
eventsByPerson.set(event.person_id, list)
}
// ---- Pair clock-ins with clock-outs, per person ----
type Day = {
date: string
site: string
firstIn?: string
lastOut?: string
workedMinutes: number
lateMinutes: number
flags: Set<string>
}
const rows: { person: Person; day: Day }[] = []
for (const [personId, events] of eventsByPerson) {
events.sort((a, b) => Date.parse(a.occurred_at) - Date.parse(b.occurred_at))
const days = new Map<string, Day>()
const dayOf = (event: AttendanceEvent) => {
const site = sites.get(event.site_id)
const { date, time } = local(event.occurred_at, site?.timeZone ?? "Africa/Accra")
let day = days.get(date)
if (!day) {
day = { date, site: site?.name ?? event.site_id, workedMinutes: 0, lateMinutes: 0, flags: new Set() }
days.set(date, day)
}
return { day, time }
}
let open: { event: AttendanceEvent; day: Day } | undefined
for (const event of events) {
if (event.kind === "clock_in") {
const { day, time } = dayOf(event)
if (open) open.day.flags.add("missing_clock_out") // the previous clock-in was never closed
day.firstIn ??= time
day.lateMinutes += event.late_minutes ?? 0
if (event.offline) day.flags.add("offline")
open = { event, day }
} else if (open) {
const site = sites.get(event.site_id)
open.day.workedMinutes += Math.round((Date.parse(event.occurred_at) - Date.parse(open.event.occurred_at)) / 60_000)
open.day.lastOut = local(event.occurred_at, site?.timeZone ?? "Africa/Accra").time
if (event.offline) open.day.flags.add("offline")
open = undefined
} else {
dayOf(event).day.flags.add("clock_out_without_clock_in")
}
}
if (open) open.day.flags.add("missing_clock_out")
const person = await personFor(personId)
for (const day of days.values()) {
if (day.date.startsWith(month)) rows.push({ person, day })
}
}
// ---- Write the CSV ----
const cell = (value: string | number | null | undefined) => {
const s = value == null ? "" : String(value)
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
}
rows.sort(
(a, b) =>
(a.person.employee_number ?? a.person.name).localeCompare(b.person.employee_number ?? b.person.name) || a.day.date.localeCompare(b.day.date),
)
const header = ["date", "employee_number", "name", "department", "site", "first_in", "last_out", "worked_minutes", "late_minutes", "flags"]
const lines = rows.map(({ person, day }) =>
[
day.date,
person.employee_number,
person.name,
person.department,
day.site,
day.firstIn,
day.lastOut,
day.workedMinutes,
day.lateMinutes,
[...day.flags].join(";"),
]
.map(cell)
.join(","),
)
const file = `attendance-${month}.csv`
// The byte-order mark helps Excel read names with accents correctly.
await writeFile(file, "" + [header.join(","), ...lines].join("\r\n") + "\r\n", "utf8")
console.log(`Wrote ${rows.length} rows for ${new Set(rows.map((r) => r.person.id)).size} people to ${file}`)
const missingNumbers = [...new Set(rows.filter((r) => !r.person.employee_number).map((r) => r.person.name))]
if (missingNumbers.length > 0) console.warn(`No employee_number for: ${missingNumbers.join(", ")}`)Read the output
date,employee_number,name,department,site,first_in,last_out,worked_minutes,late_minutes,flags
2026-09-14,RA-0377,Nii Armah,Administration,Ridge Academy Main Campus,08:14,17:32,558,14,
2026-09-15,RA-0377,Nii Armah,Administration,Ridge Academy Main Campus,07:56,,0,0,missing_clock_out| Column | Meaning |
|---|---|
first_in | First clock-in of the day, local time. |
last_out | Clock-out that closed the day's last shift. After midnight for night shifts. |
worked_minutes | Total time between paired clock-ins and clock-outs. Breaks clocked out and in are excluded. |
late_minutes | Minutes late, as Agoo calculated against the person's shift and grace period. |
flags | missing_clock_out, clock_out_without_clock_in or offline. Have a manager check flagged days. |
Leave, public holidays and overtime rules aren't in the clock events. If your payroll needs them, use the console's payroll export, or combine this file with the daily counts from GET /attendance/summary.
Run it every month
name: Attendance to payroll
on:
schedule:
- cron: "0 3 2 * *" # 03:00 UTC on the 2nd, after offline clock-ins have synced
workflow_dispatch:
inputs:
month:
description: "Month to export (YYYY-MM). Leave empty for last month."
required: false
jobs:
export:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
- run: npm ci
- name: Export attendance
run: npx tsx payroll.ts $MONTH # empty on the schedule, so last month
env:
AGOO_API_KEY: ${{ secrets.AGOO_API_KEY }}
MONTH: ${{ inputs.month }} # passed as a variable, never pasted into the script
- uses: actions/upload-artifact@v7
with:
name: attendance
path: attendance-*.csv
retention-days: 7The file contains personal data. Keep the repository private, limit who can download workflow artifacts, and keep the retention short. Or replace the upload step with a direct delivery to your payroll system's import folder.
Try it in test mode
-
Use a test key with
attendance:read,people:readandsites:read. -
In the test-mode console, make sure a few people have attendance turned on and an employee number.
-
Create clock events for one of them. With a test key that also has
attendance:write, you can record them from the CLI:Terminal agoo api POST /attendance/events --data '{"person_id":"person_01ja7n36096q14dr9gpqy77zxy","site_id":"site_01kjpt3yw0fz0v414608h9x65s","kind":"clock_in","occurred_at":"2026-09-14T08:14:00Z"}' agoo api POST /attendance/events --data '{"person_id":"person_01ja7n36096q14dr9gpqy77zxy","site_id":"site_01kjpt3yw0fz0v414608h9x65s","kind":"clock_out","occurred_at":"2026-09-14T17:32:00Z"}' -
Run the script for that month and open the CSV:
Terminal npx tsx --env-file=.env payroll.ts 2026-09
Production checklist
- Use a live key with only
attendance:read,people:readandsites:read. - Give every salaried or hourly employee an
employee_numberin Agoo that matches payroll. The script warns about anyone without one. - Agree with your payroll officer how flagged days are handled before the first run.
- Schedule the job after the month's offline clock-ins have synced (the 2nd of the month is a safe default).
- Treat the CSV as personal data: deliver it to the payroll team only, and delete old copies.
- Check the totals against the console's attendance board for the first month or two.