Requests & Responses
Every endpoint follows the same conventions, so code you write for one works for the others.
The envelope
A successful response wraps its payload in data and adds meta:
{
"data": { "id": "cm...", "name": "Northwind Health" },
"meta": { "request_id": "8f2c4b1e-5d0a-4c55-9a3e-0f6b8f1c2d77" }
}
A failed response has error instead of data:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "limit must be an integer between 1 and 100.",
"details": { "field": "limit" }
},
"meta": { "request_id": "8f2c4b1e-5d0a-4c55-9a3e-0f6b8f1c2d77" }
}
error.codeis stable and machine-readable. Branch on it. See Errors.error.messageis for people and may be reworded.error.detailsis optional. When present it holds safe hints, such as the name of the invalid field.
Every response, success or failure, carries Cache-Control: no-store, so proxies and browsers do not keep copies of your data.
Field names and values
- JSON keys are
snake_case:first_name,leave_year_start_month,expires_at. - Ids are opaque strings. Do not parse them or assume a format.
- Ids are only ever looked up inside the token's organization. An id from another organization returns
404 NOT_FOUND, exactly like an id that does not exist. - Fields the owner is not allowed to see are left out or redacted in the same way the app redacts them, for example private leave details.
Dates and times
The API uses two formats, and the difference matters:
| Kind | Format | Example | Used for |
|---|---|---|---|
| Calendar day | YYYY-MM-DD | 2026-07-01 | Days with no time of day: leave start and end dates, public holidays, dates of birth, employment dates |
| Instant | ISO 8601 in UTC | 2026-10-12T17:04:11.512Z | Moments in time: created_at, expires_at, clock-in times |
A calendar day is the same day for every reader, whatever their timezone: 2026-07-01 is July 1. Do not convert it to a timestamp in your local timezone, or it may shift to the previous or next day. When you need an organization's timezone, read timezone (an IANA name such as America/Toronto) from GET /organization.
Pagination
List endpoints use cursor pagination.
| Parameter | Default | Maximum | Notes |
|---|---|---|---|
limit | 50 | 100 | Must be a whole number of 1 or more. Values above 100 are treated as 100. |
cursor | none | The meta.next_cursor value from the previous page. Opaque: pass it back unchanged. |
A list response adds has_more and next_cursor to meta:
{
"data": [ { "id": "cm..." }, { "id": "cm..." } ],
"meta": {
"request_id": "1c7e...",
"has_more": true,
"next_cursor": "eyJpZCI6ImNtLi4uIn0"
}
}
Keep requesting with cursor set to the last next_cursor until has_more is false; next_cursor is then null.
cursor=""
while :; do
page=$(curl -s "https://app.bookyourpto.com/api/v1/<list-endpoint>?limit=100${cursor:+&cursor=$cursor}" \
-H "Authorization: Bearer $BOOKYOURPTO_TOKEN")
# ... process .data ...
[ "$(echo "$page" | jq -r '.meta.has_more')" = "true" ] || break
cursor=$(echo "$page" | jq -r '.meta.next_cursor')
done
A malformed or altered cursor returns 422 VALIDATION_FAILED. Keep the same filters on every page of a walk; a cursor only marks a position in the list.
A few endpoints return everything in one response and are not paginated: GET /leave-balances returns one object, GET /public-holidays returns a whole calendar year in one page, and GET /webhook-events returns the full catalogue.
Date windows
Some lists are also bounded by a date window, so a single request can never scan years of records. The window is in calendar days in the organization's timezone:
| Endpoint | Default window | Longest window |
|---|---|---|
GET /time-entries | The last 30 days | 120 days |
GET /shifts | The next 7 days, starting today | 120 days |
A window longer than the maximum, or an end before the start, returns 422 VALIDATION_FAILED. To read a longer period, walk it in windows of up to 120 days.
Request bodies
Endpoints that accept a body (POST, PATCH, PUT, DELETE) expect JSON:
- Send
Content-Type: application/json. A body sent with any other content type is refused with422 VALIDATION_FAILED. - The body may be at most 1 MB. A larger body is refused, usually with
413by the network edge before it reaches the API. - Malformed JSON returns
422 VALIDATION_FAILED, never a server error. - Send only the fields an endpoint documents. Many endpoints refuse an unknown field with
422 VALIDATION_FAILEDrather than ignoring it, so a typo in a field name fails loudly.
Status codes on success
| Status | When |
|---|---|
200 OK | A read, an update, or an action such as approve, reject or cancel. The body holds the record as it now stands. |
201 Created | A record was created. The body holds the new record. |
202 Accepted | A webhook test event or redelivery was queued. The body holds the delivery; its status shows whether the first attempt has already finished. |
Idempotency keys
Network failures happen. To retry a write safely, send an Idempotency-Key header with a value you generate for that operation, such as a UUID:
curl -X POST https://app.bookyourpto.com/api/v1/<endpoint> \
-H "Authorization: Bearer $BOOKYOURPTO_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1d3c7a-2b9e-4f0c-8a51-3e7d2c9b4a10" \
-d '{ ... }'
| Situation | Result |
|---|---|
| First request with the key | Processed normally. The response is remembered for 24 hours. |
| Same key, same method, path and body, within 24 hours | The original response is returned again, with the header Idempotent-Replayed: true. The write is not repeated. |
| Same key with a different body or query | 409 IDEMPOTENCY_CONFLICT |
| Same key while the first request is still running | 409 CONFLICT. Wait and retry. |
| The first request failed with a server error (5xx) | The key is released, so a retry with the same key is processed again. |
Rules for the key:
- 1 to 255 printable ASCII characters, with no spaces.
- Keys are scoped to the token and to the method and path, so two integrations cannot collide.
- A client error (4xx) is a final answer and is replayed like a success. Fix the request and use a new key.
Keys are accepted on any write endpoint; the API Reference marks the operations where they are documented.
Request ids
Every response carries an X-Request-Id header, and the same value is in meta.request_id. Log it alongside your own request logs. When you contact support about a specific call, quoting the request id lets us find exactly what happened.
Browsers and CORS
The API is for server-to-server use. It sends no Access-Control-* headers, and a preflight OPTIONS request receives 204 without them, so a browser on another origin cannot read responses. Call the API from your back end, and never ship a token to a browser or a distributed app.