Developer docs
Projects
REST · JSON · API keys

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

FieldTypeMeaning
idstringProject id.
namestringDisplay name.
locationstring | nullFree-text street address, shown to people. Never parsed or geocoded.
latitude, longitudenumber | nullLegacy single geofence centre. Kept as a fallback for a project with no rows in geofencePoints.
geofencePointsarrayGeofenced 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.
geofenceSiteCountintegerNumber 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.
allowedIpsstring | nullLegacy comma-separated IPv4 allowlist. Kept as a fallback for a project with no rows in allowedIpEntries.
allowedIpEntriesarrayLabelled 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.
allowedIpCountintegerNumber 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, workingHoursEndstring | nullLocal "HH:mm", e.g. "09:00". Both null means no schedule.
workingDaysstring | nullCSV of ISO weekday numbers, 1 (Monday) – 7 (Sunday), e.g. "1,2,3,4,5".
lunchBreakMinutesintegerDeducted from the schedule span when computing expected working minutes.
isArchivedbooleanSoft-archived — the row still exists for past claims/attendance to reference.
xeroProjectIdstring | nullSet when this project came from Xero's Projects API.
xeroTrackingOptionId, xeroTrackingCategoryIdstring | nullSet 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.
xeroStatusstring | nullStatus as last synced from Xero.
xeroSyncedAtstring (ISO 8601) | nullWhen Xero last synced this row.
hiddenByTrackingCategorybooleanTrue 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.
createdAtstring (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.
GET /projects projects:read

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"
GET /projects/{id} projects:read

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.

GET /projects/mine projects:read

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.

This endpoint identifies "the caller" from the signed-in employee's own user id. An API key has no employee or team membership behind it, so calling this with a key always returns an empty array — it's built for the employee-portal UI, not for integrations.

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.

POST /projects projects:write

Creates a project, geofence sites and IP allowlist included.

Request

FieldTypeRequiredLimitsMeaning
namestringYesmax 160 charsDisplay name.
locationstringNomax 400 charsFree-text street address.
latitudenumberNo-90 to 90Geofence centre. Both null → not geofenced.
longitudenumberNo-180 to 180Geofence centre.
allowedIpsstringNomax 1000 charsLegacy comma-separated IPv4 allowlist.
workingHoursStartstringNomax 5 charsLocal "HH:mm".
workingHoursEndstringNomax 5 charsLocal "HH:mm".
workingDaysstringNomax 20 charsCSV of ISO weekday numbers 1–7.
lunchBreakMinutesintegerNo0–480, default 60Deducted from the schedule span.
geofencePoints[].labelstringYes (per row)max 160 charsShown to an employee who's off-site.
geofencePoints[].latitudenumberYes (per row)-90 to 90
geofencePoints[].longitudenumberYes (per row)-180 to 180
allowedIpEntries[].labelstringYes (per row)max 160 chars
allowedIpEntries[].cidrstringYes (per row)max 64 charsA 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 bad allowedIpEntries[].cidr is 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" }
    ]
  }'
PUT /projects/{id} projects:write

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 bad allowedIpEntries[].cidr is 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.

POST /projects/{id}/archive projects:write

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.

POST /projects/{id}/restore projects:write

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.