OtisIO API v1
Updated 13 August 2026
The OtisIO API is a REST API scoped to a single workspace. Read people, shifts, timesheets, time-off, tasks and sites into payroll, BI or your own tools; add and update people, build the rota and record clock-ins from your own systems; and subscribe to webhooks to react to events as they happen.
Reading needs nothing but a token. Each kind of writing is a separate ability you tick when you create the token, so a reporting integration cannot change anything even if its key leaks.
OpenAPI spec
Everything on this page is also published as an OpenAPI 3.1 document:
Point your generator at it to get a typed client in your own language, or load it into Postman, Insomnia or Bruno to get every endpoint with its parameters already filled in. It covers the responses field by field, including which fields depend on which ability, the error envelope, and the webhook payloads.
The spec is checked against the code on every build: a field cannot appear in it unless the API really returns it, and a field the API returns cannot be left out of it. So where this page and the spec ever disagree, the spec is the one that was tested.
Base URL
Every request goes to your own workspace subdomain, under /api/v1:
https://your-workspace.otisio.com/api/v1
A token minted in one workspace is valid only for that workspace. There is no cross-workspace endpoint.
Authentication
Create a token under Settings -> API & webhooks. Send it as a bearer token:
Authorization: Bearer 42|xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Abilities
Every token carries read, which is the whole of the read surface. The rest are opt-in, ticked when you create the token. Two of them unlock a set of FIELDS, and three unlock the ability to WRITE:
| Ability | Unlocks |
|---|---|
read |
Every GET in this document. Granted to every token. |
people:pay |
hourly_rate_pence, day_rate_pence, annual_salary_pence, pay_basis and payroll_number on a person, and hourly_rate_pence on a shift. |
time:location |
clock_in_lat, clock_in_lng, clock_out_lat and clock_out_lng on a punch. |
people:write |
Adding a person, changing their details, archiving and restoring them. |
shifts:write |
Creating, changing and deleting shifts. |
time:write |
Recording clock-ins and clock-outs, and correcting a punch. |
The write abilities are separate from each other on purpose. A payroll integration that needs to read hours and a rota tool that needs to build next week are different jobs, and a key that leaks should cost you only the one it was minted for. Granting shifts:write does not let a token touch people, and no ability at all lets it read pay it was not given.
A write ability never widens WHOSE records a token can reach. A token belonging to a manager who can only see their own team can only write to their own team, and archiving somebody is gated at least as tightly as reading them, because it ends their access to the product.
There is one place the two deliberately differ, and it is the one that would otherwise cost you something. A leaver stays readable to the token that could read them before. They appear in GET /people with archived_at set and they are still fetchable by id, whatever the token's reach, because a directory sync that stops seeing somebody cannot tell "they have left" from "they were never here", and the account at your end stays enabled. Bringing them back is the other direction: POST /people/{id}/restore takes a seat off the workspace's plan, so a team-scoped token cannot do it even for its own person, and an Owner or Admin token can.
Fields you have not been granted are absent from the JSON, not null. A null means the workspace has no value recorded; a missing key means your token was not given that ability. Code that reads pay or coordinates should treat a missing key as "not authorised" and a null as "not recorded", because those are two different problems with two different fixes.
Abilities are additive, so a token minted before an ability existed does not have it and its responses are byte-for-byte what they always were.
The API requires a plan that includes API access. Requests on a plan without it receive 403 forbidden.
Pagination
List endpoints are cursor-paginated:
{
"data": [ ... ],
"meta": { "next_cursor": "eyJpZCI6NDJ9", "per_page": 50 }
}
Pass ?cursor=<next_cursor> to fetch the next page and ?per_page=<n> to size it (default 50, maximum 100). When next_cursor is null you have reached the end.
Filtering
updated_since=<ISO8601>returns only records changed on or after that instant. Use it to poll for changes cheaply.
URL-encode every ISO8601 value you put in a query string. An offset ends +01:00, and a raw + in a query string means a space, so the unencoded form arrives here as an unparseable date and you get 422 validation_failed on a timestamp that looks perfectly correct in your logs. Encoded it reads 2026-08-13T09%3A00%3A00%2B01%3A00. Sending UTC with a trailing Z sidesteps it entirely.
/shiftsdefaults to the current week and acceptsstatus=<status>./time-entriesacceptsuser_id=<id>,location_id=<id>,from=<ISO8601>andto=<ISO8601>.
from and to on /time-entries filter on clock_in_at, the instant the punch started, which is the question a period export is asking: a punch edited today still belongs to the week it was worked. updated_since answers the different question "what has changed since I last polled", and the two can be combined.
Both bounds are compared as instants, so a bare ?to=2026-08-07 means that date's midnight and excludes that day. Pass a time, or the following date, to include it. /shifts already behaves this way.
Endpoints
| Method | Path | Returns |
|---|---|---|
| GET | /people |
Workspace members, including archived |
| GET | /people/{id} |
A single person |
| GET | /shifts |
Scheduled shifts (current week by default) |
| GET | /time-entries |
Clock-in/out timesheet entries |
| GET | /leave-requests |
Time-off requests |
| GET | /tasks |
Tasks |
| GET | /locations |
Sites, and the geofence on each |
| GET | /locations/{id} |
A single site |
And the writes, each needing the ability in the last column:
| Method | Path | Does | Needs |
|---|---|---|---|
| POST | /people |
Adds a person, taking a seat | people:write |
| PATCH | /people/{id} |
Changes their details | people:write |
| DELETE | /people/{id} |
Archives a leaver | people:write |
| POST | /people/{id}/restore |
Brings an archived person back | people:write |
| POST | /shifts |
Creates a shift | shifts:write |
| PATCH | /shifts/{id} |
Changes a shift | shifts:write |
| DELETE | /shifts/{id} |
Deletes a shift | shifts:write |
| POST | /time-entries/clock-in |
Records a clock-in | time:write |
| POST | /time-entries/clock-out |
Closes the open punch | time:write |
| PATCH | /time-entries/{id} |
Corrects a punch | time:write |
All money is returned in integer pence with a _pence suffix; all times are ISO 8601 in UTC.
Sites and their fences
location_id appears on every punch and every shift, so /locations is how you turn that number into something you can show a human or reconcile against.
| Field | Meaning |
|---|---|
id |
The value you will see as location_id elsewhere. |
name |
What the workspace calls the site. |
address |
Free text, or null. |
timezone |
The site's IANA zone, for rendering local times. |
geofence_lat / geofence_lng / geofence_radius_m |
The fence centre and its radius in whole metres. All three are null together on a site with no fence; a radius is never published without a centre. |
no_location_policy |
What happens to a self-service punch here that arrives with no usable position: allow (recorded as normal), flag (recorded and raised for review) or disallow (refused). This is why a punch can carry a no_location flag rather than simply not existing. |
is_active |
Whether the site is in day-to-day use. |
archived_at |
When the site was retired, or null. |
updated_at |
For updated_since polling. |
Archived sites are included in the list, deliberately. A site closed last March still owns every punch ever worked there, so leaving it out would give you location_id values you could not resolve. Filter on archived_at or is_active yourself when you are building a picker rather than resolving history.
The fence is not behind the time:location ability. That ability withholds coordinates that track a person; a site's own centre and radius are a fact about the employer's premises, and without the radius the in_zone verdict and the distance in metres below have no units.
What /locations never returns, whatever your abilities: the secrets inside a site's printed clock-in and sign-up posters, and the code that unlocks the kiosk app on the wall. Those are credentials for entering the building, not properties of it.
Where a punch happened
Every entry from /time-entries carries the site it was attributed to and the geofence verdict for each end of the punch. These fields are always present, null where the question does not apply, so you can test them rather than probe for them:
| Field | Meaning |
|---|---|
location_id |
The site the punch was attributed to. null on a workspace that does not use sites, or a punch that resolved to none. |
clock_in_via / clock_out_via |
What proved presence instead of the fence: qr (a printed site poster), qr_rotating (the code on the gate screen), or handover (the punch was closed by the person arriving at another site). null on an ordinary self-service punch, which is the common case and is not a fault. A kiosk or NFC tap shows in source instead. |
clock_in_in_zone / clock_out_in_zone |
true or false for inside the radius. null rather than false whenever nothing can be said: no site, no fence on the site, or no coordinates. |
clock_in_geo_flag / clock_out_geo_flag |
Why the punch wants a manager's eye: out_of_zone (coordinates arrived and were outside the radius) or no_location (a fenced site and no coordinates arrived). null means clean or not applicable, which in_zone separates. |
clock_in_distance_m / clock_out_distance_m |
Whole metres from the fence centre wherever it was computable, flagged or clean. |
A fence flags and does not block. The person did start working, so the punch is recorded truthfully and carries a review marker rather than being refused. That is what makes these fields worth reconciling against.
One thing not to read into a null flag: it does not mean there was no fence. Flags are computed at punch time and were never backfilled, so punches predating the geofence release carry nulls whatever the site was doing.
The raw coordinates (clock_in_lat, clock_in_lng, clock_out_lat, clock_out_lng) require the time:location ability and are absent from the JSON without it. So do clock_in_geo_reason and clock_out_geo_reason, which carry what the person typed to explain a punch the fence could not vouch for: a sentence about where somebody was is the same class of fact as the position itself. They are only ever populated alongside a flag.
Rejected time entries
/time-entries returns every punch on the record, including ones a manager has rejected, so that you can reconcile a complete set and audit the rejections. Each entry carries:
voided_atthe instant a manager rejected the punch, ornullon a live onevoided_bythe id of the manager who rejected it, ornull
A rejected punch still reports its recorded worked_minutes, because those minutes really were clocked before they were rejected. They count for nothing inside OtisIO, so if you are totalling hours, skip any entry with a non-null voided_at:
worked = sum(e.worked_minutes for e in entries if e.voided_at is None and e.worked_minutes is not None)
A null worked_minutes means something different: the punch is still open (no clock_out_at yet).
Writing data
Writes go through exactly the same rules as the screens do. There is no second, looser path for machines: the seat cap, the geofence assessment, the rota's conflict and certification checks and every audit line are the same code whether a person clicked a button or your integration made a call. Where this document says something is refused, it is refused for the app too.
People
POST /people takes a seat. Creating somebody who is currently archived brings them back onto their old record rather than failing or making a duplicate, because a returning employee is the same person and their history should follow them. Creating somebody already active is a 409 conflict, and so is creating anybody at all when the plan has no seats left.
DELETE /people/{id} archives, it does not delete. Their timesheets, signed documents and clock history are employment records and are kept; what goes is the seat, the ability to sign in, and their place on every kiosk. Their tokens and live sessions are revoked in the same step, so access ends when you make the call rather than whenever a session happens to expire. It returns 200 with the archived person, so you can see the archived_at that landed, and calling it twice is safe.
Alongside name, email and position, both write bodies take phone, started_on and external_id.
phone is stored in E.164, because that column is what the SMS channel sends to and a number in any other shape is a message that silently never arrives. Spaces, dashes and a leading 00 are normalised before validation, so 0044 7700 900123 is accepted and stored as +447700900123; anything that will not reduce to E.164 is a 422 naming the format.
external_id is your own system's id for this person, and it is returned on every person. Join on it rather than the email address, which is the one field a workforce genuinely changes. It is the same column SCIM writes as externalId, so a workspace using both sees one value, not two.
On PATCH, sending null clears any of the three. Omitting the key leaves it alone.
active is rejected on both POST and PATCH with a 422, rather than quietly ignored. Archiving and restoring are their own endpoints so that a lifecycle change can never hide inside a field edit and slip past whoever is reading the audit log. A token also cannot archive its own owner: that would revoke the credential mid-request and leave your integration holding a dead key with nothing to explain why.
Shifts
Everything you create is a draft. There is no status field and no publish endpoint. Publishing on the schedule screen is a week-wide action that notifies every assignee and writes an audit line, so a field that flipped one row would put a shift in front of somebody nobody had told. Build the week over the API, then publish it in OtisIO.
PATCH takes the whole shift, not just the field you changed: location_id, starts_at and ends_at are always required. A shift's conflict rules cannot be run against a half-described window, and there is exactly one rule set behind both this API and the schedule screen. Anything you leave out takes its default, so send the shift you want to exist.
A refusal tells you whether trying again could ever work:
409 conflictis a hard block: the person is already on an overlapping shift, or they are missing a qualification the workspace has set to block. The message names the clash. Nothing you add to the payload changes it.422 validation_failedis either a malformed payload, or warnings you have not accepted. Those arrive underfields.warnings: booked leave, stated availability, a weekly cap, a short rest gap, a missing break. Resend withacknowledge_warnings: trueto schedule anyway, which is the same override a manager gets from "Schedule anyway". It never overrides a409.
required_certification_ids accepts qualifications only. A skill has no expiry, no evidence and nothing verifying it, so a shift requiring one would report its holder as compliant with nothing behind it; sending a skill id is a 422. Omit the field to leave a shift's requirements alone and send [] to clear them.
A shift's pay override reads back as hourly_rate_pence. You write it as hourly_rate and read it as hourly_rate_pence: the write field is the same one the schedule screen uses and cannot be renamed under it, and every money field on the read surface carries the _pence suffix. It needs people:pay and is absent rather than null without it, so a token that cannot read pay cannot tell whether an override exists at all. A null means there is no override and the person's own rate applies, which is a different fact and one a payroll consumer needs.
It is never on the shift.published webhook. A webhook holds no token, so nothing there can carry the ability, and a rate in that payload would be posted to every subscribed endpoint on the workspace. Read the shift back with a key that has people:pay if you need it.
is_overtime needs the overtime feature on your plan. Below it the flag is recorded as false rather than refused, so read is_overtime back off the response: that property is the only way to tell whether the flag you sent survived, and finding out at the first payroll run instead is expensive.
DELETE returns 200 with the shift as it stood, so a sync knows what it just removed.
Time
A punch you record arrives as source: "api". It is not manager: no human moved that clock, and a timesheet that named one would be a record nobody could defend. The person is still told their clock changed, in copy that names a connected system rather than a person.
A punch outside the geofence is recorded, not refused. It comes back with clock_in_in_zone: false, a clock_in_geo_flag, the distance in metres, and it raises a time_entry.flagged webhook. Blocking it would leave somebody unpaid for work they really did and would put the argument at the gate rather than in the timesheet where a manager can settle it. The only refusal is a site set to disallow, which turns away a punch carrying no position at all.
Send lat and lng together or not at all. A lone coordinate is a 422: half a position cannot be judged against a fence, and storing it would imply we had checked.
Generate a UUID per punch and send it as client_punch_id. Retrying with the same one returns the original punch instead of creating a second, which is what makes a timed-out request safe to repeat. Without it you cannot tell a lost request from a successful one whose response you never saw.
Clocking out somebody with nothing open is a 409, not a 422, and PATCH /time-entries/{id} requires a reason. This endpoint changes what somebody is paid and the only actor on the record is a token, so the reason is the sole account of why the figure moved.
A correction cannot rewrite the geofence verdict, the coordinates, how the punch was made, who it belongs to, which site it was at, or whether it was rejected. Those are the record of what happened rather than fields about it, and if they were editable a compliance consumer could no longer trust that an in_zone verdict was the one the clock actually reached.
Errors
Errors use a consistent envelope:
{ "error": { "code": "validation_failed", "message": "The given data was invalid.", "fields": { "per_page": ["..."] } } }
| Status | Code |
|---|---|
| 401 | unauthenticated |
| 403 | forbidden / account_suspended |
| 404 | not_found |
| 409 | conflict |
| 422 | validation_failed |
| 429 | rate_limited |
| 500 | server_error |
409 conflict and 422 validation_failed are worth separating in your retry logic. A 422 means the request was malformed, so sending it again unchanged will fail the same way for ever. A 409 means the request was well formed and lost to the current state of the workspace, so re-reading and retrying is a reasonable thing to do.
Rate limits
Requests are limited to 60 per minute per token. Over the limit you receive 429 rate_limited with a Retry-After header.
Directory provisioning (SCIM 2.0)
If your identity provider is the system of record for who works here, you do not need the API at all. OtisIO is a SCIM 2.0 service provider, so Microsoft Entra ID, Okta and anything else that speaks SCIM can create a joiner and archive a leaver here without anyone opening this app.
It is a separate endpoint with its own credential, not an ability on an API token, because it is a different trust relationship: an identity provider holds a connection you configure once and then forget about, and it must keep working when the person who set it up has left.
https://your-workspace.otisio.com/scim/v2
Mint the bearer token under Settings -> Directory provisioning (SCIM), which also shows the exact steps for Entra and for Okta. Entra calls the base URL the Tenant URL; Okta calls it the SCIM connector base URL.
| Method | Path | Does |
|---|---|---|
| GET | /Users |
List and filter users |
| POST | /Users |
Create a user |
| GET | /Users/{id} |
Fetch one user |
| PUT | /Users/{id} |
Replace a user |
| PATCH | /Users/{id} |
Patch a user, including active |
| DELETE | /Users/{id} |
Deactivate a user |
| GET | /ServiceProviderConfig, /ResourceTypes, /Schemas |
Discovery |
Three behaviours worth knowing before you connect it:
- Deactivation archives, it does not delete. Setting
activetofalse, or sendingDELETE, frees the licensed seat, stops every sign-in, and revokes the sessions and app tokens that are already open, which is the part that actually matters for a leaver on a site. The employment record, the timesheets and the signed equipment agreements stay, because an employer is required to keep them. - Creating somebody who is already here and archived brings them back rather than failing. That is the returning agency worker, and refusing would leave your IdP with no verb that can make progress. Creating somebody who is already here and active returns
409, because at that point your directory has lost track of an account it should have matched. /Groupsanswers501, not404. This connector does users only. An IdP that probes/Groupson connect reads a404as a broken endpoint and stops; a501tells it the truth and it carries on.
Directory provisioning is an Enterprise feature. If the plan drops, the sync stops rather than a stale connection quietly continuing to write people.
Webhooks
Add an https endpoint under Settings -> API & webhooks and subscribe it to events. When an event fires we POST a JSON envelope:
{
"id": "018f...",
"event": "shift.published",
"created_at": "2026-07-08T12:00:00Z",
"data": { ... }
}
Headers on each delivery:
X-OtisIO-Eventthe event nameX-OtisIO-Deliverya unique id for this deliveryX-OtisIO-Signaturean HMAC-SHA256 of the raw request body, keyed with your endpoint's signing secret
Verifying the signature
Compute the HMAC over the exact bytes you received and compare, in constant time, to the header:
$expected = hash_hmac('sha256', $rawBody, $signingSecret);
if (! hash_equals($expected, $request->header('X-OtisIO-Signature'))) {
abort(400);
}
Respond with any 2xx to acknowledge. Non-2xx responses are retried with backoff; persistently failing deliveries can be replayed from the webhook's delivery history.
Events
| Event | Fires when | data |
|---|---|---|
shift.published |
A shift is published to the schedule | shift |
time_entry.clocked_in |
Somebody clocks in | time entry |
time_entry.clocked_out |
Somebody clocks out | time entry |
time_entry.flagged |
A punch is recorded outside its site's area, or with no location at all | time entry |
timesheet.approved |
A timesheet entry is approved | time entry |
leave.approved |
A time-off request is approved | leave request |
leave.rejected |
A time-off request is declined | leave request |
person.joined |
A person accepts an invitation and joins | person |
person.updated |
A person's name, email, job title, phone or start date changes | person |
person.archived |
A person is archived | person |
expense_report.approved |
An expense claim is approved | expense report |
expense_report.rejected |
An expense claim is rejected | expense report |
The data object is the same shape the matching endpoint returns, so a subscriber can hold one parser.
On the punch events. They are emitted from the time clock itself, so every way a punch can be made carries them: the app, the web page, a kiosk, an NFC tag, a scanned poster, a manager punching on somebody's behalf, and an offline punch when it finally syncs. On a synced offline punch the envelope's created_at is the moment we emitted; clock_in_at and clock_out_at in the payload are the true punch times, and those are what you reconcile on.
time_entry.flagged arrives in addition to the punch event, not instead of it, and carries the same payload, so clock_in_geo_flag / clock_out_geo_flag tell you why and clock_in_distance_m / clock_out_distance_m tell you how far. Punch payloads never carry raw coordinates, whatever abilities your tokens hold.
Two clock-outs deliberately emit nothing: the overnight sweep that closes a punch nobody closed (the finish time is our estimate, not an observation) and a manager voiding a punch (a rejection stamped on an entry, not a clock movement). person.updated never fires for a pay change, a password or a preference, so a delivery cannot be used to infer the timing of one.