Projects
A project is a work site or place of work inside a company — an office, a client site, a gate. Projects give attendance its geofence (and, optionally, an IP allowlist) for clock-ins, are what an employee's claims are filed against, and can be synced from Xero as a tracking category option, so each site's costs can be split out when work is posted to the accounting system.
Projects
Needs the projects module on the company's plan — every endpoint below
answers 403 with
This organization's plan does not include the 'projects' module. otherwise.
The project object
| Field | Type | Meaning |
|---|---|---|
id | string | Project id. |
name | string | Display name. |
location | string | null | Free-text street address, shown to people. Never parsed or geocoded. |
latitude, longitude | number | null | Legacy single geofence centre. Kept as a fallback for a project with no rows in geofencePoints. |
geofencePoints | array | Geofenced sites, each { id, label, latitude, longitude }, in the order the clock-in distance check walks them — the first site inside its radius wins. Populated on every single-project response (get, create, update, archive, restore); empty on the list endpoint. |
geofenceSiteCount | integer | Number of geofence sites. Set to the array's length wherever geofencePoints is populated; on the list endpoint it's the only one of the two that's filled in. |
allowedIps | string | null | Legacy comma-separated IPv4 allowlist. Kept as a fallback for a project with no rows in allowedIpEntries. |
allowedIpEntries | array | Labelled IP allowlist entries, each { id, label, cidr }. Only enforced for employees whose policy has "require IP allowlist" on. Populated on every single-project response (get, create, update, archive, restore); empty on the list endpoint. |
allowedIpCount | integer | Number of allowlist entries. Set to the array's length wherever allowedIpEntries is populated; on the list endpoint it's the only one of the two that's filled in. |
workingHoursStart, workingHoursEnd | string | null | Local "HH:mm", e.g. "09:00". Both null means no schedule. |
workingDays | string | null | CSV of ISO weekday numbers, 1 (Monday) – 7 (Sunday), e.g. "1,2,3,4,5". |
lunchBreakMinutes | integer | Deducted from the schedule span when computing expected working minutes. |
isArchived | boolean | Soft-archived — the row still exists for past claims/attendance to reference. |
xeroProjectId | string | null | Set when this project came from Xero's Projects API. |
xeroTrackingOptionId, xeroTrackingCategoryId | string | null | Set instead when the project came from an option on a Xero tracking category. A project has at most one of xeroProjectId / xeroTrackingOptionId; either one means Xero owns this row. |
xeroStatus | string | null | Status as last synced from Xero. |
xeroSyncedAt | string (ISO 8601) | null | When Xero last synced this row. |
hiddenByTrackingCategory | boolean | True when this project came from a Xero tracking category the company has since switched away from — kept for past records, but should be left out of a project picker. Only populated by GET /projects (the list); always false elsewhere. |
createdAt | string (ISO 8601) | When the project was created. |
geofencePoints/allowedIpEntries are only populated on a
single-project response; the list endpoint reports just their counts, so the two
responses can report the same project differently — see the notes on each endpoint
below.
Every project in the company, archived included — admin screens need to see and
restore what's been retired. Each project includes
geofenceSiteCount/allowedIpCount and
hiddenByTrackingCategory, but not the full geofencePoints /
allowedIpEntries rows (see the callout above).
Response 200
An array of project objects.
[
{
"id": "prj_123",
"name": "Kuala Lumpur HQ",
"location": "Level 10, Jalan Example, 50450 Kuala Lumpur",
"latitude": 3.15,
"longitude": 101.7,
"geofencePoints": [],
"geofenceSiteCount": 2,
"allowedIps": null,
"allowedIpEntries": [],
"allowedIpCount": 1,
"workingHoursStart": "09:00",
"workingHoursEnd": "18:00",
"workingDays": "1,2,3,4,5",
"lunchBreakMinutes": 60,
"isArchived": false,
"xeroProjectId": null,
"xeroTrackingOptionId": null,
"xeroTrackingCategoryId": null,
"xeroStatus": null,
"xeroSyncedAt": null,
"hiddenByTrackingCategory": false,
"createdAt": "2025-01-10T02:00:00Z"
}
]
curl https://<api-host>/projects \
-H "Authorization: Bearer wp_live_xxx"
One project, with its full geofencePoints and
allowedIpEntries rows — the list endpoint omits both, since drawing a
grid of names doesn't need a query per project for data it never shows.
Response 200
A project object, with geofencePoints and
allowedIpEntries filled in and geofenceSiteCount/
allowedIpCount set to their lengths. hiddenByTrackingCategory
is always false here.
curl https://<api-host>/projects/prj_123 \
-H "Authorization: Bearer wp_live_xxx"
Errors
404 (empty body) — no project with that id in this company.
The caller's own projects, via their team memberships — what a clock-in picker
should offer, since clocking into a project the caller isn't on is refused.
Archived projects and ones hidden by a Xero tracking-category switch (see
hiddenByTrackingCategory above) are left out.
Response 200
An array of project objects, shaped like the list endpoint above.
curl https://<api-host>/projects/mine \
-H "Authorization: Bearer wp_live_xxx"
Creating & updating
Both require the Admin role, which an API key always holds in its own
company, so a key may call either once it has the projects module.
Creates a project, geofence sites and IP allowlist included.
Request
| Field | Type | Required | Limits | Meaning |
|---|---|---|---|---|
name | string | Yes | max 160 chars | Display name. |
location | string | No | max 400 chars | Free-text street address. |
latitude | number | No | -90 to 90 | Geofence centre. Both null → not geofenced. |
longitude | number | No | -180 to 180 | Geofence centre. |
allowedIps | string | No | max 1000 chars | Legacy comma-separated IPv4 allowlist. |
workingHoursStart | string | No | max 5 chars | Local "HH:mm". |
workingHoursEnd | string | No | max 5 chars | Local "HH:mm". |
workingDays | string | No | max 20 chars | CSV of ISO weekday numbers 1–7. |
lunchBreakMinutes | integer | No | 0–480, default 60 | Deducted from the schedule span. |
geofencePoints[].label | string | Yes (per row) | max 160 chars | Shown to an employee who's off-site. |
geofencePoints[].latitude | number | Yes (per row) | -90 to 90 | |
geofencePoints[].longitude | number | Yes (per row) | -180 to 180 | |
allowedIpEntries[].label | string | Yes (per row) | max 160 chars | |
allowedIpEntries[].cidr | string | Yes (per row) | max 64 chars | A bare IPv4 address or an IPv4 CIDR range, e.g. "203.0.113.0/24". |
allowedIpEntries is validated before anything is written: a bad
cidr fails the whole call with 400 and nothing is saved —
not even name/location/etc. geofencePoints
order is behaviour, not presentation: the clock-in distance check walks the array in
order and the first site inside its radius wins.
Response 200
A project object (status is 200, not
201), with geofencePoints/allowedIpEntries
filled in from what was just saved and their counts set to the saved lengths.
Errors
-
400— a badallowedIpEntries[].cidris rejected before anything is written:{ "errors": { "allowedIpEntries": ["\"10.0.0.999\" is not a valid IPv4 address or CIDR range."] } } 400—{ "errors": { "Name": ["..."] } }for other field validation failures.
curl -X POST https://<api-host>/projects \
-H "Authorization: Bearer wp_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"name": "Penang Site",
"location": "12 Jalan Example, Penang",
"latitude": 5.4,
"longitude": 100.3,
"lunchBreakMinutes": 60,
"geofencePoints": [
{ "label": "Main gate", "latitude": 5.4, "longitude": 100.3 }
],
"allowedIpEntries": [
{ "label": "Site office wifi", "cidr": "203.0.113.0/24" }
]
}'
Renames and/or updates a project. This is a full replace, not a merge: every
scalar field is overwritten with what's sent, and geofencePoints /
allowedIpEntries are each replaced wholesale — what you send IS the
list afterwards, so an empty array clears it.
Request
Same body as create, geofence sites and allowlist included.
geofencePoints order is behaviour, not presentation: the clock-in
distance check walks the array in order and the first site inside its radius
wins, so reordering it changes which site an employee is judged against.
Response 200
The updated project object, with
geofencePoints/allowedIpEntries filled in from what was
just saved.
Errors
404(empty body) — no project with that id in this company.-
400— a badallowedIpEntries[].cidris rejected before anything is written:{ "errors": { "allowedIpEntries": ["\"10.0.0.999\" is not a valid IPv4 address or CIDR range."] } } 400— other field validation failures, same shape as create.
curl -X PUT https://<api-host>/projects/prj_123 \
-H "Authorization: Bearer wp_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"name": "Kuala Lumpur HQ",
"latitude": 3.15,
"longitude": 101.7,
"lunchBreakMinutes": 60,
"geofencePoints": [
{ "label": "Main entrance", "latitude": 3.15, "longitude": 101.7 }
],
"allowedIpEntries": [
{ "label": "HQ office wifi", "cidr": "203.0.113.0/24" }
]
}'
Archiving
Soft-archive, not delete: an archived project is kept so past claims and
attendance records still resolve its name, it just stops being offered where a
project is picked (GET /projects/mine) and is
excluded from the clock-in/claim pickers. Both endpoints also clear
archivedByXeroConnect, so a later Xero disconnect won't silently
reverse an admin's own choice.
Archives a project.
Response 200
The updated project object, with isArchived: true
and its geofencePoints/allowedIpEntries filled in, same as
GET /projects/{id}.
curl -X POST https://<api-host>/projects/prj_123/archive \
-H "Authorization: Bearer wp_live_xxx"
Errors
404 (empty body) — no project with that id in this company.
Un-archives a project.
Response 200
The updated project object, with isArchived: false
and its geofencePoints/allowedIpEntries filled in, same as
GET /projects/{id}.
curl -X POST https://<api-host>/projects/prj_123/restore \
-H "Authorization: Bearer wp_live_xxx"
Errors
404 (empty body) — no project with that id in this company.
Not available to API keys
GET /projects/my-ip is marked [HumanOnly] — a key can never
call it. It reports the IP address the server sees for the current request, meant to
let a signed-in admin drop "the machine I'm on right now" into a project's allowlist;
called with a key it would only report the integration's own outbound address.