Skip to content
Admins · Developers · 12 min read

RotaTiger API and webhooks for shifts and leave

Read people, leave and shifts, book and approve leave from your own systems, and hear about changes as they happen. Everything a developer needs to connect.

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 canHowScope
Read people, teams and locationsGET /people, /teams, /locationspeople:read
Read leave types, balances, requests and who’s offGET /leave-types, /people/{id}/balance, /requests, /time-offleave:read
Book, approve, decline and cancel leavePOST /requests, /requests/{id}/approve, /decline, /cancelleave:write
Read shifts from the published rotaGET /shiftsshifts:read
Hear about changes as they happenSigned webhooks, including rota.publishedSet 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:

  1. Go to Settings → Integrations and find API and webhooks.
  2. Turn on Turn on the API and webhooks. The panel shows the API address, https://app.rotatiger.com/api/v1.
  3. 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).
  4. Choose when it expires: in 30 days, 90 days, a year, or never.
  5. Optionally, list the IP addresses or ranges it may be used from, such as 203.0.113.7 or 203.0.113.0/24 (up to 20).
  6. 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.

ScopeWhat it allows
people:readPeople, teams and locations
leave:readLeave types, balances, requests and who’s off
leave:writeBook leave for someone, and approve, decline or cancel requests
shifts:readShifts from the published rota and working patterns
sickness:readSickness 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-hour HH:mm in the company’s time zone (from /me). createdAt is 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.

CallWhat you get
GET /peopleEveryone current (active and invited), by name. Add ?status=all to include people who have left. Paged
GET /people/{id}One person
GET /teamsEvery team, with the ids of its members
GET /locationsEvery 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.

CallWhat it does
GET /requestsRequests, filtered by status (pending, approved, declined or cancelled), personId, from and to. Paged
GET /requests/{id}One request
POST /requestsBooks leave for someone
POST /requests/{id}/approveApproves it (one step, if it needs more than one approver)
POST /requests/{id}/declineDeclines it
POST /requests/{id}/cancelCancels 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" }
StatusMeans
400Something in the call isn’t right, such as a date that isn’t yyyy-MM-dd. The message says what
401No key, or a key that’s wrong, revoked, expired, not allowed from this address, or the API is off (code: unauthorized)
403The key is missing a scope (insufficient_scope), or its admin isn’t allowed to do that
404Nothing with that id, or nothing this key can see
409It clashes with the current state, or a call with the same Idempotency-Key is still running (idempotency_key_in_use)
422An Idempotency-Key was reused for a different call (idempotency_key_reused)
429Too 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):

  1. Press Add endpoint and enter an https:// address on port 443 or 8443 that the internet can reach.
  2. Choose the events to send there, or All events, including ones added later.
  3. Copy the signing secret. It starts whsec_ and is shown once.
  4. 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.

EventWhen
request.createdSomeone books or is given leave
request.updatedA request changes: moved, amended, undone, or approved at one step of several
request.approvedA request is fully approved
request.declinedA request is declined
request.cancelledA request is cancelled
absence.startedSickness or an absence is recorded
absence.endedSomeone is marked back
person.createdSomeone is added
person.updatedSomeone’s details change
person.leftSomeone is archived, or made dormant after leaving
rota.publishedA 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:

HeaderHolds
RotaTiger-Signaturet=1794133265,v1=5257a869…: when it was sent and the signature
RotaTiger-EventThe event type, such as request.approved
RotaTiger-Event-IdThe event’s id, the same for every retry
RotaTiger-DeliveryThis 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:

  1. Take t and each v1 from RotaTiger-Signature.
  2. Work out the HMAC-SHA256 of t + . + the raw body, exactly as received, with your secret.
  3. Accept it if it matches any v1, and t is 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.

API questions

The owner, or an admin who can change company settings. The key acts as that person, so it stops working if they leave the company or stop being an admin. Make keys for important systems with an account that will stay.

Still stuck?

Send us a message with what you tried and what happened. A person will reply.

Not using RotaTiger yet? Try it with your own team. It’s free for your first 6 months, and no credit card is required today.