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:

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

Endpoints

EndpointWhat it is
GET /v1/companyThe organization: time zone, enabled modules
GET /v1/locationsLocations
GET /v1/positionsJob positions ("Barista"), not access roles
GET /v1/employeesPeople
GET /v1/employees/by-external-id/{id}One person by the id they have in *your* system
GET /v1/shiftsThe schedule
GET /v1/absencesVacation, sick leave, days off
GET /v1/attendanceRaw clock-in records
GET /v1/timesheetsHours per person per day: plan and fact
GET /v1/eventsWhat changed since last time
GET /v1/payrollMoney per person for a period, every supplement explained
GET /v1/payroll/daysThe same, per person per day
GET /v1/paymentsPayouts to people
GET /v1/payroll/adjustmentsBonuses and deductions
GET /v1/payroll/runsPay runs: periods closed for everybody at once
GET /v1/payroll/rulesSupplement rules, with history
GET /v1/payroll/pay-termsPay terms, with history

And what writes:

EndpointWhat it does
POST /v1/shiftsCreate one shift
POST /v1/shifts/batchCreate 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

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:

  1. 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 403 with code: "missing_scope" naming the missing one.
  2. 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 404 rather than 403, 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.

ScopeGives
company:read/v1/company
locations:read/v1/locations
positions:read/v1/positions
employees:read/v1/employees without contact details
employees:read.piiphone, 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:writecreate, edit and remove shifts, as drafts
shifts:publishshow shifts to the team, ON TOP of shifts:write
employees:writecreate and update people by your own id
employees:dismissdismiss, restore and delete people with an ordinary role
employees:inviteinvite codes for the app, for people with an ordinary role
internships:writemark 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:writerecord 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:

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=:

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

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"
}

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


Limits

LimitValueWhy
Requests600 per minute per keyA runaway loop should hit a wall, not the database
limit100 rows, 50 by default
Date range366 daysKeeps every query on an index
Live keys20 per organizationPast that, the list stops being one where you can see what to remove
Change feed history90 daysLonger than that and a full re-read is the right answer anyway
Shifts per batch500One statement, one report, one transaction
Idempotency keyskept 24 hoursAfter 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


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

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

A practical nightly job

  1. GET /v1/company: time zone, and whether attendance is on.
  2. GET /v1/events from your saved cursor. Collect changed employee and shift ids.
  3. Fetch what changed.
  4. 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:

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.

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.

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.

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:

FieldQuestionDepends on the window
accruedwhat was earned for the days of the window, through todayyes
paidwhat cash left the till inside the window, by payment dateyes
balance.duewhat the organization owes this person right nowno

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:

KeyWhat it is
nightthe extra percent on hours inside the night window
holidaythe part above the base rate on holiday hours
overtime_dailythe part above the base rate past the daily threshold
shift_bonusa flat sum per worked shift
overtime_flata flat sum for overtime, per day or per hour past the threshold
away_shifta flat sum for a shift at a location that is not the person's own
month_day_bonusa flat sum by the ordinal number of the worked day in the month
sales_percenta percent of personal sales or of the location till
double_shifta flat sum for a day with two or more shifts
senioritya 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":

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":

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:

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


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:

  1. Use the RAW body, before JSON parsing. Re-serialising changes whitespace and key order, and the signature will never match again.
  2. Compare in constant time (hmac.compare_digest), not with ==.
  3. 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

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

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:

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

We will, without warning

What that means for your client

  1. 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.
  2. Do not depend on field order or on JSON formatting.
  3. Handle unknown event types by skipping them, not by failing.
  4. Do not parse message. Branch on code.
  5. 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.
  6. Take next_cursor verbatim. 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

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


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")"'

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:

Make / n8n / Zapier

There is no first-party app yet. Use a generic HTTP request block:

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

  1. actual: null in 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.
  2. email: null on a person means they have no account, not that the email field is blank. Whether somebody still needs an invite code is has_account: false, which needs no personal-data scope.
  3. /v1/absences returns every status unless you ask for one. Counting attendance means status=approved.
  4. position here is a job position ("Barista"), not an access role. Access roles are not exposed at all.
  5. A shift with employee_id: null is an unfilled slot, not a data error. With open: true it 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

FieldTypeNotes
errorobject

Company

FieldTypeNotes
idstring
namestring
timezonestringThe organization time zone. Every wall-clock time in this API is read in it
currencystringThe currency payroll is kept in. v1 returns no amounts anywhere
created_atstringAn instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z
featuresobject

Location

FieldTypeNotes
idstring
namestring
addressstring
external_idstring
groupstring
statusactive archivedLocations are archived, never deleted: past schedules must stay readable
created_atstringAn instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z
updated_atstringAn instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z

