API

The On Post API

Read who is on post right now, the locations, the team, the published schedule and every time card — from the same record the console shows — and receive a signed webhook the moment somebody clocks in or a schedule is published. Read-only, keyed per business, and documented here in full.

Base URL
https://getonpost.app/api/v1
Keys
Settings → API, in the console
Format
JSON over HTTPS
Contents

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.

on_post:readGET /on-postPro and up
locations:readGET /locationsPro and up
team:readGET /teamEnterprise
schedule:readGET /scheduleEnterprise
time_cards:readGET /time-cardsEnterprise

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.

400invalid_queryA parameter failed validation. `detail` names it.
401missing_tokenNo `Authorization: Bearer` header.
401unknown_keyThe key is not one of ours, or not in the record.
401revokedThe key was revoked. It refuses from its next request.
403insufficient_scopeThe key was not given this endpoint’s scope.
403plan_requiredThe business’s plan does not carry this endpoint. The key is intact and works again once it does.
413range_too_largeMore days than the endpoint allows, or a range that computes to more than 5,000 rows. Narrow it.
429rate_limitedThe key’s bucket is empty. `retry-after` says how many seconds.
500Internal Server ErrorOurs. Quote the `requestId` in the body to support.

Who is on post

Pro and up

GEThttps://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

location_iduuidOnly this location.

Each item in data

employee.iduuidThe team member. `employee` is the key’s name on the wire; the product says team member.
employee.first_namestringPreferred first name. Legal names never leave the record.
employee.last_namestringPreferred last name.
positionobject | nullThe primary role: `name` and `color` (a hex, the role’s color on the schedule). Null with no role.
location.iduuidThe location. Filter other calls by it.
location.namestringThe location’s name.
clocked_in_atdatetimeWhen the current shift began, UTC.
scheduled_untildatetime | nullThe end of the published shift covering this clock-in, or null when none does.
on_breakbooleanTrue 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 up

GEThttps://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

iduuidThe location.
namestringThe location’s name.
time_zonestringIANA zone, for example `America/New_York`.
activebooleanAlways true here; a closed location is not listed.

Example

{
  "data": [
    { "id": "2b9e…", "name": "Front counter", "time_zone": "America/New_York", "active": true }
  ]
}

Team members

Enterprise

GEThttps://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

location_iduuidOnly people whose home location this is.
include_inactivebooleanInclude people who have left. Default false.
limitinteger1 to 500 per page. Default 200.
cursorstringThe `meta.next_cursor` of the previous page.

Each item in data

iduuidThe team member.
first_namestringPreferred first name.
last_namestringPreferred last name.
activebooleanFalse once they have left.
home_locationobject | null`id` and `name` of the home location.
positionsarrayEach role held: `id`, `name`, and `primary` (true on the one role a shift with no role of its own is read as).
payroll_idstring | nullThe payroll provider’s identifier for the person, when recorded.
hired_ondate | nullYYYY-MM-DD.
left_ondate | nullYYYY-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

Enterprise

GEThttps://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

fromdateFirst day, YYYY-MM-DD, in the location’s zone. Default today.
todateLast day, inclusive. Default thirteen days after `from`.
location_iduuidOnly this location.
employee_iduuidOnly this team member.
limitinteger1 to 500 per page. Default 200.
cursorstringThe `meta.next_cursor` of the previous page.

Each item in data

iduuidThe shift.
employeeobject | null`id`, `first_name`, `last_name`. Null for an open shift.
location.iduuidThe location. Filter other calls by it.
location.namestringThe location’s name.
positionobject | null`id` and `name` of the role the shift names, or null.
starts_atdatetimeUTC.
ends_atdatetimeUTC.
published_atdatetime | nullWhen 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

Enterprise

GEThttps://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

fromdateFirst day, YYYY-MM-DD, in each location’s zone. Default six days ago.
todateLast day, inclusive. Default today.
location_iduuidOnly this location.
employee_iduuidOnly this team member.
limitinteger1 to 500 per page. Default 200.
cursorstringThe `meta.next_cursor` of the previous page.

Each item in data

iduuidThe clock-in that opened the shift; stable for the life of the record.
employee.iduuidThe team member. `employee` is the key’s name on the wire; the product says team member.
employee.first_namestringPreferred first name. Legal names never leave the record.
employee.last_namestringPreferred last name.
location.iduuidThe location. Filter other calls by it.
location.namestringThe location’s name.
positionobject | nullThe role the shift was worked as, or null.
clock_indatetimeUTC, exactly as recorded.
clock_outdatetime | nullNull while the shift is still open.
break_minutesintegerClocked break time inside the shift.
unpaid_break_minutesintegerThe part of that time taken off pay.
paid_hoursnumberWhat the shift pays, to two decimals. Zero while the shift is open.
regular_hoursnumberPaid hours at the regular rate.
overtime_hoursnumberPaid hours at 1.5×, split per workweek.
doubletime_hoursnumberPaid hours at 2×, where the employer’s rule has them.
holiday_hoursnumberPaid hours on a premium holiday.
problemsarray of stringThe 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

Enterprise

A 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

clock_in.recordedA team member clocked in — at the shared time clock, from a phone, or recorded by a manager.
clock_out.recordedA team member clocked out.
break.startedA clocked break began. `data.break_kind` is `meal`, `rest`, or null where the business does not name kinds.
break.endedA clocked break ended.
schedule.publishedA manager published a schedule cycle. Read the shifts with `GET /schedule`.
webhook.testYou 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

on-post-signaturet=<unix seconds>,v1=<hex> — an HMAC-SHA256 of "<t>.<body>" under the endpoint’s secret.
on-post-eventThe event key.
on-post-deliveryThe 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.