Overview
The API is one part of the product’s promise: the record a business keeps in On Post is its own, and a system it already runs — a head-office dashboard, an on-duty display, a data warehouse, an HR sync — can read it without asking us. Everything here is read-only. Nothing outside On Post can write a clock-in, change a shift, or touch a time card; the wage record is written only at the clock and corrected only in the console, where every change keeps its before and after.
Five endpoints, one shape: send a key, receive JSON. Who is on post and the locations are part of Pro; the team, the schedule, time cards and webhooks are part of Enterprise. Every new business has all of it for the length of its trial.
Authentication
An owner or general manager creates a key under Settings → API in the console, choosing what it may read. The key is shown once; On Post stores only a hash of it. Send it as a bearer token on every request:
curl "https://getonpost.app/api/v1/on-post" \ -H "Authorization: Bearer onp_…"
- Keys start with
onp_, so a leaked one is greppable and a secret scanner can match it. - A key belongs to the business, never to a person, and outlives whoever created it. Revoking it in the console refuses it on its very next request.
- Keep keys on a server. Never ship one in a browser or a mobile app.
- To rotate: create a new key, move the integration to it, revoke the old one.
Plans and scopes
A key is created with scopes — each one an endpoint it may read. The scopes a business may give a key follow its plan, and every request checks the scope’s plan again: a key that outlives a plan change keeps the scopes the new plan carries and answers plan_required on the rest, with the key itself untouched.
| Scope | Endpoint | Plan |
|---|---|---|
on_post:read | GET /on-post | Pro and up |
locations:read | GET /locations | Pro and up |
team:read | GET /team | Enterprise |
schedule:read | GET /schedule | Enterprise |
time_cards:read | GET /time-cards | Enterprise |
Webhooks are part of Enterprise and are configured in the console, not through the API.
Conventions
The envelope
Every successful answer is { "data": … }. A list that pages carries "meta": { "next_cursor": … } beside it; pass the cursor back as cursor for the next page and stop when it is null. Lists are sorted with an id tiebreak, so one unchanged range never shows a row twice or skips one across pages.
Times and days
Instants are ISO 8601 in UTC (2026-09-27T13:02:11Z). Calendar days are YYYY-MM-DD and are read in the location’s own time zone — every location carries its zone, and a business with locations in two zones gets each location’s own day.
Filters
location_id and employee_id narrow a call inside the key’s business. They never widen it: a location that is not the business’s returns nothing.
Rate limits
60 requests a minute per key on Pro, 300 on Enterprise, burstable to the same number. Every 200 and 429 carries x-ratelimit-limit and x-ratelimit-remaining; a 429 carries retry-after in seconds. A display that refreshes every ten seconds uses six.
Versioning
The path carries the version. Within v1 fields are only ever added, never renamed or removed, so read what you know and ignore the rest. A change that would break a consumer ships as v2 beside it.
Every answer
cache-control: no-store — these are live records — and x-request-id, the handle to quote to support.
Errors
An error is { "error": "<code>", "detail": "<one sentence>" }. The code is stable; the sentence is for a person.
| Status | Code | When |
|---|---|---|
| 400 | invalid_query | A parameter failed validation. `detail` names it. |
| 401 | missing_token | No `Authorization: Bearer` header. |
| 401 | unknown_key | The key is not one of ours, or not in the record. |
| 401 | revoked | The key was revoked. It refuses from its next request. |
| 403 | insufficient_scope | The key was not given this endpoint’s scope. |
| 403 | plan_required | The business’s plan does not carry this endpoint. The key is intact and works again once it does. |
| 413 | range_too_large | More days than the endpoint allows, or a range that computes to more than 5,000 rows. Narrow it. |
| 429 | rate_limited | The key’s bucket is empty. `retry-after` says how many seconds. |
| 500 | Internal Server Error | Ours. Quote the `requestId` in the body to support. |
Who is on post
Pro and upGEThttps://getonpost.app/api/v1/on-post
Scope on_post:read
Everyone clocked in right now, with their role, location, when they clocked in, whether they are on a break, and when their published shift ends if one covers them. Computed from the latest clock-in on every call; never cached.
curl "https://getonpost.app/api/v1/on-post" \ -H "Authorization: Bearer onp_…"
Parameters
| Name | Type | Meaning |
|---|---|---|
location_id | uuid | Only this location. |
Each item in data
| Field | Type | Meaning |
|---|---|---|
employee.id | uuid | The team member. `employee` is the key’s name on the wire; the product says team member. |
employee.first_name | string | Preferred first name. Legal names never leave the record. |
employee.last_name | string | Preferred last name. |
position | object | null | The primary role: `name` and `color` (a hex, the role’s color on the schedule). Null with no role. |
location.id | uuid | The location. Filter other calls by it. |
location.name | string | The location’s name. |
clocked_in_at | datetime | When the current shift began, UTC. |
scheduled_until | datetime | null | The end of the published shift covering this clock-in, or null when none does. |
on_break | boolean | True while on a clocked break. |
Example
{
"data": [
{
"employee": { "id": "6f1c…", "first_name": "Rosa", "last_name": "Diaz" },
"position": { "name": "Pharmacist", "color": "#8a3b1c" },
"location": { "id": "2b9e…", "name": "Front counter" },
"clocked_in_at": "2026-09-27T13:02:11Z",
"scheduled_until": "2026-09-27T21:00:00Z",
"on_break": false
}
]
}The shape is frozen: fields are only ever added.
Locations
Pro and upGEThttps://getonpost.app/api/v1/locations
Scope locations:read
The business’s open locations. Every calendar day and clock time at a location is read in its own time zone, and every other endpoint filters by these ids.
curl "https://getonpost.app/api/v1/locations" \ -H "Authorization: Bearer onp_…"
No parameters.
Each item in data
| Field | Type | Meaning |
|---|---|---|
id | uuid | The location. |
name | string | The location’s name. |
time_zone | string | IANA zone, for example `America/New_York`. |
active | boolean | Always true here; a closed location is not listed. |
Example
{
"data": [
{ "id": "2b9e…", "name": "Front counter", "time_zone": "America/New_York", "active": true }
]
}Team members
EnterpriseGEThttps://getonpost.app/api/v1/team
Scope team:read
Every team member, for an HR or IT sync: names, roles, home location, payroll ID and dates. Never a PIN, a legal name, a date of birth, an email or a phone number.
curl "https://getonpost.app/api/v1/team" \ -H "Authorization: Bearer onp_…"
Parameters
| Name | Type | Meaning |
|---|---|---|
location_id | uuid | Only people whose home location this is. |
include_inactive | boolean | Include people who have left. Default false. |
limit | integer | 1 to 500 per page. Default 200. |
cursor | string | The `meta.next_cursor` of the previous page. |
Each item in data
| Field | Type | Meaning |
|---|---|---|
id | uuid | The team member. |
first_name | string | Preferred first name. |
last_name | string | Preferred last name. |
active | boolean | False once they have left. |
home_location | object | null | `id` and `name` of the home location. |
positions | array | Each role held: `id`, `name`, and `primary` (true on the one role a shift with no role of its own is read as). |
payroll_id | string | null | The payroll provider’s identifier for the person, when recorded. |
hired_on | date | null | YYYY-MM-DD. |
left_on | date | null | YYYY-MM-DD, once they have left. |
Example
{
"data": [
{
"id": "6f1c…", "first_name": "Rosa", "last_name": "Diaz", "active": true,
"home_location": { "id": "2b9e…", "name": "Front counter" },
"positions": [{ "id": "91aa…", "name": "Pharmacist", "primary": true }],
"payroll_id": "10417", "hired_on": "2024-01-15", "left_on": null
}
],
"meta": { "next_cursor": null }
}Sorted by last name, then first name.
Published schedule
EnterpriseGEThttps://getonpost.app/api/v1/schedule
Scope schedule:read
Published shifts between two dates — what the team can see. Drafts are never sent. Defaults to the fourteen days from today; at most 62 days at a time.
curl "https://getonpost.app/api/v1/schedule?from=YYYY-MM-DD&to=YYYY-MM-DD" \ -H "Authorization: Bearer onp_…"
Parameters
| Name | Type | Meaning |
|---|---|---|
from | date | First day, YYYY-MM-DD, in the location’s zone. Default today. |
to | date | Last day, inclusive. Default thirteen days after `from`. |
location_id | uuid | Only this location. |
employee_id | uuid | Only this team member. |
limit | integer | 1 to 500 per page. Default 200. |
cursor | string | The `meta.next_cursor` of the previous page. |
Each item in data
| Field | Type | Meaning |
|---|---|---|
id | uuid | The shift. |
employee | object | null | `id`, `first_name`, `last_name`. Null for an open shift. |
location.id | uuid | The location. Filter other calls by it. |
location.name | string | The location’s name. |
position | object | null | `id` and `name` of the role the shift names, or null. |
starts_at | datetime | UTC. |
ends_at | datetime | UTC. |
published_at | datetime | null | When the shift was published. |
Example
{
"data": [
{
"id": "c40d…",
"employee": { "id": "6f1c…", "first_name": "Rosa", "last_name": "Diaz" },
"location": { "id": "2b9e…", "name": "Front counter" },
"position": { "id": "91aa…", "name": "Pharmacist" },
"starts_at": "2026-09-28T13:00:00Z", "ends_at": "2026-09-28T21:00:00Z",
"published_at": "2026-09-24T18:40:03Z"
}
],
"meta": { "next_cursor": null }
}Sorted by start time. A shift’s notes are never sent.
Time cards
EnterpriseGEThttps://getonpost.app/api/v1/time-cards
Scope time_cards:read
Shifts worked between two dates, from the same engine that pays them: unpaid break time taken out, the employer’s rounding applied, overtime split per workweek under the employer’s rule. Hours only, never pay. Defaults to the last seven days; at most 31 days at a time.
curl "https://getonpost.app/api/v1/time-cards?from=YYYY-MM-DD&to=YYYY-MM-DD" \ -H "Authorization: Bearer onp_…"
Parameters
| Name | Type | Meaning |
|---|---|---|
from | date | First day, YYYY-MM-DD, in each location’s zone. Default six days ago. |
to | date | Last day, inclusive. Default today. |
location_id | uuid | Only this location. |
employee_id | uuid | Only this team member. |
limit | integer | 1 to 500 per page. Default 200. |
cursor | string | The `meta.next_cursor` of the previous page. |
Each item in data
| Field | Type | Meaning |
|---|---|---|
id | uuid | The clock-in that opened the shift; stable for the life of the record. |
employee.id | uuid | The team member. `employee` is the key’s name on the wire; the product says team member. |
employee.first_name | string | Preferred first name. Legal names never leave the record. |
employee.last_name | string | Preferred last name. |
location.id | uuid | The location. Filter other calls by it. |
location.name | string | The location’s name. |
position | object | null | The role the shift was worked as, or null. |
clock_in | datetime | UTC, exactly as recorded. |
clock_out | datetime | null | Null while the shift is still open. |
break_minutes | integer | Clocked break time inside the shift. |
unpaid_break_minutes | integer | The part of that time taken off pay. |
paid_hours | number | What the shift pays, to two decimals. Zero while the shift is open. |
regular_hours | number | Paid hours at the regular rate. |
overtime_hours | number | Paid hours at 1.5×, split per workweek. |
doubletime_hours | number | Paid hours at 2×, where the employer’s rule has them. |
holiday_hours | number | Paid hours on a premium holiday. |
problems | array of string | The engine’s flags, for example `no_clock_out` or `missed_meal_break`. Empty when nothing needs a person. |
Example
{
"data": [
{
"id": "a7e2…",
"employee": { "id": "6f1c…", "first_name": "Rosa", "last_name": "Diaz" },
"location": { "id": "2b9e…", "name": "Front counter" },
"position": { "id": "91aa…", "name": "Pharmacist" },
"clock_in": "2026-09-22T13:02:11Z", "clock_out": "2026-09-22T21:34:50Z",
"break_minutes": 30, "unpaid_break_minutes": 30,
"paid_hours": 8.03, "regular_hours": 8.03, "overtime_hours": 0,
"doubletime_hours": 0, "holiday_hours": 0,
"problems": []
}
],
"meta": { "next_cursor": null }
}Sorted by clock-in. A correction changes the figures the next time the range is read; walk a live range again when it matters.
Webhooks
EnterpriseA webhook tells your system the moment something happens, so nothing has to poll. Under Settings → API an owner or general manager adds an HTTPS endpoint, chooses its events, and receives a signing secret once. On Post then POSTs a JSON body to it for every event chosen.
Events
| Event | When |
|---|---|
clock_in.recorded | A team member clocked in — at the shared time clock, from a phone, or recorded by a manager. |
clock_out.recorded | A team member clocked out. |
break.started | A clocked break began. `data.break_kind` is `meal`, `rest`, or null where the business does not name kinds. |
break.ended | A clocked break ended. |
schedule.published | A manager published a schedule cycle. Read the shifts with `GET /schedule`. |
webhook.test | You pressed Send a test on the endpoint. |
The body
id is the delivery, event the key above, created_at when the event happened, and data uses the API’s own shapes.
{
"id": "d2f0…",
"event": "clock_in.recorded",
"created_at": "2026-09-27T13:02:12Z",
"data": {
"punch_id": "a7e2…",
"employee": { "id": "6f1c…", "first_name": "Rosa", "last_name": "Diaz" },
"location": { "id": "2b9e…", "name": "Front counter" },
"occurred_at": "2026-09-27T13:02:11Z",
"method": "rfid"
}
}{
"event": "schedule.published",
"data": {
"location": { "id": "2b9e…", "name": "Front counter" },
"from": "2026-09-28", "to": "2026-10-11",
"shift_count": 42, "newly_published": 42,
"published_at": "2026-09-24T18:40:03Z"
}
}The headers
| Header | Carries |
|---|---|
on-post-signature | t=<unix seconds>,v1=<hex> — an HMAC-SHA256 of "<t>.<body>" under the endpoint’s secret. |
on-post-event | The event key. |
on-post-delivery | The delivery id. A delivery may arrive more than once; treat this as the idempotency key. |
Verifying a delivery
Compute the signature over the raw body with your secret and compare it in constant time; refuse anything whose timestamp is more than 5 minutes old. In Node:
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody is the request body as received — before any JSON parsing.
export function verifyOnPost(secret, signatureHeader, rawBody) {
const parts = Object.fromEntries(signatureHeader.split(',').map((p) => p.split('=')));
const ageSeconds = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!(ageSeconds < 300)) return false;
const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
const given = parts.v1 ?? '';
return expected.length === given.length &&
timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(given, 'hex'));
}What your endpoint must do
- Answer any 2xx within 10 seconds. Do the work afterwards; the body of your answer is never read.
- Be reachable on a public HTTPS address. Private, loopback and link-local addresses are refused when the endpoint is added, and redirects are not followed.
- Expect a delivery more than once and out of order. Dedupe by the delivery id; order by
created_at.
Retries
A delivery that is not answered 2xx is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, then marked dead. An endpoint whose deliveries keep failing — five in a row — is marked failing on the console page and in the Attention drawer, and comes back the moment a delivery succeeds. The last ten deliveries and their status are on the endpoint’s row; Send a test posts a webhook.test event to check the wiring.
OpenAPI document
The same reference as an OpenAPI 3.1 document, for Postman, a generated client, or a diff between releases. It needs no key: the contract is public, the data is not.
https://getonpost.app/api/v1/openapi.json
Changelog
- 2026-09-27Locations, team members, the published schedule and time cards; signed webhooks; the roster read moves to Pro and the rest is Enterprise; cursor pagination; the OpenAPI document.
- 2026-09-05`scheduled_until` on who is on post is real: the end of the published shift covering the clock-in.
- 2026-08-21The first endpoint, who is on post, authenticated by a key.
Questions, or an endpoint you need that is not here: hello@getonpost.app. Business accounts and plans live at pricing.