API guide
For the developer connecting payroll, HR or a website. Every address below is this server's own.
Make a key
On Integrations, add a key named after the app that will use it. Copy it: it is shown once.
Test it
curl -H "Authorization: Bearer ps_…" https://attendance.example.com/api/v1
Try every call
Import the Postman collection and paste the key into its apiKey variable. The file never contains a key.
Sign in
Every call sends the key in a header. The API only reads: it cannot change or delete anything.
- Address
- https://attendance.example.com/api/v1
- Header
- Authorization: Bearer ps_…
- Or, if a tool cannot set that
- X-Api-Key: ps_…
- Times
- Punch and attendance times carry the company's UTC offset. Dates are YYYY-MM-DD in the company's timezone.
Never put the key in the address (?key=…); it is refused there, because addresses end up in logs.
Errors & limits
| Code | Meaning |
|---|---|
| 401 | The key is missing, wrong or revoked. |
| 404 | No such employee, or a wrong address. |
| 422 | A parameter is wrong; "errors" says which one. |
| 429 | Too many calls. Wait the seconds in the Retry-After header. |
Each key may make 120 calls a minute. After 20 wrong keys in a minute, the sending address is made to wait. Lists come page by page: follow links.next until it is null.
Start
Check the key
GET /
Answers when the key works, with the company name, its timezone and today's date. Call it first.
Request
curl -H "Authorization: Bearer ps_…" \ "https://attendance.example.com/api/v1"
Response
{
"data": {
"api_version": "v1",
"company": "Acme Ltd",
"timezone": "Asia/Karachi",
"today": "2026-09-30",
"key": {
"name": "Payroll",
"ends_with": "a1b2"
}
}
}
People
List employees
GET /employees
Everyone on the books, page by page, sorted by name. Active people only unless you ask otherwise.
| Parameter | Meaning |
|---|---|
| status | active (default), inactive or all |
| department | Department id |
| q | Search name or code, or an exact PIN |
| per_page | 1 to 200, default 50 |
Request
curl -H "Authorization: Bearer ps_…" \ "https://attendance.example.com/api/v1/employees?status=active&per_page=50"
Response
{
"data": [
{
"id": 14,
"code": "EMP-014",
"name": "Ayesha Khan",
"pin": "22",
"email": "ayesha@example.com",
"phone": null,
"joined_on": "2025-03-01",
"is_active": true,
"department": {
"id": 3,
"name": "Stylists"
},
"shift": {
"id": 1,
"name": "General"
}
}
],
"links": {
"next": null
},
"meta": {
"current_page": 1,
"last_page": 1,
"per_page": 50,
"total": 1
}
}
People
One employee
GET /employees/{id}
The same fields as the list, for one person, active or not.
| Parameter | Meaning |
|---|---|
| {id} | Employee id, from the list |
Request
curl -H "Authorization: Bearer ps_…" \ "https://attendance.example.com/api/v1/employees/14"
Response
{
"data": {
"id": 14,
"code": "EMP-014",
"name": "Ayesha Khan",
"pin": "22",
"email": "ayesha@example.com",
"phone": null,
"joined_on": "2025-03-01",
"is_active": true,
"department": {
"id": 3,
"name": "Stylists"
},
"shift": {
"id": 1,
"name": "General"
}
}
}
Attendance
Punches
GET /punches
Raw punches from the machines. To keep a copy in sync, send the last id you have as since_id and store next_since_id for the next call; nothing is missed or repeated. Or ask for dates with from and to (at most 31 days).
| Parameter | Meaning |
|---|---|
| since_id | Punches after this id, oldest first |
| from / to | Dates as YYYY-MM-DD; today when left out |
| pin | Only this PIN |
| device | Only this device id |
| per_page | 1 to 500, default 100 |
Request
curl -H "Authorization: Bearer ps_…" \ "https://attendance.example.com/api/v1/punches?since_id=0&per_page=100"
Response
{
"data": [
{
"id": 1521,
"pin": "22",
"punched_at": "2026-09-30T09:02:11+05:00",
"verify_type": "1",
"direction": "0",
"counted": true,
"device": {
"id": 1,
"serial_number": "CQZ7231260112",
"name": "Front door"
},
"employee": {
"id": 14,
"code": "EMP-014",
"name": "Ayesha Khan"
}
}
],
"meta": {
"next_since_id": 1521,
"has_more": false
}
}
Attendance
Attendance
GET /attendance
Worked-out days with the same rules as the app's own reports: status, first in, last out, late and overtime minutes, and totals. Use date for one day, or from and to (at most 31 days).
| Parameter | Meaning |
|---|---|
| date | One day, YYYY-MM-DD |
| from / to | A range of up to 31 days |
| employee | One employee id, active or not |
| department | Department id |
| per_page | Employees per page, 1 to 200, default 50 |
What "status" can say
- present
- Came in and left
- half_day
- Came in, but worked too little for a full day
- working
- Checked in today and the shift is still running
- missing_out
- A past day with one punch: no punch-out
- absent
- No punch on a working day
- day_off
- Weekly off day
- holiday
- Company holiday
- on_leave
- No punch on a working day covered by leave; "leave" says which kind
- not_yet
- Today, no punch yet and the shift has not ended
- upcoming
- A day still to come
- not_joined
- Before the joining date
Request
curl -H "Authorization: Bearer ps_…" \ "https://attendance.example.com/api/v1/attendance?date=2026-09-30"
Response
{
"data": [
{
"employee": {
"id": 14,
"code": "EMP-014",
"name": "Ayesha Khan",
"pin": "22"
},
"days": [
{
"date": "2026-09-30",
"status": "present",
"late": true,
"first_in": "2026-09-30T09:12:00+05:00",
"last_out": "2026-09-30T18:05:00+05:00",
"punch_count": 2,
"worked_minutes": 533,
"late_minutes": 12,
"early_leave_minutes": 0,
"overtime_minutes": 5,
"is_day_off": false,
"holiday": null,
"shift": "General",
"corrected": false,
"leave": null,
"leave_half_day": false
}
],
"totals": {
"days_in": 1,
"late": 1,
"absent": 0,
"half_days": 0,
"missing_out": 0,
"worked_minutes": 533,
"late_minutes": 12,
"overtime_minutes": 5,
"on_leave": 0
}
}
],
"meta": {
"from": "2026-09-30",
"to": "2026-09-30",
"timezone": "Asia/Karachi",
"current_page": 1,
"last_page": 1,
"per_page": 50,
"total": 1
},
"links": {
"next": null,
"prev": null
}
}
Lists
Devices
GET /devices
The attendance machines. Punches from a machine that is not active are kept but not counted.
Request
curl -H "Authorization: Bearer ps_…" \ "https://attendance.example.com/api/v1/devices"
Response
{
"data": [
{
"id": 1,
"serial_number": "CQZ7231260112",
"name": "Front door",
"status": "active",
"model": "F22",
"last_seen_at": "2026-09-30T04:02:11+00:00"
}
]
}
Lists
Departments
GET /departments
Department ids and names, for the department filter above.
Request
curl -H "Authorization: Bearer ps_…" \ "https://attendance.example.com/api/v1/departments"
Response
{
"data": [
{
"id": 3,
"name": "Stylists"
}
]
}
Push
Webhooks
Instead of asking, your app can be told. Add its address on Integrations, and new punches are sent there as a POST with a JSON body, up to 100 punches at a time, in order.
- Answer with any 2xx code within 10 seconds. Anything else, or a redirect, counts as not delivered.
- A failed delivery is tried again after 1, 5, 30, 120, 360 minutes, then given up; it can still be sent again by hand.
- The same delivery can arrive twice. Keep its id and ignore repeats.
- Headers: X-PunchSync-Event, X-PunchSync-Delivery and X-PunchSync-Signature.
- The test message from the "Send test" button has the event ping.
- Addresses on the server's own metadata network (such as 169.254.169.254) are refused. Office-network addresses are fine.
Body · event punches.created
{
"id": "0b6f2a7e-5c41-4d3e-9a8f-2f1c3b7d9e10",
"event": "punches.created",
"created_at": "2026-09-30T04:02:15+00:00",
"data": [
{
"id": 1521,
"pin": "22",
"punched_at": "2026-09-30T09:02:11+05:00",
"verify_type": "1",
"direction": "0",
"counted": true,
"device": {
"id": 1,
"serial_number": "CQZ7231260112",
"name": "Front door"
},
"employee": {
"id": 14,
"code": "EMP-014",
"name": "Ayesha Khan"
}
}
]
}
Checking the signature
Each webhook has its own secret, shown on its page. The X-PunchSync-Signature header looks like t=1790000000,v1=5f3a…: v1 is the HMAC-SHA256, in hex, of the time, a dot, and the raw body, made with the secret. Check it before trusting a message, and refuse old times so a copied message cannot be sent again.
PHP
$body = file_get_contents('php://input'); // the raw body, exactly as sent
$header = $_SERVER['HTTP_X_PUNCHSYNC_SIGNATURE'] ?? '';
parse_str(str_replace(',', '&', $header), $parts); // t=…, v1=…
$expected = hash_hmac('sha256', ($parts['t'] ?? '').'.'.$body, $secret);
$fresh = abs(time() - (int) ($parts['t'] ?? 0)) < 300; // refuse old messages
if (! $fresh || ! hash_equals($expected, $parts['v1'] ?? '')) {
http_response_code(401);
exit;
}
$event = json_decode($body, true); // save $event['id'] to ignore repeats
http_response_code(200);
Node.js
const crypto = require('crypto');
// rawBody: the request body as text, before any JSON parsing.
function isFromPunchSync(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const expected = crypto.createHmac('sha256', secret)
.update(parts.t + '.' + rawBody).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
return fresh && expected.length === (parts.v1 || '').length
&& crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}