Position

A job position ("Barista"), not an access role

FieldTypeNotes
idstring
namestring
external_idstring
groupstring
created_atstringAn instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z
updated_atstringAn instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z

Employee

FieldTypeNotes
idstring
external_idstring
namestring
statusactive dismissed
in_schedulebooleanThis person is scheduled. false means they are on the team but never appear in the schedule
hire_datestringA calendar date, YYYY-MM-DD
dismissed_atstringAn instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z
position_idstring
position_idsarray of string
primary_location_idstring
location_idsarray of string
all_locationsbooleanWorks at every location. location_ids then lists only explicit attachments and limits nothing
has_accountbooleanThe 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_internbooleanAn intern: works alongside a mentor and is paid the intern rate of their position. Always false while the Internships module is off
hire_planned_atstringWhen an intern is planned to join the staff; null when nothing is planned. It happens by itself at that moment
created_atstringAn instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z
updated_atstringAn instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z
personnel_nostring
phonestring
birth_datestringA calendar date, YYYY-MM-DD
emailstringThe ACCOUNT email. null means the person has no account yet, NOT that the email is blank

Shift

FieldTypeNotes
idstring
datestringA calendar date, YYYY-MM-DD
start_timestringWall-clock time as the schedule shows it, HH:MM:SS
end_timestring
start_atstringAn instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z
end_atstringFor an overnight shift this is already the next day
hoursstringHours as a string with two decimals. A string, so no float rounding is introduced on the way
employee_idstringnull means an unfilled slot
employee_namestringThe name of the person on the shift, so a list of shifts reads without a lookup per row. null for an unfilled slot
location_idstring
location_namestring
position_idstringThe 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.
statusdraft published confirmed
openbooleanAn unfilled slot offered to the team: people apply, a manager picks
is_substitutionboolean
labelstring
versionintegerOptimistic-locking token. Will be used with If-Match once writes arrive
editedbooleanThe shift was edited after it was created
created_atstringAn instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z
updated_atstringAn instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z

ShiftBatchResult

FieldTypeNotes
createdinteger
failedinteger
resultsarray of object

Deleted

FieldTypeNotes
idstring
deletedtrue

EmployeeDismissalResult

FieldTypeNotes
employeeEmployee
future_shiftsintegerShifts 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_requestsintegerLive swap and substitution requests of this person that were cancelled

InviteCode

FieldTypeNotes
employee_idstring
codestring6 characters. Whoever enters it in the InShift app joins under this card
expires_atstringAfter this moment the app no longer accepts the code; 7 days from issue
join_urlstringA 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

FieldTypeNotes
employeeEmployee
atstringThe moment they are staff from, wall clock in the company time zone, YYYY-MM-DDTHH:MM
scheduledbooleantrue: the moment is in the future and the hire happens by itself then; false: done now
pay_knownbooleanStaff 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.

FieldTypeNotes
idstring
employee_idstring
typevacation sick personal unpaid
statuspending approved rejected cancelledNo default filter: when counting attendance ask for status=approved explicitly
start_datestringA calendar date, YYYY-MM-DD
end_datestringA calendar date, YYYY-MM-DD
daysintegerCalendar days, both ends inclusive
reviewed_atstringAn instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z
created_atstringAn instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z
updated_atstringAn instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z

AttendanceSession

FieldTypeNotes
idstring
employee_idstring
location_idstring
shift_idstring
datestringA calendar date, YYYY-MM-DD
opened_atstringAn instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z
closed_atstringAn instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z
break_minutesinteger
worked_minutesnumbernull means the session is still open and has no duration yet. It is not zero
hoursstringHours as a string with two decimals. A string, so no float rounding is introduced on the way
closed_byscan auto manual
created_atstringAn instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z
updated_atstringAn instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z

TimesheetEntry

FieldTypeNotes
idstringshift_<uuid> for a scheduled row, att_<uuid> for clock-ins with no shift behind them
datestringA calendar date, YYYY-MM-DD
employee_idstring
employee_external_idstring
personnel_nostringOnly present for keys holding employees:read.pii
location_idstring
position_idstring
scheduledobjectnull means the person worked unplanned: there was no shift that day
actualobjectnull means attendance is off or the person never clocked in. THIS IS NOT ZERO HOURS
hoursstringHours as a string with two decimals. A string, so no float rounding is introduced on the way
hours_sourcescheduled actualWhere the hours field came from
statusdraft published confirmed
shift_idstring
attendance_session_idsarray of string

Event

