API guide

For the developer connecting payroll, HR or a website. Every address below is this server's own.

1

Make a key

On Integrations, add a key named after the app that will use it. Copy it: it is shown once.

2

Test it

curl -H "Authorization: Bearer ps_…" https://attendance.example.com/api/v1
3

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

CodeMeaning
401The key is missing, wrong or revoked.
404No such employee, or a wrong address.
422A parameter is wrong; "errors" says which one.
429Too 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.

ParameterMeaning
statusactive (default), inactive or all
departmentDepartment id
qSearch name or code, or an exact PIN
per_page1 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.

ParameterMeaning
{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).

ParameterMeaning
since_idPunches after this id, oldest first
from / toDates as YYYY-MM-DD; today when left out
pinOnly this PIN
deviceOnly this device id
per_page1 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).

ParameterMeaning
dateOne day, YYYY-MM-DD
from / toA range of up to 31 days
employeeOne employee id, active or not
departmentDepartment id
per_pageEmployees 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));
}
Preview with sample data · nothing you change is saved