Quick start
The inshift API gives another program access to one organization: its people, locations, job positions, schedule, absences, clock-in records, timesheet and payroll. Reading covers all of that; writing covers the schedule and the staff list, and comes with its own rules (see Writing).
Base URL: https://api.inshift.app
1. Get a key
The organization owner creates it in the admin panel: Settings → Integrations → Create key. They tick exactly the permissions you ask for, and the secret is shown once. Ask for the least you need: a key you cannot lose is one that was never allowed to read much.
A key looks like this:
insh_live_7f3a9c21b4e2_KJh8sV2pQx...
2. Call something
curl https://api.inshift.app/v1/company \
-H "Authorization: Bearer insh_live_7f3a9c21b4e2_KJh8sV2pQx..."
{
"id": "17b03b07-7ed5-4e8c-bc50-2342027758c4",
"name": "Good Life Coffee",
"timezone": "Europe/Kyiv",
"currency": "UAH",
"created_at": "2026-07-22T08:52:59.257Z",
"features": { "attendance": true, "open_shifts": true }
}
Call this first, every time you set up an integration. It carries two things the rest of the API cannot be read correctly without:
timezone, because every wall-clock time in the API is that organization's local time, and the time zone lives here, not on each shift;features.attendance, whether clock-in tracking is on at all. An empty/v1/attendancemeans "the module is off" just as often as it means "nobody clocked in", and this flag is the only way to tell the two apart.
3. Pull a month of hours
The endpoint most integrations actually want:
curl "https://api.inshift.app/v1/timesheets?from=2026-09-01&to=2026-09-30" \
-H "Authorization: Bearer $INSHIFT_KEY"
One record per person per working day per location, with the planned hours and (when clock-in tracking is on) the actual ones.
What the API does not have
- Money is mostly read. Payroll is readable behind its own scope,
payroll:read: accruals with every supplement explained, payouts, balances, pay runs and the pay configuration. Behind a second scope,payroll:write, a key records advances, other payouts, bonuses and deductions, and changes only its own entries; settlements, closing periods, pay runs and pay terms are not written through the API. Read Payroll before the first request: three numbers in a row look alike and answer different questions. - No
organization_idparameter. Anywhere. The key already identifies exactly one organization and can never reach another one. If you send such a parameter you get a400, not a guess.
Endpoints
| Endpoint | What it is |
|---|---|
GET /v1/company | The organization: time zone, enabled modules |
GET /v1/locations | Locations |
GET /v1/positions | Job positions ("Barista"), not access roles |
GET /v1/employees | People |
GET /v1/employees/by-external-id/{id} | One person by the id they have in *your* system |
GET /v1/shifts | The schedule |
GET /v1/absences | Vacation, sick leave, days off |
GET /v1/attendance | Raw clock-in records |
GET /v1/timesheets | Hours per person per day: plan and fact |
GET /v1/events | What changed since last time |
GET /v1/payroll | Money per person for a period, every supplement explained |
GET /v1/payroll/days | The same, per person per day |
GET /v1/payments | Payouts to people |
GET /v1/payroll/adjustments | Bonuses and deductions |
GET /v1/payroll/runs | Pay runs: periods closed for everybody at once |
GET /v1/payroll/rules | Supplement rules, with history |
GET /v1/payroll/pay-terms | Pay terms, with history |
And what writes:
| Endpoint | What it does |
|---|---|
POST /v1/shifts | Create one shift |
POST /v1/shifts/batch | Create up to 500 shifts, with a per-row report |
PATCH /v1/shifts/{id} | Change a shift. Needs If-Match with its version |
DELETE /v1/shifts/{id} | Remove a shift. Needs If-Match too |
PUT /v1/employees/by-external-id/{id} | Create or update a person by the id they have in *your* system |
Writing has its own rules: an Idempotency-Key on every POST, If-Match on every change to an existing shift. Read Writing before the first POST, not after the first duplicate.
The full machine-readable description is in openapi.json. Point Postman, Swagger UI or a client generator at it.
Authentication
Authorization: Bearer insh_live_<key_id>_<secret>
That is the whole mechanism. There is no OAuth flow, no token refresh, no login call. A key belongs to an organization, not to a person, and it does not expire on its own.
The shape of a key
insh_live_7f3a9c21b4e2_KJh8sV2pQxR...
^^^^^^^^^ ^^^^^^^^^^^^ ^^^^^^^^^^^^
prefix key id secret
- prefix:
insh_live_. It is there so secret scanners (GitHub, GitLab, CI) recognise a leaked key within minutes instead of nobody noticing for months. - key id: the public half. It identifies the key in the owner's key list and it is the thing to quote when writing to support. It grants nothing.
- secret: 32 random bytes. We store only its SHA-256, so a stolen copy of our database gives access to no organization. It also means we cannot show it to you again. Lost it → rotate.
Send the whole string, exactly as issued.
What a key can reach
Two independent narrowings, both set by the owner when the key is created:
- Scopes: what kinds of data. A key with no scopes gets nothing; there is no implicit access. Asking for a resource without its scope returns
403withcode: "missing_scope"naming the missing one. - Locations: optionally, a key can be limited to a subset of locations. Everything with a location behind it is then filtered accordingly, and objects at other locations answer
404rather than403, because the answer must not confirm that they exist.
One deliberate exception: the change feed is not narrowed by location. An event carries only an id, and attributing that id to a location means looking the row up, which is impossible for a deletion, because the row is gone. Filtering would therefore drop exactly the deletions, and a mirror that never hears about deletions is worse than no mirror. So /v1/events returns every id in the organization; the objects behind ids outside the key's locations stay unreadable. If that is not acceptable for a branch key, do not grant it events:read.
| Scope | Gives |
|---|---|
company:read | /v1/company |
locations:read | /v1/locations |
positions:read | /v1/positions |
employees:read | /v1/employees without contact details |
employees:read.pii | phone, email, date of birth, personnel number |
shifts:read | /v1/shifts |
absences:read | /v1/absences |
attendance:read | /v1/attendance |
timesheets:read | /v1/timesheets |
events:read | /v1/events |
shifts:write | create, edit and remove shifts, as drafts |
shifts:publish | show shifts to the team, ON TOP of shifts:write |
employees:write | create and update people by your own id |
employees:dismiss | dismiss, restore and delete people with an ordinary role |
employees:invite | invite codes for the app, for people with an ordinary role |
internships:write | mark interns, undo a mark, move interns to the staff, create a person as an intern |
payroll:read | /v1/payroll, /v1/payments and the rest of Payroll, read only |
payroll:write | record advances, other payouts, bonuses and deductions, change and remove its own; ticked together with payroll:read |
About the write scopes
shifts:write on its own can only leave drafts, and a draft is visible to nobody but the people who build the schedule. Making a shift real for the team takes shifts:publish as well. The split exists because the two differ in what they cost when they go wrong: a wrong draft is corrected quietly, a wrong published shift has already reached somebody's phone.
A write scope also lets you read back the object you wrote, which is what the response contains. It does not lift employees:read.pii: contact details stay absent unless that scope is ticked too.
About employees:dismiss and employees:invite
Both are a separate box with a warning, never part of "select all", and each is ticked on its own. They change who has access to the team's app: a dismissal disconnects the person's account, and an invite code is a way into the app under the card it was issued for, with that card's access role.
Two limits hold for both, whatever else the key may do:
- Only people with an ordinary role. Not the owner, not co-owners, and not anyone whose access role lets them manage people, the schedule or money, or open the admin panel. Seeing the schedule and the colleagues of their own location is ordinary; anything beyond that is handled in the product, by people who outrank them. The answer otherwise is
403 employee_protected. - Never stronger than the person who issued the key. Keys are issued by the owner and co-owners. When the issuer loses that access or their account is deleted, the key stops dismissing and inviting (
403 key_issuer_inactive) until the owner issues a new one.
About employees:read.pii
Names are always returned; a list of people without names is useless. Contact details are not: they are needed by a handful of integrations (HR, mailing) and leak the same for everyone else. Without this scope the fields are absent from the response entirely rather than returned as null. An empty value cannot be told apart from "not filled in"; an absent one can.
If you need to match people against your own system, use external_id. It works without the PII scope and it is a technical key, not personal data.
About payroll:read
The same kind of scope as the personal-data one: a separate box with a warning, never part of "select all". It opens the pay of everyone the key can reach, rates and supplements and balances included, and it is read only. Writing money is a box of its own, payroll:write, with its own warning: advances, other payouts, bonuses and deductions, never settlements or closing a period (see Recording money). See Payroll.
A key sees money the way the person who issued it does. A key issued by the account owner sees everyone's pay. A key issued by a co-owner does not return the pay of the owner or of other co-owners (their own is included): in the product only the owner sees and changes that money. If the person who issued the key is no longer a co-owner, or their account was deleted, the key stays closed to the owner's and co-owners' pay as well. Those people are simply absent from the payroll resources, as if outside the key's reach. An integration that must see everyone's money needs a key issued by the owner.
Rotation
The owner presses Rotate. You get a new key; the old one keeps working for seven more days and then stops. Two live keys for a week is the point: your system does not have to be stopped at the moment the owner clicks a button.
A key can be rotated by the person who issued it or by the account owner. The new key is issued by whoever rotates it, and it sees money the way that person does (see payroll:read above): when the owner rotates a co-owner's key, the new key becomes the owner's. A co-owner cannot rotate a key issued by the owner or by another co-owner; the request answers 403. Otherwise the new key would quietly lose the pay of the owner and co-owners while the integration kept running. The same rule applies to changing a key's scopes or locations; renaming and revoking stay open to the owner and co-owners. An expired key cannot be rotated: issue a new one. Rotating a key that is already in its seven-day grace period does not extend that period.
Revocation
Immediate and irreversible. A revoked key answers 401 on the next request: there is no cache to wait out. Ask for revocation the moment a key might have leaked; a new one takes ten seconds to issue.
Where a key must never go
In a browser. In a mobile app. In a repository. In a support ticket.
This API is server to server. It sends no CORS headers at all: no Access-Control-Allow-Origin, for any origin, including ours. A fetch from a web page therefore fails in the browser before it reaches us, and no origin can be added to a list, because there is no list.
That is on purpose. A key is the whole organization, and in a browser it is readable by anyone: page source, network tab, any extension the visitor has installed. If your product needs schedule data on a page, call us from your backend and pass on what that particular visitor is allowed to see.
Lists, filtering and paging
Every list is an envelope
{
"data": [ ... ],
"has_more": true,
"next_cursor": "eyJrIjoiMjAyNi0wOS0wMSIsImkiOiI5ZjNhODA2NCJ9"
}
Never a bare array: a bare array cannot gain a field without breaking every client that already reads it.
Single objects (/v1/company, /v1/shifts/{id}) are returned as the object itself, with no envelope.
Paging is by cursor, not by offset
curl "https://api.inshift.app/v1/employees?limit=100" -H "Authorization: Bearer $KEY"
# → next_cursor: "eyJrIjoi..."
curl "https://api.inshift.app/v1/employees?limit=100&cursor=eyJrIjoi..." -H "Authorization: Bearer $KEY"
Loop until has_more is false.
Take next_cursor verbatim. It is opaque: do not decode it, do not build one yourself, do not assume it is a date or an id. Its contents are not part of the contract and will change.
Why not ?page= or ?offset=:
- while you walk the pages, people keep editing the schedule. With an offset every insertion shifts everything by one, so you see one row twice and never see another, silently, with no error anywhere;
OFFSET 200000makes the database read and throw away two hundred thousand rows on *every* page, so a full export gets quadratically slower. With a cursor, page 1 and page 1000 cost the same.
limit defaults to 50 and is capped at 100.
Sorting
?sort=date ascending, ?sort=-date descending. Only fields listed for that endpoint are allowed; anything else is a 400 that names what is allowed. Sorting by an arbitrary column is how one client makes a database read a whole table, so the list is closed on purpose.
Filters
- Dates are
from/to,YYYY-MM-DD, both ends inclusive. - Repeat a parameter to pass several values:
?location_id=a1b2...&location_id=c3d4... - Date range is required and capped at 366 days on
/v1/shifts,/v1/absences,/v1/attendanceand/v1/timesheets. This is what keeps every query on an index. Longer periods are pulled year by year. updated_sincetakes an ISO-8601 timestamp and is available on the directories, on shifts, on absences and on attendance. It is the convenient path, not the reliable one: a timestamp can never report a deletion. For a mirror that must stay correct, use/v1/events.
An unknown parameter is an error
curl "https://api.inshift.app/v1/employees?locations_id=..." -H "Authorization: Bearer $KEY"
{ "error": { "code": "unknown_parameter", "param": "locations_id", ... } }
A typo in a filter, silently ignored, produces a response that looks perfectly sensible and contains the wrong rows. That is the most expensive kind of small mistake in an integration, and it costs nothing to refuse it immediately.
/v1/absences overlaps, it does not contain
A range on absences means overlap: a vacation from 25 December to 10 January appears both when you ask for December and when you ask for January. Anything else would show the person as present in one of those months.
Absences also carry a status (pending, approved, rejected, cancelled) and no status filter is applied by default. If you are counting attendance you almost certainly want ?status=approved, and you have to say so. Quietly returning only approved ones would hide from you that the others exist.
What an absence does not carry is the free-text note the person wrote when asking for the time off. "Chemotherapy on Tuesday" is health data, and health data is not something that should leave through a scope called absences:read. The machine-readable reason is type, and that is what an attendance or payroll calculation actually needs.
Dates, times and time zones
The organization has one time zone
It is in GET /v1/company as timezone (an IANA name, e.g. Europe/Kyiv). Read it once and keep it: every wall-clock value in this API is local time in that zone.
Shifts carry both readings
{
"date": "2026-09-01",
"start_time": "09:00:00",
"end_time": "18:00:00",
"start_at": "2026-09-01T06:00:00.000Z",
"end_at": "2026-09-01T15:00:00.000Z",
"hours": "9.00"
}
date+start_time/end_time: the wall clock, exactly as a person sees it in the schedule. This is what a timesheet or a payroll sheet should print.start_at/end_at: the same two moments in UTC. This is what a calendar, a cross-timezone report or a duration calculation should use.
You need both. Wall-clock time alone cannot tell you whether two shifts in different cities overlap; UTC alone cannot show a person "their nine in the morning".
Overnight shifts
A shift from 22:00 to 06:00 ends the next day, and end_at already says so:
{ "date": "2026-09-01", "start_time": "22:00:00", "end_time": "06:00:00",
"start_at": "2026-09-01T19:00:00.000Z", "end_at": "2026-09-02T03:00:00.000Z",
"hours": "8.00" }
Do not compute the duration from start_time and end_time yourself unless you handle this case. hours already has it right.
The night the clocks change
hours is the time that actually passed, not the wall-clock difference. In Europe/Kyiv the clocks go forward at 03:00 on the last Sunday of March and back at 04:00 on the last Sunday of October, so the same 22:00-06:00 shift lasts 7 hours one night and 9 hours the other:
{ "date": "2026-10-24", "start_time": "22:00:00", "end_time": "06:00:00",
"start_at": "2026-10-24T19:00:00.000Z", "end_at": "2026-10-25T04:00:00.000Z",
"hours": "9.00" }
hours is end_at minus start_at, and payroll pays exactly these hours (GET /v1/payroll). This holds in every zone, including half-hour changes (Australia/Lord_Howe) and changes at midnight (America/Santiago).
A wall-clock time that happens twice (the clocks go back) is read as its first occurrence: the moment the clock first showed it. In Europe/Warsaw the clocks go back from 03:00 to 02:00 on 2026-10-25, so 02:00-02:59 happen twice. A shift that ends inside that hour ends the first time the clock reads its end, and one that passes through the whole hour gets the extra hour:
{ "date": "2026-10-24", "start_time": "18:00:00", "end_time": "02:30:00",
"start_at": "2026-10-24T16:00:00.000Z", "end_at": "2026-10-25T00:30:00.000Z",
"hours": "8.50" }
{ "date": "2026-10-24", "start_time": "22:00:00", "end_time": "06:00:00",
"start_at": "2026-10-24T20:00:00.000Z", "end_at": "2026-10-25T05:00:00.000Z",
"hours": "9.00" }
A start inside that hour is read the same way: 02:30-06:00 on 2026-10-25 lasts 4.5 hours. start_at / end_at of shifts, scheduled_start / scheduled_end of timesheets and the events of the calendar feed all use this rule, so they always agree with hours. Note that a plain (date + time) AT TIME ZONE in PostgreSQL picks the second occurrence; do not rebuild these moments that way.
A wall-clock time that does not exist that night (02:30 in New York in March) is read with the offset from before the change, so it lands an hour later on the clock (03:30). The one case where hours is not end_at minus start_at comes from this: a shift that starts inside the skipped hour and ends less than an hour later (02:50-03:10 in New York on 2026-03-08) has end_at before start_at, and its hours is "0.00", never negative.
start_time == end_time means zero, not 24 hours
Equal start and end is how the product marks a day with no work, not a round-the-clock shift. hours is "0.00" there, and any calculation of your own must agree.
Hours are strings
"hours": "8.62"
Not a number. 0.1 + 0.2 is 0.30000000000000004 in every language with binary floats, and hours turn into money one step later. The string arrives exactly as the database computed it; parse it with a decimal type, not a float.
Timestamps
Everything that is a moment (created_at, updated_at, opened_at, occurred_at, start_at) is ISO-8601 in UTC with milliseconds and a Z: 2026-09-01T06:00:00.000Z.
Everything that is a calendar day (date, start_date, hire_date) is a bare YYYY-MM-DD with no zone, because that is what it is: a day in the organization's calendar, not an instant.
Errors
Every failure has the same shape:
{
"error": {
"type": "permission_error",
"code": "missing_scope",
"message": "This API key lacks the timesheets:read scope.",
"param": null,
"doc_url": "https://inshift.app/api/#missing_scope",
"request_id": "req_9f3ac21b4e2d5f7a8b0c1d2e"
}
}
Branch on code. It is a stable string and it will not change inside v1. message is English prose meant for a human reading a log; it may be reworded at any time. type is the family, useful for deciding whether to retry at all. param names the offending field when a field is at fault.
request_id
It is on every response, successful ones included, both in the body of an error and in the Inshift-Request-Id header. Log it. Without it, "it didn't work" cannot be found in our logs, and we will have to ask you to reproduce the problem while we watch.
Codes
Each heading below is the anchor that doc_url points to.
unknown_parameter
400. A query parameter we do not know; param names it. Fix the spelling, and do not ignore it: the filter you meant was never applied.
invalid_parameter
400. A parameter with the wrong shape or an impossible value; param names it and message says what was wrong.
range_too_wide
400. from/to spans more than 366 days. Fetch it year by year. The cap is what keeps every query on an index rather than reading the table.
invalid_cursor
400. The cursor is not one of ours. Pass next_cursor back verbatim; never build a cursor yourself and never carry one across API versions.
idempotency_required
400. A POST arrived without an Idempotency-Key header. Send any unique string, and send the SAME one when you retry. See Writing.
too_many_items
400. More items in one request than we accept; message says the cap. Split the batch.
missing_api_key
401. No Authorization header at all. Send Authorization: Bearer insh_live_....
invalid_api_key
401. Unknown, malformed, expired or revoked. Do not retry: it will not start working. Ask the owner to issue a new key.
missing_scope
403. The key lacks a permission; message names it. The owner adds it by rotating the key with the scope ticked. Retrying changes nothing.
organization_suspended
403. The organization is suspended on our side. Nothing technical to fix; the owner has to settle it with us.
organization_deleted
403. The organization no longer exists. Stop the integration.
organization_restricted
403, only on writes (POST, PUT, PATCH, DELETE). The organization is read-only right now: its invoice has stayed unpaid for more than 7 days, or it is on the free plan above its limits. Reading keeps working the whole time, so exports and reports do not break. The key itself is fine and there is nothing to fix on the integration side: writes work again the moment the owner settles it. Retrying before that changes nothing.
plan_horizon
403, only when creating a shift or moving one to another date. On the free plan shifts can be scheduled up to the end of next week (weeks start on the organization's first day of the week); the message names the last allowed day. Shifts that already sit further ahead can still be edited and deleted. In POST /v1/shifts/batch only the rows past that day fail with this code, the rest are created. The key itself is fine: the owner lifts the limit.
plan_module
403, on every request, reading included. The API, webhooks and AI assistants are part of the Basic plan, and the organization is on the free plan: its trial has ended and nothing has been paid yet. Webhook deliveries pause for the same time. The key itself is fine and keeps its scopes: everything works again the moment the owner moves to Basic. Retrying before that changes nothing.
resource_not_found
404. No such object in this organization. Note that this is also the answer for an object that exists in a different organization, or outside a location-limited key's reach. See below.
unknown_endpoint
404. No such path. Check it against the endpoint reference.
invalid_request
400 (or another 4xx). The request was rejected before it reached a handler: a malformed header, an oversized URL, a body we could not read. message says what happened.
shift_conflict
409. The person already works an overlapping shift that day; message names it. Either move your shift or leave the existing one alone. In a batch this appears per row, and the rest of the rows still go through.
employee_dismissed
409. The person was dismissed. A shift cannot be created for them, an invite code is not issued to them, and dismissing them again has nothing to do. Your HR system and the schedule disagree about them; fix that first, or restore them.
employee_not_dismissed
409. You asked to restore somebody who is not dismissed. Nothing changed.
employee_has_account
409. The person has already joined the app and signs in with their own account, so an invite code would not connect anything. has_account on the person says this before you ask.
employee_has_history
409. The card has history: past published shifts, clock-ins, payouts, bonuses or deductions, closed pay runs or approved time off. message lists what. Such a card is never deleted, because deleting it would erase money and records; dismiss the person instead, and the history stays.
module_off
403. The module this endpoint belongs to is off for the organization, for example Internships. The owner turns it on in the product; a key cannot.
employee_is_intern
409. The person is already an intern. Nothing changed.
employee_not_intern
409. The person is not an intern, so there is no mark to undo and nobody to hire into the staff.
intern_has_shifts
409. The intern already has shifts since the internship started, so the mark cannot be undone as if it never was. Hire them into the staff instead.
intern_paid_after
409. Hiring from this moment would rewrite shifts that were already paid to the intern or to their mentor; message names the last such day. A key never rewrites paid money. Pick a moment after that day, or hire them in the product, where the person who manages their pay sees the amounts.
employee_protected
403. The person is the owner or a co-owner, or their access role lets them manage people, the schedule or money, or open the admin panel. A key dismisses, restores, deletes and invites only people with an ordinary role. An invite code is a way into the app with the card's role, and a dismissal disconnects the account, so for anyone with power over others this is done in the product.
key_issuer_inactive
403. The person who issued this key is no longer an owner or co-owner (their access was removed or their account deleted). A key is never stronger than the person behind it, so it stops dismissing and inviting people and recording money. Reading and the other scopes keep working. Ask the owner to issue a new key.
record_not_own
403. The payment, bonus or deduction was recorded by someone else (a person in the product or another key), or a person has corrected it in the product since. A key changes and removes only what it recorded itself, including before a rotation (created_by_this_key: true in the object); the rest is changed in the product by whoever handles that person's pay.
own_pay_change
403. Only the owner changes their own pay. This key was issued by a co-owner and the bonus or deduction is for that co-owner's own card; the owner records it. A payout to themselves is allowed, the same as in the product.
employee_absent
409. The person has approved time off on this day (vacation, sick leave, personal or unpaid leave), and nobody is scheduled on such a day by any route. The message names the kind and the dates. A day-off marker (a shift whose start equals its end) is still accepted. The absence dates are changed in the product, under Requests. In a batch this appears per row, and the rest of the rows still go through.
idempotency_key_reuse
409. This Idempotency-Key was already used with a different body. The request is not a retry of the first one, and creating a second object would hide a bug in your key generator.
idempotency_in_progress
409. The same key is still being processed. Wait a moment and retry with the same key: you will get the first attempt's answer.
version_conflict
412. Somebody changed the object after you read it; message carries the current version. Read it again, decide what to do with their change, and retry. This is the error that stops an integration from silently undoing a manager.
version_required
428. An edit or delete arrived without If-Match. Send the version you last read, or * if losing a concurrent edit genuinely does not matter.
rate_limited
429. Too many requests in the window. Back off and retry; Retry-After says for how long.
temporarily_unavailable
503 with Retry-After. Too many requests hit our side at once and the public API's connection pool is busy. It is deliberately small, so that a nightly export can never make the product slow for the people using it. Back off and retry; nothing is broken.
request_too_heavy
503. The query did not finish in the time we allow it. Almost always a date range that is too wide or a limit that is too high for that much data. Narrow the range and it will go through.
internal_error
500. Our fault. Retry once, and if it persists send us the request_id. It is what makes the failure findable in our logs.
Why 404 and not 403 for someone else's object
An object belonging to another organization answers 404, exactly as a non-existent one does. A 403 would confirm that the id exists somewhere, and that is already information you are not entitled to. The same applies to objects outside a key's allowed locations.
Retrying
401,403,404: never retry. Nothing about them is temporary.400: never retry. The request is wrong and will stay wrong.409shift_conflict,employee_dismissed,employee_absent,employee_not_dismissed,employee_has_account,employee_has_history,employee_is_intern,employee_not_intern,intern_has_shifts,intern_paid_after,idempotency_key_reuse: never retry unchanged; the answer will be the same.409idempotency_in_progress: retry after a second, with the same key.412: read the object again, then decide. Retrying the same body would overwrite somebody's edit, which is exactly what the check prevented.428: addIf-Matchand send it again.429: back off, then retry.503: retry after the delay inRetry-After. Forrequest_too_heavy, also make the request smaller, or it will time out again.500: retry with exponential backoff. If it persists, send us therequest_id.
Limits
| Limit | Value | Why |
|---|---|---|
| Requests | 600 per minute per key | A runaway loop should hit a wall, not the database |
limit | 100 rows, 50 by default | |
| Date range | 366 days | Keeps every query on an index |
| Live keys | 20 per organization | Past that, the list stops being one where you can see what to remove |
| Change feed history | 90 days | Longer than that and a full re-read is the right answer anyway |
| Shifts per batch | 500 | One statement, one report, one transaction |
| Idempotency keys | kept 24 hours | After that the same key means a new action, not a retry |
Rate limit headers
Every response carries them, both in the draft-standard spelling and the older one:
RateLimit-Limit: 600
RateLimit-Remaining: 587
RateLimit-Reset: 41
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 587
X-RateLimit-Reset: 41
Reset is seconds until the window rolls over. On 429 a Retry-After header is added.
The bucket is per key, not per IP address: one integrator's server serving ten organizations would otherwise have all ten sharing a single budget and throttling each other.
The real ceiling is not the rate limit
Public API requests run on their own small pool of database connections, separate from the one behind the product's own screens. That is deliberate: a nightly export must never be able to make the schedule slow to open for the people using it. In practice this means a handful of concurrent requests, so paging through data sequentially is faster than firing fifty requests at once.
The same applies to writing, and there one large request beats many small ones for a second reason: 500 shifts in one /v1/shifts/batch are a single statement and a single transaction, while 500 separate POSTs are 500 of each.
Be a good citizen
- Pull the range you need, not "everything since 2020".
- Use
/v1/eventsfor the daily delta instead of re-downloading the month. - Cache the directories (locations, positions); they change a few times a year.
- Do not poll
/v1/companyon every request; read it once at start-up.
Keeping in sync
Two mechanisms, for two different questions.
updated_since, or "what looks different now"
Available on /v1/locations, /v1/positions, /v1/employees and /v1/shifts.
curl "https://api.inshift.app/v1/employees?updated_since=2026-09-08T00:00:00Z" \
-H "Authorization: Bearer $KEY"
Simple, and enough for directories. It has one blind spot that matters:
A timestamp can never report a deletion. The row is gone; no filter will ever return it. An integration built on updated_since alone will keep a dismissed person, a deleted shift and an archived location forever, and nothing will look wrong.
(Most removals in the product are soft: a location is archived, a person is dismissed, and those *do* come back as an update with a changed status. But not all of them, and "not all" is enough to corrupt a mirror.)
/v1/events, or "what changed, deletions included"
curl "https://api.inshift.app/v1/events?limit=100" -H "Authorization: Bearer $KEY"
{
"data": [
{
"id": "evt_cf819f02-2fa4-4d40-9d81-73b183f73d7c",
"type": "shift.updated",
"entity": "shift",
"action": "updated",
"object_id": "9f3a8064-ce13-4ef7-8338-c72cf667a89b",
"occurred_at": "2026-09-08T21:43:44.795Z"
}
],
"has_more": true,
"next_cursor": "eyJrIjoiOTk4MSIsImkiOiI0MiJ9"
}
Event types are entity.action, where entity is one of employee, location, position, shift, absence, attendance_session, and action is created, updated or deleted. Filter with ?type=shift.updated, or a whole entity with ?type=shift.*. Repeat the parameter for several.
A shift.deleted can follow a write that deleted nothing by hand: putting a person on a shift removes their day-off marker on that date (see Writing).
Events carry no body, on purpose
An event tells you that something changed and which object. It does not contain the object.
A body would be a second copy of the truth, ageing from the moment it was written: an event delivered six hours later would contradict the API itself. It would also be a second contract to version, and it would put personal data into queues and delivery logs. So: take object_id, fetch the object from its resource, and you have the current state rather than a snapshot of unknown freshness.
If several events arrive for the same object, one fetch settles all of them.
The loop
cursor = load_saved_cursor() # nothing on the first run
loop:
page = GET /v1/events?limit=100 [&cursor=cursor]
for event in page.data:
mark object as dirty
cursor = page.next_cursor or cursor
save cursor ← only after the whole page is processed
if not page.has_more: break
then fetch every dirty object once
Save the cursor only after the page is handled. Saving it first turns any crash into permanently missed events.
What the feed guarantees
- Every event you have not yet read is ahead of your cursor. The feed deliberately withholds events from transactions that have not finished committing, so nothing can appear *behind* a cursor you already passed. This is the failure mode a naive "give me everything with id > N" feed has, and it loses data silently.
- A bulk change produces one event per object, not one for the batch: publishing a month of 500 shifts gives 500 events.
- An edit that changed nothing produces no event at all.
Location-limited keys
The feed is not narrowed by a key's allowed locations, and that is a deliberate trade rather than an oversight; see Authentication for why. In short: filtering it would silently drop deletions. The ids of objects you cannot read are still just ids; fetching them gives 404.
What it does not guarantee
- Not "exactly once". Handle a repeated event by fetching again; the result is the same.
- Only 90 days. If your integration has been silent for a quarter, do a full re-read: by then almost everything you hold is stale anyway.
A practical nightly job
GET /v1/company: time zone, and whether attendance is on.GET /v1/eventsfrom your saved cursor. Collect changed employee and shift ids.- Fetch what changed.
- Once a week, re-read the directories in full: cheap, and it repairs anything a bug on either side may have skewed.
Writing
v1 writes the schedule, the staff list and a part of the money: advances, other payouts, bonuses and deductions behind payroll:write (see Recording money). Everything else is read only.
Three rules run through all of it.
Every POST carries Idempotency-Key. A network can drop the answer to a request that already worked. Without a key that leaves you choosing between a duplicate shift and a missing one; with a key, the retry returns the first answer and creates nothing.
Every edit and delete carries If-Match. People edit the schedule by hand all day. If your integration read a shift this morning and rewrites it tonight, If-Match is what stops it from silently undoing what a manager did in between.
Publishing is a separate permission. A key with shifts:write can only leave drafts, and a draft is visible to nobody but the people who build the schedule. Showing a shift to the team needs shifts:publish as well. Ask for both only if your integration is meant to schedule people, not just prepare work for someone to approve.
Trying it without touching anyone
There is no separate test account. The key you were given writes to the real organization, and a published shift is on somebody's phone within seconds. So the first POST is a real question: who will see this.
Two things make experimenting safe, and you can use both.
Write drafts. status: "draft" is the default, and a draft is invisible to everyone except whoever builds the schedule. No push, no notification, nothing in anyone's app. Create, edit and delete drafts as much as you like; the team learns nothing. Publishing is a separate right (shifts:publish), so a key without it cannot make anything visible even by accident.
Ask for a key narrowed to one location. The owner can tie a key to specific locations when creating it. Point it at a location with nobody attached, and every write lands somewhere no employee is looking. A key narrowed this way cannot reach any other location: the write is refused with 400 invalid_parameter, and the message says there is no such location in this organization. That wording is deliberate. A narrowed key is never told which locations exist behind the narrowing, so a wrong id and a hidden one look the same from outside.
Both together give you a place to be wrong in.
Notifications: the same as from inside the product
A published shift reaches the person the moment it is created, exactly as if a manager had put it there. There is no flag to turn that off, and that is on purpose: for the person, a shift in their schedule has no origin. They do not know it came from your system, they know they work at nine tomorrow. A shift that appears silently is how somebody does not show up.
Three things keep a bulk import from becoming a flood of notifications, and they are already in the product:
- drafts never notify anybody, and publishing needs its own scope;
- many shifts for one person collapse into one message ("4 shifts, first one Sept 12"), no matter how large the import;
- past days never notify.
Creating a shift
curl -X POST https://api.inshift.app/v1/shifts \
-H "Authorization: Bearer $INSHIFT_KEY" \
-H "Idempotency-Key: 8f14e45f-ea0b-4d1f-9a2b-77c4d9e1a2b3" \
-H "Content-Type: application/json" \
-d '{
"date": "2026-09-15",
"start_time": "09:00",
"end_time": "18:00",
"location_id": "3f1c...",
"employee_id": "7a2b...",
"status": "draft"
}'
The answer is the shift, in the same shape GET /v1/shifts/{id} returns, with version in it. Keep that number: it is what you send back in If-Match.
Leave out employee_id and you get an unfilled slot. Add "open": true and the slot is offered to the team: people apply for it in the app and a manager picks. The two cannot be combined, and a request that tries is refused rather than quietly ignored.
Idempotency in practice
Generate one key per intended action, not per attempt. Keep it for as long as you might retry.
- Same key, same body: you get the stored answer, byte for byte, with
Idempotency-Replayed: truein the headers. Nothing is created. - Same key, different body:
409 idempotency_key_reuse. Your key generator is producing collisions, and creating the second shift would hide that. - Same key while the first attempt is still running:
409 idempotency_in_progress. Wait a moment and retry with the same key.
Keys live for 24 hours. After that the same key means a new action.
Editing and removing
curl -X PATCH https://api.inshift.app/v1/shifts/$SHIFT_ID \
-H "Authorization: Bearer $INSHIFT_KEY" \
-H "If-Match: 3" \
-H "Content-Type: application/json" \
-d '{"start_time": "10:00"}'
Only the fields you send change. "employee_id": null frees the slot; an omitted employee_id leaves the person where they were.
If-Match takes the version you last read. A quoted ETag ("3") works too, and PATCH/DELETE responses carry ETag so you can chain edits without re-reading. When the version has moved on you get 412 version_conflict with the current number in the message: read the shift again, decide what to do with the change somebody else made, and retry.
If-Match: * means "whatever version is current". It is a deliberate escape hatch, not a default. Use it only where losing a concurrent edit genuinely does not matter.
Deleting a shift tells the person it is gone, the same as when a manager removes it. Retrying a delete that already worked answers 404, which is the state you asked for.
Importing a month at a time
POST /v1/shifts/batch takes up to 500 shifts and answers with a report:
{
"created": 498,
"failed": 2,
"results": [
{ "index": 0, "status": "created", "shift": { "id": "..." } },
{ "index": 17, "status": "failed",
"error": { "code": "shift_conflict", "message": "This person already works 2026-09-15 09:00-18:00." } }
]
}
A 200 here means the report is ready, not that everything worked. Read failed and the per-row lines. Partial success is the right shape for this: a month of schedule almost always contains a few clashes, and refusing the whole month over them would leave you splitting the batch by hand to find out which row was at fault.
Rows are reported in the order you sent them, and index is the position in your array. The whole batch shares one Idempotency-Key, and a retry replays the same report.
Keeping people in step with your HR system
curl -X PUT https://api.inshift.app/v1/employees/by-external-id/EMP-4417 \
-H "Authorization: Bearer $INSHIFT_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Anna Kovalenko", "hire_date": "2026-09-01", "position_id": "b41f..."}'
Your id in the path, the fields you know in the body. 201 means the person was created, 200 that they already existed. There is no Idempotency-Key here and none is needed: the same request twice leaves the same person, because the identity is in the path rather than in a generated id.
Fields you do not send are left alone. Your HR system does not know about the locations or positions somebody set up by hand, and a sync that wiped them would be a worse problem than a stale field.
Dismissal is not a field. In the product, dismissing someone cancels their future shifts, closes their requests, releases the equipment they hold and fixes a date that payroll depends on. A field called status could not carry any of that, and a half-done dismissal is discovered a month later in the money. It has an endpoint of its own, below.
Dismissing, restoring and deleting people
curl -X POST https://api.inshift.app/v1/employees/7c1e.../dismiss \
-H "Authorization: Bearer $INSHIFT_KEY" \
-H "Idempotency-Key: dismiss-EMP-4417" \
-H "Content-Type: application/json" \
-d '{"future_shifts": "open", "reason_code": "own_request"}'
This is the same dismissal as the Dismiss button in the product, run by the same code. Shifts after today become unfilled slots, or are removed with "future_shifts": "delete"; today's shift stays, because people usually work it out. Live swap and substitution requests are cancelled, a vacation already under way ends today and later ones are cancelled, mentoring of interns stops, invite codes die, and the person's app account is disconnected from the organization. Past shifts and money stay where they are. The answer is the person plus how many shifts and requests were touched. The free-text note the product keeps on a dismissal is not part of the API: write it in the product if you need one.
POST /v1/employees/{id}/restore brings the person back with their history. Shifts released at dismissal stay where they are, and the account is not reattached: the person joins again with a new invite code.
DELETE /v1/employees/{id} is for a card created by mistake, and only for a card without history. A card with past published shifts, clock-ins, payouts, bonuses or deductions, closed pay runs or approved time off answers 409 employee_has_history: dismiss that person instead. Delete takes no If-Match, because a card has no version; the history rule is what protects it.
All three need employees:dismiss and act only on people with an ordinary role (see Authentication).
Invite codes
curl -X POST https://api.inshift.app/v1/employees/7c1e.../invite-code \
-H "Authorization: Bearer $INSHIFT_KEY"
{
"employee_id": "7c1e...",
"code": "K7Q2MX",
"expires_at": "2026-11-01T09:12:00.000Z",
"join_url": "https://app.inshift.app/j/K7Q2MX"
}
The answer is the live code of this card, or a new one valid for 7 days. Whoever enters it in the InShift app joins under this card with its access role, so it is a password: give it to that person only. We send no email; deliver code or join_url yourself. join_url opens the app on the join screen with the code filled in, or the app store when the app is not installed.
POST /v1/employees/{id}/invite-code/reissue replaces the live code with a new one, and the old code stops working at once. Use it when a code went to the wrong place or expired.
Neither takes an Idempotency-Key, unlike other writes. Asking for the code again returns the same live code, so a retry is safe as it is. A reissue whose answer was lost gives an even newer code when retried, and the code you never received is already dead. Storing the answer for replay would mean keeping the code itself, and replaying a code that a later reissue has killed.
A code is not issued to a dismissed person (409 employee_dismissed) or to one who has already joined (409 employee_has_account); has_account on the person tells you which people still need one. Both endpoints need employees:invite.
Interns
curl -X POST https://api.inshift.app/v1/employees/7c1e.../hire \
-H "Authorization: Bearer $INSHIFT_KEY" \
-H "Idempotency-Key: hire-EMP-4417" \
-H "Content-Type: application/json" \
-d '{"at": "2026-11-01T09:00"}'
An intern works alongside a mentor and is paid the intern rate of their position instead of staff pay. Everything here needs internships:write and the Internships module on, and acts only on people with an ordinary role.
- Create a person as an intern:
is_intern: truein the body ofPUT /v1/employees/by-external-id/{id}, only when the person is new. - Mark somebody already on the team:
POST /v1/employees/{id}/intern. From the start of today they are an intern; their live swap and open-shift requests are closed, because interns do not swap or take shifts. - Undo a mark set by mistake:
POST /v1/employees/{id}/intern/cancel, only while they have no shifts since the internship started. - Move an intern to the staff:
POST /v1/employees/{id}/hirewithat, the wall-clock moment in the company time zone, or without it for now. A past moment turns their shifts from then on into staff shifts and takes the mentors off them, but never shifts already paid to the intern or their mentor (409 intern_paid_after). A future moment, up to 30 days ahead, is planned:hire_planned_atshows it, it happens by itself, andDELETE /v1/employees/{id}/hiredrops the plan.
at is read in the company time zone, with its daylight-saving changes, wherever the company is. A time that does not exist that night, because the clocks go forward, is refused (400 invalid_parameter) rather than quietly moved by an hour. A time that happens twice, because the clocks go back, is read as the second one, after the change: the same way the start of every shift is read, so "shifts from this moment" means the same thing on both sides.
is_intern and hire_planned_at on every person tell you where they stand; GET /v1/employees?is_intern=true lists the interns. The mentor on a shift and intern pay are set in the product.
What a write is allowed to touch
A key limited to locations writes only inside them: it cannot create a shift elsewhere, cannot edit one it is not allowed to see, and cannot give a person access to every location.
Shifts cannot be created for a dismissed person (409 employee_dismissed) or for somebody whose card says they are not scheduled. A location that has been archived takes no new shifts.
Nor can a person be put on a day of their approved time off (409 employee_absent), whether by creating a shift, moving one to that day or assigning somebody else's. Their own shift already standing on that day can still be edited. A day-off marker is accepted.
A shift removes the person's day-off marker
A day-off marker is a shift whose start equals its end: the schedule shows it as a day off. Where a person works, they do not have a day off. So when a write puts somebody on a shift on a date where they have a day-off marker, the marker is deleted in the same transaction. This holds for POST /v1/shifts, for every created row of POST /v1/shifts/batch, and for a PATCH that gives the person a new day: assigning a different person, moving the shift to another date, or giving a day-off marker working hours. That marker turns into the shift, and another marker of the same person that day is deleted. The MCP tools that create and edit shifts do the same.
- A
PATCHthat only changes the hours, the location or the status of a working shift takes no new day, and a marker standing next to that shift stays. - Only the marker goes. Another working shift of the same person that day stays.
- The marker goes whatever its status, and whatever location it was on, even a location outside a key narrowed to some locations. A day off belongs to the person, not to a location: otherwise the person would have a shift and a day off on the same date. The panel does the same.
- The response describes only the shift you wrote. The removed marker shows up in the change feed and in webhooks as
shift.deletedwith the marker's id. - A marker and a shift for the same person and date sent in one batch are both kept: that is what you asked for.
- Writing a marker removes nothing, and an unfilled slot removes nothing.
- A removed marker does not come back by itself, not even if you delete the shift later. Put it back with a new write if you need it.
The product follows the same rule: a manager putting someone on a shift, a copied week, a person taking an open shift or a swap all remove the marker the same way. That is why your feed can show shift.deleted for markers your integration never touched.
confirmed cannot be set from outside. It means "the person saw this and agreed", and only they can say that.
Who the owner sees as the author
Actions made with a key are signed with the key's name: "API: 1C accounting". Not the person who issued it, because they may leave the company while the integration keeps working, and not a nameless "system", because then nobody could tell which integration did what.
The owner also sees what the key changed on the key's own page in the panel, "What this key did": people added, edited, dismissed, reinstated or deleted, invite codes, interns and shifts, each line with the person and the moment. A batch of shifts is one line ("Created 240 shifts 01.11 - 30.11 (drafts)"), and so are shifts sent one by one within a few minutes of each other. Publishing drafts with PATCH /v1/shifts/{id} and status: "published" shows up the same way ("Published 240 shifts 01.11 - 30.11"), as when a person presses "Publish" in the panel. Reads are not listed. When a customer asks "why is this here, I didn't do it", this is where the answer is, so name your key after the system that holds it.
Payroll
Money is read behind its own scope, payroll:read. The owner ticks it deliberately, in a separate box with a warning, the same way as the personal-data scope. A key for a tablet at a location has no business here; a key for accounting or an ERP does. Recording advances, other payouts, bonuses and deductions is a separate scope again, payroll:write (see Recording money).
Everything under this scope comes from the same engine that draws the payroll screen in the admin panel. The API does not compute "something similar": the numbers you read are the numbers the owner sees, and the only thing added is the explanation of where each one came from.
Every amount is a string
"accrued": "12687.00"
Two decimals, in the currency from /v1/company. A string on purpose: a numeric that became a JSON float would give 0.30000000000004 after three additions, and a hundred rows later somebody is a cent short. Parse it with a decimal type or in cents. Never as a float.
Three numbers that must not be mixed
A row of GET /v1/payroll carries three things that look alike and answer different questions:
| Field | Question | Depends on the window |
|---|---|---|
accrued | what was earned for the days of the window, through today | yes |
paid | what cash left the till inside the window, by payment date | yes |
balance.due | what the organization owes this person right now | no |
balance is cumulative from balance.since, the day the organization started keeping payroll here. It is earned + adjustments - paid over the whole account, and it does not care which month you asked about: a person dismissed in July without a final settlement still shows their balance in a September request. Money before since lived elsewhere (a notebook, another system) and is not counted.
Do not reconcile accrued against paid and call the difference a debt. That is the mistake the product itself was built to stop: an advance in September is paid against August's work, and the two only meet in balance.
Where the money comes from
{
"accrued": "12687.00",
"base": "11400.00",
"premium": "1287.00",
"supplements": {
"shift_bonus": "150.00",
"away_shift": "400.00",
"month_day_bonus": "100.00",
"sales_percent": "637.00"
}
}
base is hours times the rate, or the shift prices, or the salary share. premium is everything above it. supplements is the same premium split by the event that produced it, and the vocabulary is fixed by the product:
| Key | What it is |
|---|---|
night | the extra percent on hours inside the night window |
holiday | the part above the base rate on holiday hours |
overtime_daily | the part above the base rate past the daily threshold |
shift_bonus | a flat sum per worked shift |
overtime_flat | a flat sum for overtime, per day or per hour past the threshold |
away_shift | a flat sum for a shift at a location that is not the person's own |
month_day_bonus | a flat sum by the ordinal number of the worked day in the month |
sales_percent | a percent of personal sales or of the location till |
double_shift | a flat sum for a day with two or more shifts |
seniority | a percent of the day's base pay by full years since the hire date; no hire date, no bonus |
A key is present when a rule of that type applied on at least one day, even if it yielded 0.00; an absent key means the rule never applied. holiday and overtime_daily never stack: the larger multiplier wins the whole segment. The rules themselves, with their values, levels and dates, are in GET /v1/payroll/rules.
Rounding. accrued, base and premium in the summary are rounded to whole currency units at the very end, exactly as the product screen shows them. The parts in supplements keep their cents, so their sum can differ from premium by less than one unit. GET /v1/payroll/days is not rounded at all, and the sum of a person's days equals their accrued before that final rounding.
Unknown is not zero
Two fields say "we do not know" and must not be read as "nothing":
unpriced_shifts: shifts the product cannot price, because the person has no pay terms and no rate on that day. Their money is missing fromaccruedand frombalance, not zero. While the number is above zero, treat that person's row as incomplete. Inbalanceit counts only days after the person's last closed period: shifts inside a closed period were settled with that period, the same as in the panel.personal_salesandlocation_saleson a day:nullmeans the till is not connected, the person is not linked to a cashier, or the collection has not run. It never means zero sales.
Days
GET /v1/payroll/days is the same engine run once per person per day: hours by bucket, the rate that applied, the amount with its split, the sales it was computed from, and the shifts of the day. Only days with paid shifts (published or confirmed) through today: a draft is the schedule, not money, and /v1/shifts already has it.
Two flags explain a day that does not look like "hours times rate":
instead_of_rate: true: the sales percent outgrew the rate and replaced it.baseandsales_percentare not additive on that day;amountis the larger of the two.preliminary: true: the sales for this day are still coming from an open till session, and the number will change by the evening.
Payouts, adjustments, pay runs
GET /v1/payments is cash that was handed over, by payment date. Three kinds are real cash: advance, settlement, other. The fourth, closing, is a period closed as "the money had already been paid earlier, just close it": it counts against balance but is not a cash movement on that day. Exclude it when reconciling the till. Deleted payments are never returned.
GET /v1/payroll/adjustments is one-off bonuses and deductions. They change what is accrued, not what is paid, and they carry no day of work, which is why they never appear inside days. kind: opening is the opening balance: what the person was really owed on the payroll start day, carried in from before the account was kept in InShift. It is dated on the start day and moves with it; treat it as a balance brought forward, not as an accrual of the period.
Money is counted from the payroll start day only. A window that reaches back before it returns the shifts and hours of those days, but no money: accrued, premiums, adjustments and paid in the window start on the start day, and /v1/payroll/days returns earlier days with amount: 0. Before the start day the organization was trying the product out; it owes nothing for those days here. signed_amount already has the sign. The free-text reason is not exposed, for the same reason the note on an absence is not: it is text about a person.
GET /v1/payroll/runs is the list of periods closed for everybody at once. Each run freezes what was due and what was paid per person at the moment of confirmation and is never recomputed: it is the document money was handed out by. A shift added to a closed month later shows up in the next period's /v1/payroll, not in the run. A cancelled run stays in the list with status: cancelled; its payments are gone from /v1/payments.
Recording money
With payroll:write a key records what accounting or an ERP already knows:
POST /v1/payments: anadvanceor anotherpayout, withpaid_at. Settlements and closing a period stay in the product: they move the "settled through" line, and that is set by a person who sees the amounts due.POST /v1/payroll/adjustments: abonusor adeduction, withdateand areason. The person reads the reason in the app, so write it for them. It is write-only: no response and no list ever returns it. The opening balance stays in the product.
Both need an Idempotency-Key: retrying after a network failure returns the first answer and never pays or books twice. Amounts are sent the way they are read, a string with two decimals ("500.00"; a number is accepted too), always positive and at most 99999999, the same limit as in the product. A day before the payroll start day is refused (invalid_parameter): it would not count.
Only your own records. Every payment and adjustment carries version and created_by_this_key. PATCH and DELETE (with If-Match: <version>) work only on what this key recorded; anything else answers 403 record_not_own. Rotating the key keeps its records: the new key changes what the old one recorded. Once a person corrects such a record in the product it becomes theirs, and the key no longer touches it. The person a record is for never changes: remove the record and create a new one. Removing is soft, as in the product: the record stops counting and stays crossed out in the product journal; a second delete answers 404.
Whose money. A key records money for the people whose pay it can read (the sections below): within its locations, and without the owner and other co-owners for a key issued by a co-owner. Only the owner changes their own pay, so a co-owner's key refuses a bonus or deduction for the co-owner's own card (403 own_pay_change); a payout to themselves is allowed, as in the product. When the person who issued the key is no longer an owner or co-owner, recording stops (403 key_issuer_inactive).
The owner sees it. Each entry appears in the product journal and in "History" signed with the key name ("API: 1C accounting"), and on the key's page in "What this key did".
Keys narrowed to locations
A key limited to some locations sees the people of those locations (primary, attached, or working everywhere). A person's money is counted whole: for a narrowed key, /v1/payroll (accrued and balance) and /v1/payroll/days include all of that person's shifts, including those at other locations, the same way the admin panel counts them. Payouts and adjustments belong to a person, not a location, and come in full too, so balance matches the panel. /v1/shifts stays narrowed to the key's locations: that is the schedule, not money. Personal (employee-level) entries in /v1/payroll/rules and /v1/payroll/pay-terms are narrowed to the people of the key's locations; organization, location and position entries come in full.
Keys issued by a co-owner
A key sees money the way the person who issued it does (see Authentication). For a key issued by a co-owner, the owner and the other co-owners are absent from /v1/payroll, /v1/payroll/days, /v1/payments, /v1/payroll/adjustments, their personal entries in /v1/payroll/rules and /v1/payroll/pay-terms, and from pay runs: a run's total and people_count count only the rows the key can see, and a run in which the key can see no row is not listed. A key issued by the owner sees everyone.
What is not here
- No settlements, closing, pay runs or pay terms on write. A key records advances, other payouts, bonuses and deductions; the rest is done in the product.
- No payout details. Card and account numbers are never exposed.
- No reasons, notes or names of who paid. Text about people stays inside.
Webhooks
Instead of you asking us every minute, we call you when something changes.
The organization owner adds an endpoint in Settings → Integrations → Webhooks, picks the event types, and gets a signing secret, once.
What arrives
A POST with the same thin event the change feed returns:
{
"id": "evt_cf819f02-2fa4-4d40-9d81-73b183f73d7c",
"type": "shift.updated",
"entity": "shift",
"action": "updated",
"object_id": "9f3a8064-ce13-4ef7-8338-c72cf667a89b",
"occurred_at": "2026-09-08T21:43:44.795Z"
}
Content-Type: application/json
Inshift-Signature: t=1788903694,v1=5f3a...
Inshift-Event-Id: evt_cf819f02-2fa4-4d40-9d81-73b183f73d7c
Inshift-Delivery-Id: 188031dd-f5f6-4ac5-b788-bdc7cad2c415
Inshift-Event-Type: shift.updated
User-Agent: inshift-webhooks/1
There is no object in the body, only its id. A body would be a second copy of the truth, ageing from the moment it was written: a webhook delivered six hours late would contradict the API itself. Take object_id, fetch the object from its resource, and you have the current state instead of a snapshot of unknown freshness.
Inshift-Event-Id is the same id the change feed reports for that event, so the two can be reconciled.
Test it before you need it
The owner can send a test event from Settings → Integrations → Webhooks, next to the endpoint. It travels the whole real path: same signature, same headers, same timeout, and it lands in the delivery log with whatever your server answered. So a green result means the real thing will arrive too.
{
"id": "evt_1f0c4a58-7a1e-4b2e-9d0a-2f6d3f1b8e77",
"type": "ping",
"entity": "ping",
"action": "test",
"object_id": null,
"occurred_at": "2026-09-09T11:24:03.118Z"
}
Answer 2xx and ignore it. type is ping and object_id is null, so nothing to fetch and nothing to act on.
A failed test does not count against the endpoint: it will not push it towards being disabled, and it is not retried. It is a question, not a promise. You can also test an endpoint we already disabled, which is exactly how you check whether it is safe to switch back on.
Verify the signature
Inshift-Signature: t=<unix seconds>,v1=<hex hmac-sha256>
The signed string is "{t}.{raw body}", keyed with your signing secret.
The timestamp is inside the signature on purpose. A signature over the body alone stays valid forever, so anyone who captures one delivery can replay it a year later. With the timestamp signed, you reject anything older than a few minutes and the replay stops working.
import hmac, hashlib, time
def verify(secret: str, raw_body: bytes, header: str, tolerance=300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts["t"])
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
Three things it is easy to get wrong:
- Use the RAW body, before JSON parsing. Re-serialising changes whitespace and key order, and the signature will never match again.
- Compare in constant time (
hmac.compare_digest), not with==. - Reject on a stale timestamp. Skipping that check throws away the only protection against replay.
Answer quickly
Any 2xx means delivered. Anything else, or no answer within 10 seconds, counts as a failure.
Do the work asynchronously: put the event on your own queue and answer 200 immediately. A handler that fetches the object, writes to a database and only then answers will eventually cross ten seconds, and every one of those becomes a retry, that is, more load on you, not less.
Retries
1 minute → 5 minutes → 30 minutes → 2 hours → 6 hours → 24 hours, then the delivery is marked failed. The schedule widens on purpose: the early attempts catch a minute of network trouble, the late ones give your team a working day to bring a server back.
After 10 deliveries fail in a row the endpoint is switched off and the owner sees why in the panel. We do not keep knocking at a server that is gone.
Switching it back on is one button in the panel, next to the test button, so a morning of downtime on your side does not cost the owner a new endpoint and you a new signing secret. Events from the downtime are not replayed: the endpoint resumes from the next change, and anything missed in between is fetched from the API the same way a first sync is.
Every attempt is in the delivery log in the panel, with the status code and the first part of your response, and any delivery can be retried by hand.
At least once, not exactly once
A delivery can arrive twice: your 200 may be lost on the way back, and we retry what we could not confirm. Make the handler idempotent. Since the event carries only an id, handling it twice means fetching the same object twice, which changes nothing.
The opposite also happens: a delivery can be lost entirely if your server was down for a day. A reliable integration reconciles against /v1/events at least daily. Webhooks make you fast; the feed makes you correct.
What we refuse to call
http://. Without TLS the body and the signature travel in the clear.- Anything not reachable from the public internet:
localhost,127.0.0.1, private ranges (10.x,172.16–31.x,192.168.x), link-local (169.254.169.254, the cloud metadata address), CGNAT, IPv6 equivalents. - Redirects. We do not follow them. A
302is the standard way to turn an allowed address into a forbidden one.
The address is resolved once and the connection goes to that exact IP, so a domain cannot answer with a public address during the check and a private one a moment later.
If you are testing locally, use a tunnel with a public https address (ngrok, Cloudflare Tunnel).
Ordering
Not guaranteed. Events are dispatched in commit order, but retries reorder things by design: an event that failed once arrives after ones that succeeded. If order matters to you, sort by occurred_at, or simply fetch the current state, which is what the thin-event design is for.
Calendar subscription
A schedule that appears in Google Calendar, Apple Calendar or Outlook on its own. No API key, no developer, no OAuth.
How it works
Each person creates their own link in the InShift app, under Settings -> Calendar. The owner is not involved: this used to be a panel screen where the owner picked a person from a list and handed the address over, which is one manual step per employee for something everyone can do for themselves in a tap.
https://api.inshift.app/v1/calendars/AHjTaDbgEksx1qI-A8GNB7dmKRzJzXhz8iq5Zv8DP58.ics
The link is added to a calendar app once ("Add calendar by URL"), and the shifts keep showing up there. One live link per person: creating it again returns the same address, and removing it in the app makes that address answer 404 immediately, so a link sent to the wrong chat is undone in two taps.
The URL is the credential. Calendar apps cannot send headers. They can do exactly one thing: fetch a URL every few hours. That is why there is no Bearer here and why the token is long and random. Treat the link like a password: it shows one person's schedule and nothing else.
What the feed contains
- Published and confirmed shifts only. A draft is not a promise, and waking someone up for a shift that will not happen is worse than showing nothing.
- A month back and six months forward. Calendars do not need history, and a file covering years would be re-downloaded in full on every refresh.
- Both readings of time are already resolved: every event is an absolute moment in UTC, so overnight shifts and daylight-saving transitions land where they should.
The event title is the location, and the job position and the shift label go into the description. The calendar itself is named after the organization, so it is recognisable among the other calendars on the phone.
The UID of each event is stable, so moving a shift moves the existing event instead of creating a duplicate.
Refresh delay is normal
Google refreshes external calendars on its own schedule, usually every few hours, sometimes longer, and it ignores our REFRESH-INTERVAL hint. Apple Calendar can be set to refresh more often; Outlook behaves like Google.
This is worth saying out loud to whoever asks for the integration, because "I changed the schedule and nothing happened in my phone" reads exactly like a bug and is not one. If someone needs changes within seconds, that is what webhooks and the API are for.
Why this instead of a real Google Calendar integration
A full integration means OAuth, storing other people's Google tokens, writing into their calendars and syncing changes in both directions: weeks of work and a permanent responsibility for someone else's account. A subscription link closes the same need with a 2 KB file, works identically in Google, Apple and Outlook, and gives us no access to anyone's account at all.
The trade-off is honest: subscriptions are read-only and refresh slowly. For "see my shifts on my phone" that is the whole requirement.
MCP: InShift inside Claude, ChatGPT, Codex and Cursor
The same API, but the assistant calls it for you. InShift exposes every endpoint of this API as an MCP tool, so you can ask your assistant "who is on shift tomorrow at Head Office", "give Olena the evening shift on Friday" or "how many hours did Marko work in September" and it does the calls itself.
POST https://api.inshift.app/mcp
Authorization: Bearer insh_live_...
One URL, one key, no other setup. The key is an ordinary API key from Settings → Integrations: the scopes you give it are exactly what the assistant can do. A key without shifts:write lets the assistant read the schedule and nothing else, and that is the right key for most people.
Tools
The tools are the endpoints of this reference, named by their operationId: getCompany, listShifts, createShift, listTimesheets, listPayroll and so on. Path parameters, query parameters and body fields become one flat set of tool arguments. Two things are handled for the assistant:
Idempotency-Keyon writes is generated per call.If-Matchon edits and deletes comes from the optionalversionargument; omitted, it means "whatever is current".
Everything else is the API: the same wall-clock times in the company time zone, the same money strings, the same cursors, the same errors with the same doc_url. A tool call that the API would reject comes back to the assistant as an error it can read and fix.
Connecting
The server is stateless Streamable HTTP, which every current client speaks. There are two ways in, and both end with the same key.
Claude.ai and ChatGPT (in the browser): add a custom connector with the URL https://api.inshift.app/mcp. The client discovers OAuth on its own, opens a page on api.inshift.app where you paste the API key, and receives its own token. The token inherits the key's scopes and locations and dies with the key: revoke the key in Settings → Integrations and the assistant is disconnected.
The page names the address the authorization code goes back to. For Claude and ChatGPT it says so and marks the client as verified; any other address is shown with a warning. Paste the key only if you started the connection yourself, in that client: a link to this page sent by someone else is how a key gets stolen, and the page cannot tell who opened it.
Claude Desktop, Claude Code, Cursor, Codex, Gemini CLI: add a remote MCP server with the URL above and the header Authorization: Bearer <key>. For example, Claude Code:
claude mcp add --transport http inshift https://api.inshift.app/mcp \
--header "Authorization: Bearer insh_live_..."
Codex (~/.codex/config.toml):
[mcp_servers.inshift]
url = "https://api.inshift.app/mcp"
bearer_token_env_var = "INSHIFT_API_KEY"
Custom GPTs can also use the API directly: create a GPT and import https://inshift.app/api/openapi.json as an action with Bearer authentication.
What to expect
The assistant starts with getCompany to learn your time zone and which modules are on, finds people and locations by name, and is instructed to confirm with you before it deletes or moves shifts. It sees only what the key sees: a key limited to two locations answers about two locations. Every write it makes is signed in the history with the key's name, the same as any other integration.
Rate limits and the request log are shared with the API: an assistant that loops is stopped by the same ceiling as a script that loops. So is the organization's access: when it is read-only for an unpaid invoice, tools that read keep working and tools that write answer organization_restricted until the owner settles it, and the key is not to blame. On the free plan every tool answers plan_module: AI assistants are part of the Basic plan.
What we promise not to break
The version is in the path: /v1/. Everything below is what v1 means as a promise.
We will not, inside v1
- Remove a field, or rename one.
- Change a field's type. A string will not become a number, an object will not become an array.
- Make a nullable field non-nullable or the reverse.
- Change the meaning of a value.
hourswill not quietly switch from planned to actual. - Remove an endpoint, a query parameter, or a value a parameter accepts.
- Change an error
code. The Englishmessagenext to it may be reworded at any time; that is exactly why you branch oncode. - Reduce what a scope grants.
We will, without warning
- Add fields to any response.
- Add endpoints, parameters and accepted values.
- Add new event types to the change feed and to webhooks.
- Add error codes for situations that previously fell under a broader one.
- Change the order of fields in JSON, and the exact wording of
message.
What that means for your client
- Ignore fields you do not know. Do not validate responses against a closed schema that rejects extra keys: the first field we add would break you.
- Do not depend on field order or on JSON formatting.
- Handle unknown event types by skipping them, not by failing.
- Do not parse
message. Branch oncode. - Treat ids as opaque strings. They are UUIDs today; a timesheet row id is already not one (
shift_<uuid>). Do not parse them, do not assume a length. - Take
next_cursorverbatim. Its contents will change.
Changes to v1 behaviour
Each rule below is a rule of the schedule itself. It applies to every route that writes shifts, the product included, so the API follows it rather than keeping a second set of rules. Each one changed what a request that used to work does, which the lists above do not cover, so we name them here.
October 2026
409 employee_absent.POST /v1/shifts,PATCH /v1/shifts/{id}, rows ofPOST /v1/shifts/batchand the MCP shift tools refuse to put a person on a day of their approved time off (vacation, sick leave, personal or unpaid leave). Before, such a write succeeded. Their own shift already standing on that day can still be edited, and a day-off marker is still accepted. See Errors.- A shift removes the person's day-off marker. Creating a shift for a person, or a
PATCHthat gives the person a new day (a different person, another date, a marker given working hours), deletes their day-off marker on that date. Editing the hours, location or status of a working shift does not. You see it asshift.deletedin the change feed and in webhooks. Before, the marker stayed next to the shift. See Writing.
If v2 ever happens
It would be a new path (/v2/), and /v1/ would keep working. We would say how long in advance, in writing, to every organization with a live key. You can see which keys those are in your own panel, and so can we.
There is no deprecation of v1 planned, and nothing in this document is a hint of one.
Watching for changes
openapi.jsonis regenerated from the code itself, so a diff of it is a true diff of the API. It is worth pinning a copy and diffing on your side.Inshift-Api-Version: v1comes back on every response, so a client can assert what it is talking to.
Recipes
Export a month of hours (Python)
import os, requests
from decimal import Decimal
BASE = "https://api.inshift.app"
S = requests.Session()
S.headers["Authorization"] = f"Bearer {os.environ['INSHIFT_KEY']}"
def pages(path, **params):
cursor = None
while True:
page = S.get(f"{BASE}{path}", params={**params, "limit": 100, "cursor": cursor},
timeout=30).json()
yield from page["data"]
if not page["has_more"]:
return
cursor = page["next_cursor"]
people = {e["id"]: e for e in pages("/v1/employees")}
total = {}
for row in pages("/v1/timesheets", **{"from": "2026-09-01", "to": "2026-09-30"}):
# Decimal, not float: hours become money one step later.
total[row["employee_id"]] = total.get(row["employee_id"], Decimal(0)) + Decimal(row["hours"])
for employee_id, hours in sorted(total.items(), key=lambda kv: -kv[1]):
person = people[employee_id]
print(f"{person.get('external_id') or person['id']:<40} {person['name']:<30} {hours}")
Who is scheduled tomorrow (curl + jq)
TOMORROW=$(date -u -d '+1 day' +%F 2>/dev/null || date -u -v+1d +%F)
curl -s "https://api.inshift.app/v1/shifts?from=$TOMORROW&to=$TOMORROW&status=published&limit=100" \
-H "Authorization: Bearer $INSHIFT_KEY" \
| jq -r '.data[] | "\(.start_time[0:5])-\(.end_time[0:5]) \(.employee_id // "OPEN SLOT")"'
Link your own employee ids
Ask the owner to fill external_id on each person (it is a technical key: it is what your system calls them). Then a daily sync is one call per person:
curl -s "https://api.inshift.app/v1/employees/by-external-id/EMP-000123" \
-H "Authorization: Bearer $INSHIFT_KEY"
404 means "no such person here", which is a clear answer, unlike an empty list, which you would still have to interpret.
external_id needs no PII scope, so a key that must not see phone numbers can still match people reliably.
1C (8.3)
Функция ВыполнитьЗапрос(Путь)
HTTPСоединение = Новый HTTPСоединение("api.inshift.app", 443, , , , 30,
Новый ЗащищенноеСоединениеOpenSSL);
Запрос = Новый HTTPЗапрос(Путь);
Запрос.Заголовки.Вставить("Authorization", "Bearer " + Константы.КлючInshift.Получить());
Ответ = HTTPСоединение.Получить(Запрос);
Если Ответ.КодСостояния <> 200 Тогда
// request_id из тела обязательно кладём в лог: без него разбирать нечего.
ВызватьИсключение "inshift " + Ответ.КодСостояния + ": " + Ответ.ПолучитьТелоКакСтрока();
КонецЕсли;
Чтение = Новый ЧтениеJSON;
Чтение.УстановитьСтроку(Ответ.ПолучитьТелоКакСтрока());
Возврат ПрочитатьJSON(Чтение);
КонецФункции
Функция ВыгрузитьТабель(ДатаС, ДатаПо)
Результат = Новый Массив;
Курсор = "";
Пока Истина Цикл
Путь = "/v1/timesheets?from=" + Формат(ДатаС, "ДФ=yyyy-MM-dd")
+ "&to=" + Формат(ДатаПо, "ДФ=yyyy-MM-dd") + "&limit=100";
Если Не ПустаяСтрока(Курсор) Тогда
Путь = Путь + "&cursor=" + КодироватьСтроку(Курсор, СпособКодированияСтроки.КодировкаURL);
КонецЕсли;
Страница = ВыполнитьЗапрос(Путь);
Для Каждого Строка Из Страница["data"] Цикл
Результат.Добавить(Строка);
КонецЦикла;
Если Не Страница["has_more"] Тогда
Прервать;
КонецЕсли;
Курсор = Страница["next_cursor"];
КонецЦикла;
Возврат Результат;
КонецФункции
Notes for 1C specifically:
- Hours arrive as strings. Convert them with a decimal type, never through
Число()on a locale-dependent path. - The cursor must be URL-encoded when appended to the query string.
- TLS. The API requires TLS 1.2 or newer. Platform versions older than 8.3.14 do not speak it and will fail to connect at all.
Make / n8n / Zapier
There is no first-party app yet. Use a generic HTTP request block:
- URL
https://api.inshift.app/v1/... - Header
Authorization: Bearer insh_live_... - Follow
next_cursorin a loop untilhas_moreisfalse.
Postman
Import openapi.json and Postman turns it into a collection with every endpoint and parameter. Set the collection's Bearer token to your key once.
Pitfalls worth reading before you start
actual: nullin a timesheet row is not zero hours. It means attendance tracking is off or the person never clocked in. Reading it as zero is how an integration underpays people.email: nullon a person means they have no account, not that the email field is blank. Whether somebody still needs an invite code ishas_account: false, which needs no personal-data scope./v1/absencesreturns every status unless you ask for one. Counting attendance meansstatus=approved.positionhere is a job position ("Barista"), not an access role. Access roles are not exposed at all.- A shift with
employee_id: nullis an unfilled slot, not a data error. Withopen: trueit is one the team can apply for.
Objects
Every field of every answer, straight from the schemas the API runs on. Request bodies are described with their endpoint, in the endpoint reference.
Error
| Field | Type | Notes |
|---|---|---|
error | object |
Company
| Field | Type | Notes |
|---|---|---|
id | string | |
name | string | |
timezone | string | The organization time zone. Every wall-clock time in this API is read in it |
currency | string | The currency payroll is kept in. v1 returns no amounts anywhere |
created_at | string | An instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z |
features | object |
Location
| Field | Type | Notes |
|---|---|---|
id | string | |
name | string | |
address | string | |
external_id | string | |
group | string | |
status | active archived | Locations are archived, never deleted: past schedules must stay readable |
created_at | string | An instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z |
updated_at | string | An instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z |
Position
A job position ("Barista"), not an access role
| Field | Type | Notes |
|---|---|---|
id | string | |
name | string | |
external_id | string | |
group | string | |
created_at | string | An instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z |
updated_at | string | An instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z |
Employee
| Field | Type | Notes |
|---|---|---|
id | string | |
external_id | string | |
name | string | |
status | active dismissed | |
in_schedule | boolean | This person is scheduled. false means they are on the team but never appear in the schedule |
hire_date | string | A calendar date, YYYY-MM-DD |
dismissed_at | string | An instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z |
position_id | string | |
position_ids | array of string | |
primary_location_id | string | |
location_ids | array of string | |
all_locations | boolean | Works at every location. location_ids then lists only explicit attachments and limits nothing |
has_account | boolean | The person has joined the app with an invite code and signs in with their own account. false means nobody has joined under this card yet: an invite code (POST /v1/employees/{id}/invite-code) is what connects them |
is_intern | boolean | An intern: works alongside a mentor and is paid the intern rate of their position. Always false while the Internships module is off |
hire_planned_at | string | When an intern is planned to join the staff; null when nothing is planned. It happens by itself at that moment |
created_at | string | An instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z |
updated_at | string | An instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z |
personnel_no | string | |
phone | string | |
birth_date | string | A calendar date, YYYY-MM-DD |
email | string | The ACCOUNT email. null means the person has no account yet, NOT that the email is blank |
Shift
| Field | Type | Notes |
|---|---|---|
id | string | |
date | string | A calendar date, YYYY-MM-DD |
start_time | string | Wall-clock time as the schedule shows it, HH:MM:SS |
end_time | string | |
start_at | string | An instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z |
end_at | string | For an overnight shift this is already the next day |
hours | string | Hours as a string with two decimals. A string, so no float rounding is introduced on the way |
employee_id | string | null means an unfilled slot |
employee_name | string | The name of the person on the shift, so a list of shifts reads without a lookup per row. null for an unfilled slot |
location_id | string | |
location_name | string | |
position_id | string | The position of the shift itself, which is not the same thing as the position of the person. An unfilled slot uses it to say which position it is looking for; a slot that was filled from the open list keeps the position it was taken with. Most shifts are planned by putting a named person on a day, and those carry null: nothing about the shift overrides anything, and the position to show is the person own, from employees.position_id. |
status | draft published confirmed | |
open | boolean | An unfilled slot offered to the team: people apply, a manager picks |
is_substitution | boolean | |
label | string | |
version | integer | Optimistic-locking token. Will be used with If-Match once writes arrive |
edited | boolean | The shift was edited after it was created |
created_at | string | An instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z |
updated_at | string | An instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z |
ShiftBatchResult
| Field | Type | Notes |
|---|---|---|
created | integer | |
failed | integer | |
results | array of object |
Deleted
| Field | Type | Notes |
|---|---|---|
id | string | |
deleted | true |
EmployeeDismissalResult
| Field | Type | Notes |
|---|---|---|
employee | Employee | |
future_shifts | integer | Shifts after today that were turned into unfilled slots or removed, depending on future_shifts in the request. Today's shift is left as it is: the person usually works it out |
cancelled_requests | integer | Live swap and substitution requests of this person that were cancelled |
InviteCode
| Field | Type | Notes |
|---|---|---|
employee_id | string | |
code | string | 6 characters. Whoever enters it in the InShift app joins under this card |
expires_at | string | After this moment the app no longer accepts the code; 7 days from issue |
join_url | string | A link that opens the InShift app on the join screen with the code filled in, or the app store when the app is not installed |
InternHireResult
| Field | Type | Notes |
|---|---|---|
employee | Employee | |
at | string | The moment they are staff from, wall clock in the company time zone, YYYY-MM-DDTHH:MM |
scheduled | boolean | true: the moment is in the future and the hire happens by itself then; false: done now |
pay_known | boolean | Staff pay is set for them from that day. false: their shifts as staff have no price yet, and the people who manage pay get a notice to set it |
Absence
The free-text note a person wrote when asking for time off is deliberately NOT here. "Chemotherapy on Tuesday" is health data, and health data must not leave through a scope called "absences:read". The machine-readable reason is type; that is what a payroll or attendance integration actually needs.
| Field | Type | Notes |
|---|---|---|
id | string | |
employee_id | string | |
type | vacation sick personal unpaid | |
status | pending approved rejected cancelled | No default filter: when counting attendance ask for status=approved explicitly |
start_date | string | A calendar date, YYYY-MM-DD |
end_date | string | A calendar date, YYYY-MM-DD |
days | integer | Calendar days, both ends inclusive |
reviewed_at | string | An instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z |
created_at | string | An instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z |
updated_at | string | An instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z |
AttendanceSession
| Field | Type | Notes |
|---|---|---|
id | string | |
employee_id | string | |
location_id | string | |
shift_id | string | |
date | string | A calendar date, YYYY-MM-DD |
opened_at | string | An instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z |
closed_at | string | An instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z |
break_minutes | integer | |
worked_minutes | number | null means the session is still open and has no duration yet. It is not zero |
hours | string | Hours as a string with two decimals. A string, so no float rounding is introduced on the way |
closed_by | scan auto manual | |
created_at | string | An instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z |
updated_at | string | An instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z |
TimesheetEntry
| Field | Type | Notes |
|---|---|---|
id | string | shift_<uuid> for a scheduled row, att_<uuid> for clock-ins with no shift behind them |
date | string | A calendar date, YYYY-MM-DD |
employee_id | string | |
employee_external_id | string | |
personnel_no | string | Only present for keys holding employees:read.pii |
location_id | string | |
position_id | string | |
scheduled | object | null means the person worked unplanned: there was no shift that day |
actual | object | null means attendance is off or the person never clocked in. THIS IS NOT ZERO HOURS |
hours | string | Hours as a string with two decimals. A string, so no float rounding is introduced on the way |
hours_source | scheduled actual | Where the hours field came from |
status | draft published confirmed | |
shift_id | string | |
attendance_session_ids | array of string |
Event
| Field | Type | Notes |
|---|---|---|
id | string | evt_<uuid>, stable across requests |
type | string | entity.action, for example shift.updated |
entity | string | |
action | created updated deleted | |
object_id | string | The id of the changed object. Fetch its current state from the matching resource |
occurred_at | string | An instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z |
PayrollSummary
| Field | Type | Notes |
|---|---|---|
employee_id | string | |
employee_external_id | string | |
personnel_no | string | Only with employees:read.pii |
status | active dismissed | |
from | string | A calendar date, YYYY-MM-DD |
to | string | A calendar date, YYYY-MM-DD |
accrued_through | string | The last day money is accrued for: the end of the window or today, whichever is earlier. Future shifts are never money |
pay | PayrollPay | |
shifts | integer | Worked shifts in the window: published or confirmed, drafts excluded |
hours | string | Hours as a string with two decimals |
night_hours | string | Buckets overlap on purpose: a night hour on a holiday is in both |
holiday_hours | string | Hours as a string with two decimals |
overtime_hours | string | Hours as a string with two decimals |
unpriced_shifts | integer | Shifts in the window with no known price: no pay terms and no rate on that day. Not zero money: unknown money |
accrued | string | Everything accrued in the window: base plus supplements, rounded to whole currency units like the product screen |
base | string | accrued minus premium: hours times the rate, or the shift prices, or the salary share |
premium | string | Everything above the base: multipliers and flat supplements together |
supplements | PayrollSupplements | Money above the base pay, split by the event that produced it. A key is present when a rule of that type applied on at least one day, even if it yielded 0.00. Holiday and daily overtime never stack: the larger multiplier wins the whole day segment, so at most one of them is non-zero per segment. Parts keep cents; the summary premium is rounded to whole currency units like the product screen, so the sum of parts can differ from it by less than one unit |
instead_of_rate_days | integer | Days when the sales percent outgrew the rate and replaced it, so base and sales_percent are not additive on those days |
adjustments | string | Bonuses minus deductions dated inside the window |
paid | string | Cash paid out inside the window by payment date. Closings ("already paid earlier") are not cash and are excluded |
balance | PayrollBalance | The running account of one person: what the organization owes them right now. Does not depend on the window |
PayrollPay
| Field | Type | Notes |
|---|---|---|
kind | hourly per_shift monthly shift_rate none sales_only | How this person is paid at the end of the window. hourly: amount per hour. per_shift: amount per shift. monthly: amount per month, split by the planned shifts of the month. shift_rate: each shift carries its own price. none: works but is not paid (an owner). sales_only: shifts cost nothing, the person earns the sales percent and other extras only. null: no pay terms yet, the legacy hourly rate applies |
amount | string | The amount behind kind. null when there are no pay terms |
hourly_rate | string | The hourly rate in force on the last day of the window, from the rate history. 0.00 when the person is paid another way |
hourly_rate_from | string | The hourly rate on the first day of the window when it changed inside it, otherwise null |
PayrollSupplements
Money above the base pay, split by the event that produced it. A key is present when a rule of that type applied on at least one day, even if it yielded 0.00. Holiday and daily overtime never stack: the larger multiplier wins the whole day segment, so at most one of them is non-zero per segment. Parts keep cents; the summary premium is rounded to whole currency units like the product screen, so the sum of parts can differ from it by less than one unit
| Field | Type | Notes |
|---|---|---|
night | string | Night premium: the extra percent on the hours inside the night window |
holiday | string | Holiday multiplier: the part above the base rate on holiday hours |
overtime_daily | string | Daily overtime multiplier: the part above the base rate past the daily threshold |
shift_bonus | string | Flat bonus per worked shift |
overtime_flat | string | Flat bonus for overtime: per day or per hour past the threshold |
away_shift | string | Flat bonus for a shift at a location that is not the person own |
month_day_bonus | string | Flat bonus by the ordinal number of the worked day in the month (the 22nd day, the 26th...) |
sales_percent | string | Percent of personal sales or of the location till |
double_shift | string | Flat bonus for a day with two or more shifts |
mentoring | string | Pay for mentoring trainees: per trainee shift or per hour with a trainee, taken from the trainee shift. Can fall on a day without own shifts |
minimum_top_up | string | Top-up to the shift minimum of the sales_percent rule (min_shift_pay): when the base of the day plus sales_percent fall short of the minimum times the shifts of the day, the difference. Other supplements are not counted towards the minimum |
seniority | string | Seniority bonus: a percent of the base pay of the day (hours at the rate, shift price, share of the salary or a sum written into the shift), other supplements excluded. Years count from the hire date; no hire date, no bonus |
PayrollBalance
The running account of one person: what the organization owes them right now. Does not depend on the window
| Field | Type | Notes |
|---|---|---|
since | string | The day this organization started keeping payroll here. Money before it lived elsewhere and is not counted |
earned | string | Accrued for shifts from since through today, supplements included |
adjustments | string | Bonuses minus deductions from since, no upper bound: a bonus dated tomorrow is already a promise |
paid | string | Paid out from since, no upper bound, every kind including closings |
due | string | earned + adjustments - paid. Negative means the person was paid more than they earned |
unpriced_shifts | integer | Shifts in the whole account the product cannot price. While above zero, due is incomplete |
PayrollDay
One person, one day: the same engine as the product screen "how the month was made up", day by day
| Field | Type | Notes |
|---|---|---|
date | string | A calendar date, YYYY-MM-DD |
employee_id | string | |
employee_external_id | string | |
personnel_no | string | Only with employees:read.pii |
hours | string | Hours as a string with two decimals |
night_hours | string | Hours as a string with two decimals |
holiday_hours | string | Hours as a string with two decimals |
overtime_hours | string | Hours as a string with two decimals |
pay_kind | hourly per_shift monthly shift_rate none sales_only | The pay term that priced this day. null: legacy hourly rate |
hourly_rate | string | The hourly rate in force on this day |
amount | string | Accrued for the day, with cents. The sum of days equals the summary accrued before its final rounding |
base | string | Money as a string with two decimals, in the company currency (see /v1/company). A string on purpose: never parse it as a float |
premium | string | Money as a string with two decimals, in the company currency (see /v1/company). A string on purpose: never parse it as a float |
supplements | PayrollSupplements | Money above the base pay, split by the event that produced it. A key is present when a rule of that type applied on at least one day, even if it yielded 0.00. Holiday and daily overtime never stack: the larger multiplier wins the whole day segment, so at most one of them is non-zero per segment. Parts keep cents; the summary premium is rounded to whole currency units like the product screen, so the sum of parts can differ from it by less than one unit |
unpriced_shifts | integer | |
instead_of_rate | boolean | The sales percent replaced the rate on this day: amount is the larger of the two, not their sum |
preliminary | boolean | Sales for this day are still coming from an open till session: the number will change |
month_day_number | integer | The ordinal number of this worked day inside its month, the basis of month_day_bonus. null when no such rule exists |
personal_sales | string | Personal sales of this person on this day from the connected till. null means unknown, never zero; also null before the sales-percent start date of the till (normally the 1st of the month it was connected): earlier collected days are statistics, not pay |
location_sales | string | The whole till of the locations worked on this day. null means unknown, including a day before the sales-percent start date of any of those tills (normally the 1st of the month the till was connected) |
sales_threshold | string | The sales threshold of the sales_percent rule on this day: the percent was paid only on personal_sales or location_sales above it. null: no threshold, the percent counts from the first unit |
shifts | array of PayrollDayShift |
PayrollDayShift
| Field | Type | Notes |
|---|---|---|
id | string | |
start_time | string | Wall-clock time, HH:MM |
end_time | string | |
location_id | string | |
position_id | string | |
is_substitution | boolean | |
price | string | The agreed price of this very shift, only when a shift_rate pay term is what paid it. null otherwise |
Payment
Deleted payments are never returned: they are not money anymore
| Field | Type | Notes |
|---|---|---|
id | string | |
employee_id | string | |
employee_external_id | string | |
amount | string | Money as a string with two decimals, in the company currency (see /v1/company). A string on purpose: never parse it as a float |
kind | advance settlement other closing | advance, settlement and other are cash that left the till. closing is "already paid earlier, just close the period": it counts against the balance but is not a cash movement on that day |
paid_at | string | The day the money was handed over |
run_id | string | The pay run that recorded this payment, when it came from one |
version | integer | Send it as If-Match to change or remove the payment |
created_by_this_key | boolean | Recorded with the key making this request. A key changes and removes only its own payments |
created_at | string | An instant in UTC, ISO-8601 |
updated_at | string | An instant in UTC, ISO-8601 |
PayrollAdjustment
A one-off bonus, deduction or opening balance. Changes what is accrued, not what is paid. The free-text reason is not exposed
| Field | Type | Notes |
|---|---|---|
id | string | |
employee_id | string | |
employee_external_id | string | |
kind | bonus deduction opening | opening is the opening balance owed to the person on the payroll start day: debt carried in from before the account was kept here, not an accrual of this period |
amount | string | Always positive; the sign comes from kind |
signed_amount | string | The same amount with its sign: a deduction is negative |
date | string | The day it was booked on: it decides which window and which side of a pay run it falls into |
version | integer | Send it as If-Match to change or remove the bonus or deduction |
created_by_this_key | boolean | Recorded with the key making this request. A key changes and removes only its own bonuses and deductions |
created_at | string | An instant in UTC, ISO-8601 |
updated_at | string | An instant in UTC, ISO-8601 |
PayrollRun
A pay run: one period closed for everybody at once. Rows are frozen at confirmation and never recomputed
| Field | Type | Notes |
|---|---|---|
id | string | |
period_from | string | A calendar date, YYYY-MM-DD |
period_to | string | Inclusive |
paid_at | string | A calendar date, YYYY-MM-DD |
kind | settlement closing | settlement: cash left the till. closing: the money had been paid before, the run only closes the period |
status | confirmed cancelled | |
people_count | integer | |
total | string | Money as a string with two decimals, in the company currency (see /v1/company). A string on purpose: never parse it as a float |
created_at | string | An instant in UTC, ISO-8601 |
cancelled_at | string | An instant in UTC, ISO-8601 |
rows | array of PayrollRunRow | Only on GET /v1/payroll/runs/{id} |
PayrollRunRow
| Field | Type | Notes |
|---|---|---|
employee_id | string | |
employee_external_id | string | |
accrued | string | Accrued from the payroll start through period_to, frozen at confirmation |
paid_before | string | Paid before this run, over the whole account |
due | string | accrued - paid_before at the moment of confirmation |
amount | string | What this run paid. Less than due means a partial payout |
settled | boolean | A settlement line was drawn at period_to: only on a full payout |
PayrollRule
One supplement rule as configured. The vocabulary of types is fixed by the product; values, levels and dates are the organization own
| Field | Type | Notes |
|---|---|---|
id | string | |
type | night holiday overtime_daily shift_bonus overtime_flat away_shift month_day_bonus sales_percent double_shift mentoring seniority | |
scope | organization location position employee | Whom it applies to. The narrower always wins the wider: employee over position over location over organization |
scope_id | string | The location, position or employee. null for the organization |
effective_from | string | Rules have history: a rule edited today does not rewrite last month |
value | number | night, sales_percent and seniority: the percent (20 = +20%). holiday and overtime_daily: the multiplier (2 = double). Others: null |
amount | string | Flat types: the sum of money. Others: null |
amount_unit | day hour shift | overtime_flat: per day of overtime (day) or per hour past the threshold (hour). mentoring: per trainee shift (shift) or per hour with a trainee (hour) |
threshold_hours | number | overtime_daily and overtime_flat: hours per day paid as usual |
window_start | string | night only: HH:MM:SS. An end before the start means the window crosses midnight |
window_end | string | |
away_basis | primary any | away_shift only: which locations count as the person own |
day_from | integer | month_day_bonus only: the first ordinal worked day of the month this step applies to |
day_to | integer | null means no upper bound |
sales_base | personal location | sales_percent only: personal sales or the whole till |
crew_from | integer | sales_percent only: the step by the number of people on shift |
crew_to | integer | |
crew_position_ids | array of string | sales_percent steps on the location till only: who counts as people on shift. One list for the whole ladder (every step with the same scope and effective_from). null: everybody on the schedule of the location. Otherwise only people working these positions on that shift (the shift position, or the one on their card when the shift has none); the person being paid always counts |
instead_of_rate | boolean | sales_percent only: pay the rate OR the percent, whichever is larger |
sales_threshold | string | sales_percent only: the percent counts only on the sales of the day above this sum (the base of the rule: personal sales or the whole till). null: from the first unit |
min_shift_pay | string | sales_percent only: the base of the day plus the percent is never less than this sum per shift; the difference is paid as the minimum_top_up supplement. null: no minimum |
group_id | string | Rules saved together for several people at once share it: each person still has an own employee rule, the group only ties them for editing. null: not part of a group |
years_from | integer | seniority only: the step starts at this many full years since the hire date. The highest step reached on the day pays. Steps with the same scope and effective_from are one ladder |
created_at | string | An instant in UTC, ISO-8601 |
PayTerm
How people are paid, with history. The narrower level wins: employee over position over organization
| Field | Type | Notes |
|---|---|---|
id | string | |
scope | organization position employee | |
scope_id | string | |
kind | hourly per_shift monthly shift_rate none sales_only | |
amount | string | Per hour, per shift or per month, according to kind. 0.00 for none and sales_only |
effective_from | string | A calendar date, YYYY-MM-DD |
norm_shifts | integer | monthly only: how many shifts a month count as the full salary. null: the planned shifts of the month |
created_at | string | An instant in UTC, ISO-8601 |
Endpoint reference
Generated from the same schemas the API runs on. The machine-readable version is openapi.json. Point Postman or a client generator at it.
GET /v1/company
The organization this key belongs to
Read this first. It carries the time zone every wall-clock time in the API is read in, and which modules are on: an empty /v1/attendance means "the module is off" as often as it means "nobody clocked in", and only this endpoint tells the two apart.
Requires the company:read scope.
GET /v1/locations
Locations
Paginated. Take next_cursor from the response verbatim and pass it as cursor. Never build a cursor yourself and never assume it is a date or an id.
Requires the locations:read scope.
| Query parameter | Type | Required | Notes |
|---|---|---|---|
status | active archived | ||
updated_since | string | ||
sort | string | ||
limit | integer | ||
cursor | string |
GET /v1/locations/{id}
One location
Requires the locations:read scope.
GET /v1/positions
Job positions
Job positions ("Barista"), not access roles. Paginated. Take next_cursor from the response verbatim and pass it as cursor. Never build a cursor yourself and never assume it is a date or an id.
Requires the positions:read scope.
| Query parameter | Type | Required | Notes |
|---|---|---|---|
updated_since | string | ||
sort | string | ||
limit | integer | ||
cursor | string |
GET /v1/positions/{id}
One job position
Requires the positions:read scope.
GET /v1/employees
People
Contact details (phone, email, date of birth, personnel number) are returned only for keys holding employees:read.pii; without it the fields are absent from the response entirely. To match people against your own system use external_id, which needs no such scope. Paginated. Take next_cursor from the response verbatim and pass it as cursor. Never build a cursor yourself and never assume it is a date or an id.
Requires the employees:read scope.
| Query parameter | Type | Required | Notes |
|---|---|---|---|
location_id | string | ||
position_id | string | ||
status | active dismissed | ||
is_intern | true false | ||
q | string | ||
updated_since | string | ||
sort | string | ||
limit | integer | ||
cursor | string |
GET /v1/employees/{id}
One person
Requires the employees:read scope.
DELETE /v1/employees/{id}
Delete a card created by mistake
Only a card without history: no past published shifts, clock-ins, payouts, bonuses or deductions, closed pay runs or approved time off. Otherwise 409 employee_has_history, and the answer is to dismiss the person: deleting would erase money and records. Future shifts of the card go with it. No If-Match: a card has no version, and the history rule is what protects it. Retrying a delete that already succeeded answers 404. Only for people with an ordinary access role: not the owner or co-owners, and not anyone whose role lets them manage people, the schedule or money, or open the admin panel (403 employee_protected). Those are handled in the product. The key also stops working for this when the person who issued it is no longer an owner or co-owner (403 key_issuer_inactive). AI assistants: say exactly who this is about and wait for confirmation.
Requires the employees:dismiss scope.
GET /v1/employees/by-external-id/{external_id}
One person by your own id
Answers with the person or 404, the shape a daily sync actually needs.
Requires the employees:read scope.
PUT /v1/employees/by-external-id/{external_id}
Create or update a person by your own id
The shape a daily HR sync needs: your id in the path, the fields you know in the body. Creates the person when the id is new (201) and updates them when it is not (200). Fields you do not send stay as they are, so an HR system never wipes what it does not know about. Dismissal is not a field here: it cancels future shifts, closes requests and fixes a date payroll depends on, so it has its own endpoint, dismissEmployee.
Requires the employees:write scope.
Fields you do not send are left as they are. Dismissal, invite codes and pay are not fields: dismissal and invite codes have endpoints of their own, pay is set in the product.
| Body field | Type | Required | Notes |
|---|---|---|---|
name | string | ||
hire_date | string | ||
personnel_no | string | ||
phone | string | International format: + and country code, 7 to 15 digits; spaces, brackets and dashes allowed. Empty string or null clears it | |
birth_date | string | ||
position_id | string | ||
primary_location_id | string | ||
location_ids | array of string | ||
all_locations | boolean | ||
in_schedule | boolean | ||
is_intern | boolean | Create the person as an intern. Only when creating: needs the internships:write scope and the Internships module on. On an existing person the same value is accepted and ignored; changing it goes through markEmployeeIntern and hireIntern |
GET /v1/shifts
Scheduled shifts
Every shift carries both readings of its time: date + start_time/end_time are the wall clock a person sees in the schedule, start_at/end_at are the same moments in UTC. The from/to range is required and capped at 366 days. This is what keeps every query on an index; longer periods are fetched year by year. Paginated. Take next_cursor from the response verbatim and pass it as cursor. Never build a cursor yourself and never assume it is a date or an id.
Requires the shifts:read scope.
| Query parameter | Type | Required | Notes |
|---|---|---|---|
from | string | yes | |
to | string | yes | |
location_id | string | ||
employee_id | string | ||
position_id | string | ||
status | draft published confirmed | ||
open | true false | ||
updated_since | string | ||
sort | string | ||
limit | integer | ||
cursor | string |
POST /v1/shifts
Create a shift
A shift with employee_id is somebody's working day; without it, it is an unfilled slot, and with open: true the slot is offered to the team. A published shift reaches the person the same second, exactly as if a manager had put it there: that is deliberate, because for them it is simply their schedule. Publishing needs the shifts:publish scope ON TOP of shifts:write. Without it a key can only leave drafts, which nobody on the team sees until a person publishes them.
Requires the shifts:write scope, and shifts:publish on top of it to publish.
| Required header | Type | Required | Notes |
|---|---|---|---|
Idempotency-Key | string | yes | Required. Any unique string, for example a UUID. Reuse the SAME value when you retry after a network failure: the answer comes back from the first attempt and nothing is created twice. The same key with a different body is rejected. |
A new shift. Without status it is a draft, and a draft is visible to nobody but the people who build the schedule.
| Body field | Type | Required | Notes |
|---|---|---|---|
date | string | yes | |
start_time | string | yes | |
end_time | string | yes | |
location_id | string | yes | |
employee_id | string | ||
position_id | string | ||
status | draft published | ||
label | string | ||
open | boolean | ||
is_substitution | boolean |
GET /v1/shifts/{id}
One shift
Requires the shifts:read scope.
PATCH /v1/shifts/{id}
Edit a shift
Only the fields you send change. employee_id: null frees the slot. Moving or retiming a published shift notifies the person, because a silent change is how people show up at the old hour. Publishing needs the shifts:publish scope ON TOP of shifts:write. Without it a key can only leave drafts, which nobody on the team sees until a person publishes them.
Requires the shifts:write scope, and shifts:publish on top of it to publish.
| Required header | Type | Required | Notes |
|---|---|---|---|
If-Match | string | yes | Required. The version you last read, for example 7 (a quoted ETag works too). It fails with 412 when somebody edited the object meanwhile, which is what stops an integration from silently overwriting a manager. Send * to edit whatever version is current. |
Only the fields you send change. Send employee_id: null to free the slot.
| Body field | Type | Required | Notes |
|---|---|---|---|
date | string | ||
start_time | string | ||
end_time | string | ||
location_id | string | ||
employee_id | string | ||
position_id | string | ||
status | draft published | ||
label | string | ||
open | boolean | ||
is_substitution | boolean |
DELETE /v1/shifts/{id}
Remove a shift
The person is told their shift is gone, the same as when a manager removes it. Retrying a delete that already succeeded answers 404: the shift is gone, which is what you asked for.
Requires the shifts:write scope.
| Required header | Type | Required | Notes |
|---|---|---|---|
If-Match | string | yes | Required. The version you last read, for example 7 (a quoted ETag works too). It fails with 412 when somebody edited the object meanwhile, which is what stops an integration from silently overwriting a manager. Send * to edit whatever version is current. |
POST /v1/shifts/batch
Create up to 500 shifts
PARTIAL SUCCESS. 200 means the report is ready, not that everything worked: every row you sent gets a line, with either the shift or the reason it was refused. A month of schedule almost always has a few clashes, and dropping the whole month over them would be worse than useless. Publishing needs the shifts:publish scope ON TOP of shifts:write. Without it a key can only leave drafts, which nobody on the team sees until a person publishes them.
Requires the shifts:write scope, and shifts:publish on top of it to publish.
| Required header | Type | Required | Notes |
|---|---|---|---|
Idempotency-Key | string | yes | Required. Any unique string, for example a UUID. Reuse the SAME value when you retry after a network failure: the answer comes back from the first attempt and nothing is created twice. The same key with a different body is rejected. |
Up to 500 shifts in one request.
| Body field | Type | Required | Notes |
|---|---|---|---|
shifts | array of ShiftCreate | yes |
POST /v1/employees/{id}/dismiss
Dismiss a person
The same dismissal as in the product, not a status field: shifts after today become unfilled slots (or are removed with future_shifts: delete), live swap and substitution requests are cancelled, a vacation already under way ends today and later ones are cancelled, mentoring of interns stops, invite codes die and the person's app account is disconnected from the organization. Past shifts and money stay. Undo with restoreEmployee. Only for people with an ordinary access role: not the owner or co-owners, and not anyone whose role lets them manage people, the schedule or money, or open the admin panel (403 employee_protected). Those are handled in the product. The key also stops working for this when the person who issued it is no longer an owner or co-owner (403 key_issuer_inactive). AI assistants: say exactly who this is about and wait for confirmation.
Requires the employees:dismiss scope.
| Required header | Type | Required | Notes |
|---|---|---|---|
Idempotency-Key | string | yes | Required. Any unique string, for example a UUID. Reuse the SAME value when you retry after a network failure: the answer comes back from the first attempt and nothing is created twice. The same key with a different body is rejected. |
| Body field | Type | Required | Notes |
|---|---|---|---|
future_shifts | open delete | What happens to their shifts after today: open (default) leaves them as unfilled slots, delete removes them | |
reason_code | own_request probation discipline layoff other | Why, as a code: own_request, probation, discipline, layoff, other. Counted in the product statistics |
POST /v1/employees/{id}/restore
Bring a dismissed person back
The card becomes active again with its history. Shifts that were released at dismissal stay where they are, and the account is not reattached: the person joins again with a new invite code. Only for people with an ordinary access role: not the owner or co-owners, and not anyone whose role lets them manage people, the schedule or money, or open the admin panel (403 employee_protected). Those are handled in the product. The key also stops working for this when the person who issued it is no longer an owner or co-owner (403 key_issuer_inactive). AI assistants: say exactly who this is about and wait for confirmation.
Requires the employees:dismiss scope.
| Required header | Type | Required | Notes |
|---|---|---|---|
Idempotency-Key | string | yes | Required. Any unique string, for example a UUID. Reuse the SAME value when you retry after a network failure: the answer comes back from the first attempt and nothing is created twice. The same key with a different body is rejected. |
POST /v1/employees/{id}/invite-code
Invite code for the app
Returns the live code of this card or issues a new one (7 days). Whoever enters the code in the InShift app joins under this card with its access role, so treat it like a password and hand it to that person only. We send no email: deliver code or join_url yourself. Not for a dismissed card (409 employee_dismissed) or one that already has an account (409 employee_has_account). No Idempotency-Key: a retry returns the same live code. Only for people with an ordinary access role: not the owner or co-owners, and not anyone whose role lets them manage people, the schedule or money, or open the admin panel (403 employee_protected). Those are handled in the product. The key also stops working for this when the person who issued it is no longer an owner or co-owner (403 key_issuer_inactive). AI assistants: say exactly who this is about and wait for confirmation.
Requires the employees:invite scope.
POST /v1/employees/{id}/invite-code/reissue
Replace the invite code
A new 7-day code instead of the live one, which stops working at once. For a code that went to the wrong place or expired. No Idempotency-Key: if the answer is lost, retrying gives a newer code, and the one you never received is dead. Only for people with an ordinary access role: not the owner or co-owners, and not anyone whose role lets them manage people, the schedule or money, or open the admin panel (403 employee_protected). Those are handled in the product. The key also stops working for this when the person who issued it is no longer an owner or co-owner (403 key_issuer_inactive). AI assistants: say exactly who this is about and wait for confirmation.
Requires the employees:invite scope.
POST /v1/employees/{id}/intern
Make a person an intern
The same mark as in the product: from the start of today they are an intern, paid the intern rate of their position instead of staff pay, work alongside a mentor and cannot swap, give away or take shifts; their live swap and open-shift requests are closed. To create a new person as an intern, send is_intern: true to upsertEmployeeByExternalId. Needs the Internships module on (403 module_off otherwise). The mentor on a shift and intern pay are set in the product. Only for people with an ordinary access role: not the owner or co-owners, and not anyone whose role lets them manage people, the schedule or money, or open the admin panel (403 employee_protected). Those are handled in the product. The key also stops working for this when the person who issued it is no longer an owner or co-owner (403 key_issuer_inactive). AI assistants: say exactly who this is about and wait for confirmation.
Requires the internships:write scope.
| Required header | Type | Required | Notes |
|---|---|---|---|
Idempotency-Key | string | yes | Required. Any unique string, for example a UUID. Reuse the SAME value when you retry after a network failure: the answer comes back from the first attempt and nothing is created twice. The same key with a different body is rejected. |
POST /v1/employees/{id}/intern/cancel
Undo an intern mark set by mistake
Removes the mark as if it was never set, only while the intern has no shifts since the internship started (409 intern_has_shifts otherwise: hire them instead). Requests closed by the mark do not come back. Needs the Internships module on (403 module_off otherwise). The mentor on a shift and intern pay are set in the product. Only for people with an ordinary access role: not the owner or co-owners, and not anyone whose role lets them manage people, the schedule or money, or open the admin panel (403 employee_protected). Those are handled in the product. The key also stops working for this when the person who issued it is no longer an owner or co-owner (403 key_issuer_inactive). AI assistants: say exactly who this is about and wait for confirmation.
Requires the internships:write scope.
| Required header | Type | Required | Notes |
|---|---|---|---|
Idempotency-Key | string | yes | Required. Any unique string, for example a UUID. Reuse the SAME value when you retry after a network failure: the answer comes back from the first attempt and nothing is created twice. The same key with a different body is rejected. |
POST /v1/employees/{id}/hire
Move an intern to the staff
From at (wall clock in the company time zone; omit for now) they are staff: their shifts from then on are paid as staff and mentors are taken off them. A past moment rewrites those shifts, but never ones already paid to the intern or their mentor (409 intern_paid_after): a key does not rewrite paid money. A future moment, up to 30 days ahead, is planned and happens by itself; cancelInternHire drops the plan. Needs the Internships module on (403 module_off otherwise). The mentor on a shift and intern pay are set in the product. Only for people with an ordinary access role: not the owner or co-owners, and not anyone whose role lets them manage people, the schedule or money, or open the admin panel (403 employee_protected). Those are handled in the product. The key also stops working for this when the person who issued it is no longer an owner or co-owner (403 key_issuer_inactive). AI assistants: say exactly who this is about and wait for confirmation.
Requires the internships:write scope.
| Required header | Type | Required | Notes |
|---|---|---|---|
Idempotency-Key | string | yes | Required. Any unique string, for example a UUID. Reuse the SAME value when you retry after a network failure: the answer comes back from the first attempt and nothing is created twice. The same key with a different body is rejected. |
| Body field | Type | Required | Notes |
|---|---|---|---|
at | string | From which moment they are staff, wall clock YYYY-MM-DDTHH:MM in the company time zone. Omit for now. In the past it turns their shifts from then on into staff shifts; in the future (up to 30 days) it is planned and happens by itself |
DELETE /v1/employees/{id}/hire
Drop a planned hire
The intern stays an intern and hire_planned_at becomes null. Nothing planned: the same answer, the person as they are. No If-Match: the plan has no version, and only the plan goes. Needs the Internships module on (403 module_off otherwise). The mentor on a shift and intern pay are set in the product. Only for people with an ordinary access role: not the owner or co-owners, and not anyone whose role lets them manage people, the schedule or money, or open the admin panel (403 employee_protected). Those are handled in the product. The key also stops working for this when the person who issued it is no longer an owner or co-owner (403 key_issuer_inactive). AI assistants: say exactly who this is about and wait for confirmation.
Requires the internships:write scope.
GET /v1/absences
Vacation, sick leave, days off
These are requests, so they carry a status. Counting attendance almost always means status=approved, and you have to say so: no status filter is applied by default. The range means OVERLAP, not containment: a vacation from Dec 25 to Jan 10 appears in both months. The from/to range is required and capped at 366 days. This is what keeps every query on an index; longer periods are fetched year by year.
Requires the absences:read scope.
| Query parameter | Type | Required | Notes |
|---|---|---|---|
from | string | yes | |
to | string | yes | |
employee_id | string | ||
type | vacation sick personal unpaid | ||
status | pending approved rejected cancelled | ||
updated_since | string | ||
sort | string | ||
limit | integer | ||
cursor | string |
GET /v1/attendance
Raw clock-in records
The fact, not the timesheet. An open session has worked_minutes: null, not 0: it is still running, and a zero would read as "did nothing". The from/to range is required and capped at 366 days. This is what keeps every query on an index; longer periods are fetched year by year.
Requires the attendance:read scope.
| Query parameter | Type | Required | Notes |
|---|---|---|---|
from | string | yes | |
to | string | yes | |
employee_id | string | ||
location_id | string | ||
state | open closed | ||
updated_since | string | ||
sort | string | ||
limit | integer | ||
cursor | string |
GET /v1/timesheets
Timesheet: person × day × location
Plan and fact reconciled. actual: null means the attendance module is off or the person never clocked in. It does NOT mean zero hours, and reading it as zero is how integrations underpay people. hours is the number this organization pays by today (the plan); hours_source says where it came from. No money anywhere in v1: no rates, no amounts. The from/to range is required and capped at 366 days. This is what keeps every query on an index; longer periods are fetched year by year.
Requires the timesheets:read scope.
| Query parameter | Type | Required | Notes |
|---|---|---|---|
from | string | yes | |
to | string | yes | |
employee_id | string | ||
location_id | string | ||
sort | string | ||
limit | integer | ||
cursor | string |
GET /v1/events
Change feed
What changed since last time, deletions included. A timestamp filter can never report a deletion, because the row is gone. Events carry no body: fetch the current state from the resource. Kept for 90 days. Paginated. Take next_cursor from the response verbatim and pass it as cursor. Never build a cursor yourself and never assume it is a date or an id.
Requires the events:read scope.
| Query parameter | Type | Required | Notes |
|---|---|---|---|
type | string | ||
limit | integer | ||
cursor | string |
GET /v1/payroll
Payroll: one row per person for a period
The same engine and the same numbers as the payroll screen in the admin panel, with the money explained: base, premium and a split of every supplement by the event that produced it (night, holiday, overtime, per-shift bonus, away shift, N-th day of the month, sales percent, double shift). Three different numbers live in a row and must not be mixed: accrued is for the days of the window through today, paid is cash that left the till inside the window by payment date, and balance is what the organization owes right now, cumulative from the payroll start date and independent of the window. A person with unpriced_shifts above zero worked days the product cannot price yet: read their money as incomplete, not as small. For a key narrowed to locations, accrued and balance include all shifts of the key's people, also at other locations. Every amount is a string with two decimals in the company currency: never parse it as a float. The from/to range is required and capped at 366 days. This is what keeps every query on an index; longer periods are fetched year by year.
Requires the payroll:read scope.
| Query parameter | Type | Required | Notes |
|---|---|---|---|
from | string | yes | |
to | string | yes | |
employee_id | string | ||
limit | integer | ||
cursor | string |
GET /v1/payroll/days
Payroll: one row per person per day
How each person's money was made up, day by day: hours by bucket, the rate that applied, the amount with its base/premium split and every supplement named. Only days with paid shifts (published or confirmed) through today; drafts and future days are the schedule, not money. Sums of days equal the summary accrued before its final rounding. personal_sales and location_sales come from the connected till and are null when unknown, never zero. Sorted by date, then employee. For a key narrowed to locations, the days include all shifts of the key's people, also at other locations. Every amount is a string with two decimals in the company currency: never parse it as a float. The from/to range is required and capped at 366 days. This is what keeps every query on an index; longer periods are fetched year by year.
Requires the payroll:read scope.
| Query parameter | Type | Required | Notes |
|---|---|---|---|
from | string | yes | |
to | string | yes | |
employee_id | string | ||
limit | integer | ||
cursor | string |
GET /v1/payments
Payouts to people
Money that was handed over, by payment date. Advances, settlements and other are cash; closing is a period closed as "already paid earlier" and is not a cash movement, so exclude it when reconciling the till. Deleted payments are never returned. The from/to range is required and capped at 366 days. This is what keeps every query on an index; longer periods are fetched year by year.
Requires the payroll:read scope.
| Query parameter | Type | Required | Notes |
|---|---|---|---|
from | string | yes | |
to | string | yes | |
employee_id | string | ||
kind | advance settlement other closing | ||
sort | string | ||
limit | integer | ||
cursor | string |
POST /v1/payments
Record an advance or another payout
Money handed over to a person: advance or other. Settlements and closing a period stay in the product, where the person doing them sees the amounts due. Retrying with the same Idempotency-Key never pays twice. A key records money for the people whose pay it can read, and changes or removes only what it recorded itself (403 record_not_own otherwise). It stops working when the person who issued it is no longer an owner or co-owner (403 key_issuer_inactive). A day before the payroll start day is refused: it would not count. The owner sees each entry in the product signed with the key name. AI assistants: say the person, the amount and the day, and wait for confirmation. Every amount is a string with two decimals in the company currency: never parse it as a float.
Requires the payroll:write scope.
| Required header | Type | Required | Notes |
|---|---|---|---|
Idempotency-Key | string | yes | Required. Any unique string, for example a UUID. Reuse the SAME value when you retry after a network failure: the answer comes back from the first attempt and nothing is created twice. The same key with a different body is rejected. |
| Body field | Type | Required | Notes |
|---|---|---|---|
employee_id | string | yes | |
amount | string | yes | Positive, up to 99999999, at most two decimals: "500.00" (a number is accepted too) |
kind | advance other | yes | advance or other. Settlements and closing a period are done in the product |
paid_at | string | yes | The day the money was handed over, YYYY-MM-DD. Not before the payroll start day |
GET /v1/payroll/adjustments
Bonuses and deductions
One-off amounts that change what is accrued, not what is paid. signed_amount already carries the sign. The free-text reason is not exposed. The from/to range is required and capped at 366 days. This is what keeps every query on an index; longer periods are fetched year by year.
Requires the payroll:read scope.
| Query parameter | Type | Required | Notes |
|---|---|---|---|
from | string | yes | |
to | string | yes | |
employee_id | string | ||
kind | bonus deduction opening | ||
sort | string | ||
limit | integer | ||
cursor | string |
POST /v1/payroll/adjustments
Record a bonus or a deduction
Changes what is accrued, not what is paid. reason is shown to the person in the app and is never returned. Only the owner changes their own pay: a key issued by a co-owner refuses their own card (403 own_pay_change). Retrying with the same Idempotency-Key never books it twice. A key records money for the people whose pay it can read, and changes or removes only what it recorded itself (403 record_not_own otherwise). It stops working when the person who issued it is no longer an owner or co-owner (403 key_issuer_inactive). A day before the payroll start day is refused: it would not count. The owner sees each entry in the product signed with the key name. AI assistants: say the person, the amount and the day, and wait for confirmation. Every amount is a string with two decimals in the company currency: never parse it as a float.
Requires the payroll:write scope.
| Required header | Type | Required | Notes |
|---|---|---|---|
Idempotency-Key | string | yes | Required. Any unique string, for example a UUID. Reuse the SAME value when you retry after a network failure: the answer comes back from the first attempt and nothing is created twice. The same key with a different body is rejected. |
| Body field | Type | Required | Notes |
|---|---|---|---|
employee_id | string | yes | |
kind | bonus deduction | yes | bonus adds to what is accrued, deduction takes from it |
amount | string | yes | Positive, up to 99999999, at most two decimals: "300.00"; the sign comes from kind |
date | string | yes | The day it is booked on, YYYY-MM-DD. Not before the payroll start day |
reason | string | yes | Why, as the person will read it in the app: "Sales plan for October". Write-only: never returned |
PATCH /v1/payments/{id}
Change a payout this key recorded
Amount, kind or day; the person never changes (remove the payment and record a new one). A key records money for the people whose pay it can read, and changes or removes only what it recorded itself (403 record_not_own otherwise). It stops working when the person who issued it is no longer an owner or co-owner (403 key_issuer_inactive). A day before the payroll start day is refused: it would not count. The owner sees each entry in the product signed with the key name. AI assistants: say the person, the amount and the day, and wait for confirmation.
Requires the payroll:write scope.
| Required header | Type | Required | Notes |
|---|---|---|---|
If-Match | string | yes | Required. The version you last read, for example 7 (a quoted ETag works too). It fails with 412 when somebody edited the object meanwhile, which is what stops an integration from silently overwriting a manager. Send * to edit whatever version is current. |
| Body field | Type | Required | Notes |
|---|---|---|---|
amount | string | ||
kind | advance other | ||
paid_at | string |
DELETE /v1/payments/{id}
Remove a payout this key recorded
It stops counting as paid and stays crossed out in the product journal. Retrying a delete that already succeeded answers 404. A key records money for the people whose pay it can read, and changes or removes only what it recorded itself (403 record_not_own otherwise). It stops working when the person who issued it is no longer an owner or co-owner (403 key_issuer_inactive). A day before the payroll start day is refused: it would not count. The owner sees each entry in the product signed with the key name. AI assistants: say the person, the amount and the day, and wait for confirmation.
Requires the payroll:write scope.
| Required header | Type | Required | Notes |
|---|---|---|---|
If-Match | string | yes | Required. The version you last read, for example 7 (a quoted ETag works too). It fails with 412 when somebody edited the object meanwhile, which is what stops an integration from silently overwriting a manager. Send * to edit whatever version is current. |
PATCH /v1/payroll/adjustments/{id}
Change a bonus or deduction this key recorded
Kind, amount, day or reason; the person never changes. A key records money for the people whose pay it can read, and changes or removes only what it recorded itself (403 record_not_own otherwise). It stops working when the person who issued it is no longer an owner or co-owner (403 key_issuer_inactive). A day before the payroll start day is refused: it would not count. The owner sees each entry in the product signed with the key name. AI assistants: say the person, the amount and the day, and wait for confirmation.
Requires the payroll:write scope.
| Required header | Type | Required | Notes |
|---|---|---|---|
If-Match | string | yes | Required. The version you last read, for example 7 (a quoted ETag works too). It fails with 412 when somebody edited the object meanwhile, which is what stops an integration from silently overwriting a manager. Send * to edit whatever version is current. |
| Body field | Type | Required | Notes |
|---|---|---|---|
kind | bonus deduction | ||
amount | string | ||
date | string | ||
reason | string | Why, as the person will read it in the app: "Sales plan for October". Write-only: never returned |
DELETE /v1/payroll/adjustments/{id}
Remove a bonus or deduction this key recorded
It stops counting and stays crossed out in the product. Retrying answers 404. A key records money for the people whose pay it can read, and changes or removes only what it recorded itself (403 record_not_own otherwise). It stops working when the person who issued it is no longer an owner or co-owner (403 key_issuer_inactive). A day before the payroll start day is refused: it would not count. The owner sees each entry in the product signed with the key name. AI assistants: say the person, the amount and the day, and wait for confirmation.
Requires the payroll:write scope.
| Required header | Type | Required | Notes |
|---|---|---|---|
If-Match | string | yes | Required. The version you last read, for example 7 (a quoted ETag works too). It fails with 412 when somebody edited the object meanwhile, which is what stops an integration from silently overwriting a manager. Send * to edit whatever version is current. |
GET /v1/payroll/runs
Pay runs: periods closed for everybody at once
Each run freezes what was due and what was paid per person at the moment of confirmation, and is never recomputed. Cancelled runs stay in the list with status: cancelled. Newest first. Paginated. Take next_cursor from the response verbatim and pass it as cursor. Never build a cursor yourself and never assume it is a date or an id.
Requires the payroll:read scope.
| Query parameter | Type | Required | Notes |
|---|---|---|---|
sort | string | ||
limit | integer | ||
cursor | string |
GET /v1/payroll/runs/{id}
One pay run with its rows
Requires the payroll:read scope.
GET /v1/payroll/rules
Supplement rules, with history
The configuration behind the supplements keys: which events this organization pays for, how much, at which level (organization, location, position, employee) and from which date. The vocabulary of types is fixed by the product; a rule edited today does not rewrite last month. Not paginated: an organization has dozens of rules at most. Personal (employee-level) rules follow the key: only for people within its locations and whose pay its issuer may see.
Requires the payroll:read scope.
GET /v1/payroll/pay-terms
Pay terms, with history
How people are paid: per hour, per shift, per month, by the price of each shift, or not at all (an owner). The narrower level wins: employee over position over organization. Not paginated. Personal (employee-level) terms follow the key: only for people within its locations and whose pay its issuer may see.
Requires the payroll:read scope.