FieldTypeNotes
idstringevt_<uuid>, stable across requests
typestringentity.action, for example shift.updated
entitystring
actioncreated updated deleted
object_idstringThe id of the changed object. Fetch its current state from the matching resource
occurred_atstringAn instant in UTC, ISO-8601: 2026-09-01T06:00:00.000Z

PayrollSummary

FieldTypeNotes
employee_idstring
employee_external_idstring
personnel_nostringOnly with employees:read.pii
statusactive dismissed
fromstringA calendar date, YYYY-MM-DD
tostringA calendar date, YYYY-MM-DD
accrued_throughstringThe last day money is accrued for: the end of the window or today, whichever is earlier. Future shifts are never money
payPayrollPay
shiftsintegerWorked shifts in the window: published or confirmed, drafts excluded
hoursstringHours as a string with two decimals
night_hoursstringBuckets overlap on purpose: a night hour on a holiday is in both
holiday_hoursstringHours as a string with two decimals
overtime_hoursstringHours as a string with two decimals
unpriced_shiftsintegerShifts in the window with no known price: no pay terms and no rate on that day. Not zero money: unknown money
accruedstringEverything accrued in the window: base plus supplements, rounded to whole currency units like the product screen
basestringaccrued minus premium: hours times the rate, or the shift prices, or the salary share
premiumstringEverything above the base: multipliers and flat supplements together
supplementsPayrollSupplementsMoney 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_daysintegerDays when the sales percent outgrew the rate and replaced it, so base and sales_percent are not additive on those days
adjustmentsstringBonuses minus deductions dated inside the window
paidstringCash paid out inside the window by payment date. Closings ("already paid earlier") are not cash and are excluded
balancePayrollBalanceThe running account of one person: what the organization owes them right now. Does not depend on the window

PayrollPay

FieldTypeNotes
kindhourly per_shift monthly shift_rate none sales_onlyHow 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
amountstringThe amount behind kind. null when there are no pay terms
hourly_ratestringThe 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_fromstringThe 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

FieldTypeNotes
nightstringNight premium: the extra percent on the hours inside the night window
holidaystringHoliday multiplier: the part above the base rate on holiday hours
overtime_dailystringDaily overtime multiplier: the part above the base rate past the daily threshold
shift_bonusstringFlat bonus per worked shift
overtime_flatstringFlat bonus for overtime: per day or per hour past the threshold
away_shiftstringFlat bonus for a shift at a location that is not the person own
month_day_bonusstringFlat bonus by the ordinal number of the worked day in the month (the 22nd day, the 26th...)
sales_percentstringPercent of personal sales or of the location till
double_shiftstringFlat bonus for a day with two or more shifts
mentoringstringPay 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_upstringTop-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
senioritystringSeniority 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

FieldTypeNotes
sincestringThe day this organization started keeping payroll here. Money before it lived elsewhere and is not counted
earnedstringAccrued for shifts from since through today, supplements included
adjustmentsstringBonuses minus deductions from since, no upper bound: a bonus dated tomorrow is already a promise
paidstringPaid out from since, no upper bound, every kind including closings
duestringearned + adjustments - paid. Negative means the person was paid more than they earned
unpriced_shiftsintegerShifts 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

FieldTypeNotes
datestringA calendar date, YYYY-MM-DD
employee_idstring
employee_external_idstring
personnel_nostringOnly with employees:read.pii
hoursstringHours as a string with two decimals
night_hoursstringHours as a string with two decimals
holiday_hoursstringHours as a string with two decimals
overtime_hoursstringHours as a string with two decimals
pay_kindhourly per_shift monthly shift_rate none sales_onlyThe pay term that priced this day. null: legacy hourly rate
hourly_ratestringThe hourly rate in force on this day
amountstringAccrued for the day, with cents. The sum of days equals the summary accrued before its final rounding
basestringMoney as a string with two decimals, in the company currency (see /v1/company). A string on purpose: never parse it as a float
premiumstringMoney as a string with two decimals, in the company currency (see /v1/company). A string on purpose: never parse it as a float
supplementsPayrollSupplementsMoney 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_shiftsinteger
instead_of_ratebooleanThe sales percent replaced the rate on this day: amount is the larger of the two, not their sum
preliminarybooleanSales for this day are still coming from an open till session: the number will change
month_day_numberintegerThe ordinal number of this worked day inside its month, the basis of month_day_bonus. null when no such rule exists
personal_salesstringPersonal 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_salesstringThe 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_thresholdstringThe 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
shiftsarray of PayrollDayShift

PayrollDayShift

