What the API does
The API is for your own integrations, such as a payroll or HR system you connect yourself, or a reporting dashboard. There aren’t ready-made connectors to named payroll or HR products: your developer, or your provider, calls the API.
| You can | How | Scope |
|---|---|---|
| Read people, teams and locations | GET /people, /teams, /locations | people:read |
| Read leave types, balances, requests and who’s off | GET /leave-types, /people/{id}/balance, /requests, /time-off | leave:read |
| Book, approve, decline and cancel leave | POST /requests, /requests/{id}/approve, /decline, /cancel | leave:write |
| Read shifts from the published rota | GET /shifts | shifts:read |
| Hear about changes as they happen | Signed webhooks, including rota.published | Set up in Settings |
Shifts are read-only: the API doesn’t create or change shifts. Plan the rota in RotaTiger, or let Auto-fill draft it. To ask questions in plain words instead of writing code, connect an AI assistant.
Before you start
The API and webhooks are off until an admin turns them on:
- Go to Settings → Integrations and find API and webhooks.
- Turn on Turn on the API and webhooks. The panel shows the API address,
https://app.rotatiger.com/api/v1. - Under API keys, press New key. Give it a name, such as the system it’s for, and tick what it can do (its scopes).
- Choose when it expires: in 30 days, 90 days, a year, or never.
- Optionally, list the IP addresses or ranges it may be used from, such as
203.0.113.7or203.0.113.0/24(up to 20). - Press Create key and copy the key. It starts
rtk_and is shown once: keep it in your password manager or the other system’s secret settings.
Keep keys on servers. Never put one in a web page or a phone app, where anyone could read it.
Only the owner and admins who can change company settings can do this. A company can have up to 25 working keys. Revoke a key from the same list and it stops working straight away.
Scopes
Choose the fewest a system needs.
| Scope | What it allows |
|---|---|
people:read | People, teams and locations |
leave:read | Leave types, balances, requests and who’s off |
leave:write | Book leave for someone, and approve, decline or cancel requests |
shifts:read | Shifts from the published rota and working patterns |
sickness:read | Sickness and private leave types as they are. Without it they show as “Away” |
Only someone who can see sickness can make a key with sickness:read. If they later lose that permission, the key carries on without it.
Making a call
Send the key in the Authorization header. Keys in the address (query string) are ignored, so they don’t end up in logs.
curl https://app.rotatiger.com/api/v1/me \
-H "Authorization: Bearer rtk_your_key_here"
GET /me needs no scope, so it’s the quickest way to check a key works:
{
"company": { "id": "t_8kq2…", "name": "Harbour Café", "timeZone": "Europe/London" },
"key": { "id": "key_3fd9…", "scopes": ["people:read", "leave:read"] },
"actingAs": { "personId": "p_1a7c…", "name": "Sam Patel" }
}
Everything else works the same way:
- Answers are JSON. Ids are strings: store them as they come.
- Dates are
yyyy-MM-dd. Times are 24-hourHH:mmin the company’s time zone (from/me).createdAtis a UTC time in ISO 8601. - Amounts of leave come in the company’s unit (days or hours), with the exact number of minutes alongside.
- New fields can appear in answers at any time. Ignore the ones you don’t use.
People, teams and locations
Needs people:read.
| Call | What you get |
|---|---|
GET /people | Everyone current (active and invited), by name. Add ?status=all to include people who have left. Paged |
GET /people/{id} | One person |
GET /teams | Every team, with the ids of its members |
GET /locations | Every location, with its time zone and the ids of the people there |
A person looks like this:
{
"id": "p_4b2e…",
"name": "Ann Lee",
"email": "[email protected]",
"jobTitle": "Barista",
"employeeNumber": "0042",
"lineManagerId": "p_1a7c…",
"locationId": "loc_9d1f…",
"workPattern": "Full time, Mon–Fri",
"startDate": "2024-03-04",
"leaveDate": null,
"status": "active"
}
status is invited, active, left, dormant or archived.
Leave types and balances
Needs leave:read.
GET /leave-types lists the company’s leave types: id, name, needsApproval, deductsAllowance, allowPartDay, paid and private. Private types are only listed for keys with sickness:read.
GET /people/{id}/balance gives someone’s allowance for the current leave year. Add ?year=2025 for another one (the year the leave year starts in).
{
"personId": "p_4b2e…",
"year": 2026,
"yearStart": "2026-01-01",
"yearEnd": "2026-12-31",
"unit": "day",
"unitMinutes": 450,
"entitlement": 28,
"adjustments": 1,
"total": 29,
"taken": 14,
"booked": 5,
"pending": 2,
"remaining": 8
}
Amounts are in unit, rounded to two decimal places. remaining is what’s left after taken, booked and pending leave.
Requests
Needs leave:read to read and leave:write to change.
| Call | What it does |
|---|---|
GET /requests | Requests, filtered by status (pending, approved, declined or cancelled), personId, from and to. Paged |
GET /requests/{id} | One request |
POST /requests | Books leave for someone |
POST /requests/{id}/approve | Approves it (one step, if it needs more than one approver) |
POST /requests/{id}/decline | Declines it |
POST /requests/{id}/cancel | Cancels it |
from and to match requests that overlap those dates: from keeps requests ending on or after it, to keeps requests starting on or before it.
A request looks like this:
{
"id": "req_7c3a…",
"number": 128,
"personId": "p_4b2e…",
"personName": "Ann Lee",
"leaveTypeId": "lt_2f8e…",
"leaveType": "Annual leave",
"startDate": "2026-12-21",
"endDate": "2026-12-23",
"partDay": null,
"duration": { "minutes": 1350, "amount": 3, "unit": "day" },
"status": "pending",
"note": "Family visit",
"waitingFor": "Sam Patel",
"createdAt": "2026-11-09T10:15:42.120Z"
}
waitingFor names the approver while it’s pending. A part day has "partDay": { "start": "09:00", "end": "13:00" }. Without sickness:read, a private type shows "leaveType": "Away" with no leaveTypeId or note.
Booking leave. Send the person, the type and the dates. Leave out endDate for a single day. For part of a day, send startTime and endTime with the same start and end date.
curl https://app.rotatiger.com/api/v1/requests \
-H "Authorization: Bearer rtk_your_key_here" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1d2c0e-booking-ann-xmas" \
-d '{
"personId": "p_4b2e…",
"leaveTypeId": "lt_2f8e…",
"startDate": "2026-12-21",
"endDate": "2026-12-23",
"note": "Family visit"
}'
It answers 201 with the new request. It goes to the person’s approver as if they’d asked in the app, with the same checks on allowance, clashes and cover. To record leave that’s already agreed, add "approve": true: it’s approved as far as the key’s admin is allowed to approve it. Notes can be up to 1,000 characters.
Deciding. approve, decline and cancel take an optional comment, { "comment": "Enjoy the break" }, and answer with the request as it is now. They follow the same rules as the app: the key’s admin must be allowed to make that decision.
Every change made with a key is recorded in the company’s audit log as made by that key.
Who’s off and shifts
GET /time-off?from=2026-12-01&to=2026-12-31 (needs leave:read) lists everyone’s time off overlapping those dates, up to 93 days at a time: approved leave, requests waiting for approval ("pending": true) and sickness. Filter with personId or teamId (a team or a location id).
{
"from": "2026-12-01",
"to": "2026-12-31",
"data": [
{
"id": "req_7c3a…",
"kind": "leave",
"personId": "p_4b2e…",
"leaveTypeId": "lt_2f8e…",
"type": "Annual leave",
"startDate": "2026-12-21",
"endDate": "2026-12-23",
"partDay": null,
"pending": true,
"ongoing": false
}
]
}
kind is leave, sickness or away. Without sickness:read, sickness and private leave come back as away, with no type or times. ongoing is true for sickness with no end date yet.
GET /shifts?from=2026-12-01&to=2026-12-14 (needs shifts:read) lists shifts from the published rota and working patterns, up to 62 days at a time, with the same personId and teamId filters:
{ "personId": "p_4b2e…", "date": "2026-12-01", "start": "07:00", "end": "15:00", "paidMinutes": 450, "night": false }
Paging
GET /people and GET /requests come a page at a time: 100 by default, up to 500 with limit. When there’s more, the answer has a nextCursor. Pass it back as cursor for the next page, and stop when it’s null.
curl "https://app.rotatiger.com/api/v1/requests?status=approved&limit=200&cursor=bzoyMDA=" \
-H "Authorization: Bearer rtk_your_key_here"
Treat the cursor as a token: don’t build or change one. The other lists (teams, locations, leave types, time off and shifts) come whole, as data.
Errors and limits
Errors use the usual HTTP status codes, with a JSON body you can show to a person or log. Some also have a code your system can act on.
{ "message": "This API key doesn't have the leave:write scope. An admin can make a key that does in Settings → Integrations.", "code": "insufficient_scope" }
| Status | Means |
|---|---|
400 | Something in the call isn’t right, such as a date that isn’t yyyy-MM-dd. The message says what |
401 | No key, or a key that’s wrong, revoked, expired, not allowed from this address, or the API is off (code: unauthorized) |
403 | The key is missing a scope (insufficient_scope), or its admin isn’t allowed to do that |
404 | Nothing with that id, or nothing this key can see |
409 | It clashes with the current state, or a call with the same Idempotency-Key is still running (idempotency_key_in_use) |
422 | An Idempotency-Key was reused for a different call (idempotency_key_reused) |
429 | Too many calls. Wait the number of seconds in the Retry-After header |
Rate limits. Each key can make 120 calls a minute, and each IP address 600 a minute across all keys. To stay well inside them, use webhooks to hear about changes instead of asking every few seconds.
Retrying safely. Send an Idempotency-Key header (up to 100 characters, such as a UUID) with any POST. If the same key comes again within 24 hours, RotaTiger doesn’t do it twice: it sends back the first answer, with the header Idempotent-Replayed: true. Use a new key for each new thing you do.
Webhooks
Webhooks tell your system when something changes, so it doesn’t have to keep asking. Add them in Settings → Integrations → Webhooks (the API must be on):
- Press Add endpoint and enter an
https://address on port 443 or 8443 that the internet can reach. - Choose the events to send there, or All events, including ones added later.
- Copy the signing secret. It starts
whsec_and is shown once. - Press Send test event to send a
ping, and check it arrives.
A company can have up to 10 endpoints. Each one has a log of what was sent in the last 30 days, with Resend for anything that failed.
| Event | When |
|---|---|
request.created | Someone books or is given leave |
request.updated | A request changes: moved, amended, undone, or approved at one step of several |
request.approved | A request is fully approved |
request.declined | A request is declined |
request.cancelled | A request is cancelled |
absence.started | Sickness or an absence is recorded |
absence.ended | Someone is marked back |
person.created | Someone is added |
person.updated | Someone’s details change |
person.left | Someone is archived, or made dormant after leaving |
rota.published | A rota is published |
absence.started and absence.ended say who is off sick, so only someone who can see sickness can choose them for an endpoint. They stop if that person loses the permission.
Each event is a POST with a small JSON body. Events carry ids, never names, dates or reasons, so a misdirected webhook gives nothing away. Fetch the details from data.url with your API key:
{
"id": "evt_Qm3x9TzK2pLw8aV",
"type": "request.approved",
"createdAt": "2026-11-09T10:21:05Z",
"companyId": "t_8kq2…",
"data": {
"object": "request",
"id": "req_7c3a…",
"personId": "p_4b2e…",
"url": "https://app.rotatiger.com/api/v1/requests/req_7c3a…"
}
}
For rota.published, data has the published dates as from and to, and url points at /shifts for them.
Answering. Reply with any 2xx status within 10 seconds, then do the work. Anything else counts as a failure, including redirects, which aren’t followed. A failed event is tried again after 1 minute, 5 minutes, 30 minutes, then 2, 5, 10 and 24 hours. If it still fails after that, about two days, and the endpoint hasn’t worked since, the endpoint is turned off. Fix it, then turn it back on in Settings.
An event can arrive more than once, and not always in order. Use the id (also in the RotaTiger-Event-Id header) to skip ones you’ve already handled, and fetch the latest state from the API rather than relying on the order.
Checking a webhook’s signature
Every delivery has these headers:
| Header | Holds |
|---|---|
RotaTiger-Signature | t=1794133265,v1=5257a869…: when it was sent and the signature |
RotaTiger-Event | The event type, such as request.approved |
RotaTiger-Event-Id | The event’s id, the same for every retry |
RotaTiger-Delivery | This delivery’s id |
The signature is an HMAC-SHA256, in lowercase hex, of the timestamp, a full stop and the raw body, using your endpoint’s signing secret. Check it before you trust a delivery:
- Take
tand eachv1fromRotaTiger-Signature. - Work out the HMAC-SHA256 of
t+.+ the raw body, exactly as received, with your secret. - Accept it if it matches any
v1, andtis within 5 minutes of now.
Node.js
import crypto from "node:crypto"
export function verifyRotaTiger(rawBody, header, secret, toleranceSeconds = 300) {
const parts = header.split(",").map((p) => p.split("="))
const t = Number(parts.find(([k]) => k === "t")?.[1])
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex")
return parts
.filter(([k]) => k === "v1")
.some(([, sig]) => sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)))
}
Python
import hashlib, hmac, time
def verify_rotatiger(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = [p.split("=", 1) for p in header.split(",") if "=" in p]
t = next((v for k, v in parts if k == "t"), "")
if not t.isdigit() or abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(v, expected) for k, v in parts if k == "v1")
Use the raw body as it arrived. A body that’s been parsed and turned back into JSON won’t match.
Changing the secret. Roll secret on an endpoint makes a new one. Choose Roll, keep old for 24h and deliveries carry two v1 signatures, one for each secret, until the old one expires, so you can update your system without missing anything.