FieldTypeNotes
idstring
start_timestringWall-clock time, HH:MM
end_timestring
location_idstring
position_idstring
is_substitutionboolean
pricestringThe 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

FieldTypeNotes
idstring
employee_idstring
employee_external_idstring
amountstringMoney as a string with two decimals, in the company currency (see /v1/company). A string on purpose: never parse it as a float
kindadvance settlement other closingadvance, 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_atstringThe day the money was handed over
run_idstringThe pay run that recorded this payment, when it came from one
versionintegerSend it as If-Match to change or remove the payment
created_by_this_keybooleanRecorded with the key making this request. A key changes and removes only its own payments
created_atstringAn instant in UTC, ISO-8601
updated_atstringAn 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

FieldTypeNotes
idstring
employee_idstring
employee_external_idstring
kindbonus deduction openingopening 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
amountstringAlways positive; the sign comes from kind
signed_amountstringThe same amount with its sign: a deduction is negative
datestringThe day it was booked on: it decides which window and which side of a pay run it falls into
versionintegerSend it as If-Match to change or remove the bonus or deduction
created_by_this_keybooleanRecorded with the key making this request. A key changes and removes only its own bonuses and deductions
created_atstringAn instant in UTC, ISO-8601
updated_atstringAn instant in UTC, ISO-8601

PayrollRun

A pay run: one period closed for everybody at once. Rows are frozen at confirmation and never recomputed

FieldTypeNotes
idstring
period_fromstringA calendar date, YYYY-MM-DD
period_tostringInclusive
paid_atstringA calendar date, YYYY-MM-DD
kindsettlement closingsettlement: cash left the till. closing: the money had been paid before, the run only closes the period
statusconfirmed cancelled
people_countinteger
totalstringMoney as a string with two decimals, in the company currency (see /v1/company). A string on purpose: never parse it as a float
created_atstringAn instant in UTC, ISO-8601
cancelled_atstringAn instant in UTC, ISO-8601
rowsarray of PayrollRunRowOnly on GET /v1/payroll/runs/{id}

PayrollRunRow

FieldTypeNotes
employee_idstring
employee_external_idstring
accruedstringAccrued from the payroll start through period_to, frozen at confirmation
paid_beforestringPaid before this run, over the whole account
duestringaccrued - paid_before at the moment of confirmation
amountstringWhat this run paid. Less than due means a partial payout
settledbooleanA 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

FieldTypeNotes
idstring
typenight holiday overtime_daily shift_bonus overtime_flat away_shift month_day_bonus sales_percent double_shift mentoring seniority
scopeorganization location position employeeWhom it applies to. The narrower always wins the wider: employee over position over location over organization
scope_idstringThe location, position or employee. null for the organization
effective_fromstringRules have history: a rule edited today does not rewrite last month
valuenumbernight, sales_percent and seniority: the percent (20 = +20%). holiday and overtime_daily: the multiplier (2 = double). Others: null
amountstringFlat types: the sum of money. Others: null
amount_unitday hour shiftovertime_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_hoursnumberovertime_daily and overtime_flat: hours per day paid as usual
window_startstringnight only: HH:MM:SS. An end before the start means the window crosses midnight
window_endstring
away_basisprimary anyaway_shift only: which locations count as the person own
day_fromintegermonth_day_bonus only: the first ordinal worked day of the month this step applies to
day_tointegernull means no upper bound
sales_basepersonal locationsales_percent only: personal sales or the whole till
crew_fromintegersales_percent only: the step by the number of people on shift
crew_tointeger
crew_position_idsarray of stringsales_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_ratebooleansales_percent only: pay the rate OR the percent, whichever is larger
sales_thresholdstringsales_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_paystringsales_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_idstringRules 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_fromintegerseniority 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_atstringAn instant in UTC, ISO-8601

PayTerm

How people are paid, with history. The narrower level wins: employee over position over organization

FieldTypeNotes
idstring
scopeorganization position employee
scope_idstring
kindhourly per_shift monthly shift_rate none sales_only
amountstringPer hour, per shift or per month, according to kind. 0.00 for none and sales_only
effective_fromstringA calendar date, YYYY-MM-DD
norm_shiftsintegermonthly only: how many shifts a month count as the full salary. null: the planned shifts of the month
created_atstringAn 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 parameterTypeRequiredNotes
statusactive archived
updated_sincestring
sortstring
limitinteger
cursorstring

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 parameterTypeRequiredNotes
updated_sincestring
sortstring
limitinteger
cursorstring

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 parameterTypeRequiredNotes
location_idstring
position_idstring
statusactive dismissed
is_interntrue false
qstring
updated_sincestring
sortstring
limitinteger
cursorstring

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 fieldTypeRequiredNotes
namestring
hire_datestring
personnel_nostring
phonestringInternational format: + and country code, 7 to 15 digits; spaces, brackets and dashes allowed. Empty string or null clears it
birth_datestring
position_idstring
primary_location_idstring
location_idsarray of string
all_locationsboolean
in_scheduleboolean
is_internbooleanCreate 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 parameterTypeRequiredNotes
fromstringyes
tostringyes
location_idstring
employee_idstring
position_idstring
statusdraft published confirmed
opentrue false
updated_sincestring
sortstring
limitinteger
cursorstring

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 headerTypeRequiredNotes
Idempotency-KeystringyesRequired. 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 fieldTypeRequiredNotes
datestringyes
start_timestringyes
end_timestringyes
location_idstringyes
employee_idstring
position_idstring
statusdraft published
labelstring
openboolean
is_substitutionboolean

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 headerTypeRequiredNotes
If-MatchstringyesRequired. 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 fieldTypeRequiredNotes
datestring
start_timestring
end_timestring
location_idstring
employee_idstring
position_idstring
statusdraft published
labelstring
openboolean
is_substitutionboolean

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 headerTypeRequiredNotes
If-MatchstringyesRequired. 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 headerTypeRequiredNotes
Idempotency-KeystringyesRequired. 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 fieldTypeRequiredNotes
shiftsarray of ShiftCreateyes

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 headerTypeRequiredNotes
Idempotency-KeystringyesRequired. 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 fieldTypeRequiredNotes
future_shiftsopen deleteWhat happens to their shifts after today: open (default) leaves them as unfilled slots, delete removes them
reason_codeown_request probation discipline layoff otherWhy, 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 headerTypeRequiredNotes
Idempotency-KeystringyesRequired. 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 headerTypeRequiredNotes
Idempotency-KeystringyesRequired. 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 headerTypeRequiredNotes
Idempotency-KeystringyesRequired. 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 headerTypeRequiredNotes
Idempotency-KeystringyesRequired. 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 fieldTypeRequiredNotes
atstringFrom 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 parameterTypeRequiredNotes
fromstringyes
tostringyes
employee_idstring
typevacation sick personal unpaid
statuspending approved rejected cancelled
updated_sincestring
sortstring
limitinteger
cursorstring

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 parameterTypeRequiredNotes
fromstringyes
tostringyes
employee_idstring
location_idstring
stateopen closed
updated_sincestring
sortstring
limitinteger
cursorstring

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 parameterTypeRequiredNotes
fromstringyes
tostringyes
employee_idstring
location_idstring
sortstring
limitinteger
cursorstring

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 parameterTypeRequiredNotes
typestring
limitinteger
cursorstring

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 parameterTypeRequiredNotes
fromstringyes
tostringyes
employee_idstring
limitinteger
cursorstring

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 parameterTypeRequiredNotes
fromstringyes
tostringyes
employee_idstring
limitinteger
cursorstring

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 parameterTypeRequiredNotes
fromstringyes
tostringyes
employee_idstring
kindadvance settlement other closing
sortstring
limitinteger
cursorstring

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 headerTypeRequiredNotes
Idempotency-KeystringyesRequired. 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 fieldTypeRequiredNotes
employee_idstringyes
amountstringyesPositive, up to 99999999, at most two decimals: "500.00" (a number is accepted too)
kindadvance otheryesadvance or other. Settlements and closing a period are done in the product
paid_atstringyesThe 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 parameterTypeRequiredNotes
fromstringyes
tostringyes
employee_idstring
kindbonus deduction opening
sortstring
limitinteger
cursorstring

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 headerTypeRequiredNotes
Idempotency-KeystringyesRequired. 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 fieldTypeRequiredNotes
employee_idstringyes
kindbonus deductionyesbonus adds to what is accrued, deduction takes from it
amountstringyesPositive, up to 99999999, at most two decimals: "300.00"; the sign comes from kind
datestringyesThe day it is booked on, YYYY-MM-DD. Not before the payroll start day
reasonstringyesWhy, 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 headerTypeRequiredNotes
If-MatchstringyesRequired. 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 fieldTypeRequiredNotes
amountstring
kindadvance other
paid_atstring

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 headerTypeRequiredNotes
If-MatchstringyesRequired. 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 headerTypeRequiredNotes
If-MatchstringyesRequired. 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 fieldTypeRequiredNotes
kindbonus deduction
amountstring
datestring
reasonstringWhy, 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 headerTypeRequiredNotes
If-MatchstringyesRequired. 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 parameterTypeRequiredNotes
sortstring
limitinteger
cursorstring

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.