Developer guide
TEAM MATE Open API, version 1
The TEAM MATE Open API lets your app read a business’s venues, staff, published rosters and timesheets, and get a webhook (a signed message to your server) when one of them changes. It’s read-only: nothing can be changed through it. The business decides: an owner or admin makes the key, and can revoke it at any time.
It never includes pay rates, bank, tax file number or super details, birthdays, home addresses, personal phone numbers, emergency contacts, photos, or the notes staff leave when they clock off.
1. Getting started
- An owner or admin of the business goes to Settings → Integrations → API keys, names the key after your app, and presses Make API key.
- They copy the key (it starts
tm_live_) and give it to you. It’s shown once: we only keep a scrambled copy, so a lost key can’t be shown again. Make a new one and revoke the old one instead. - Send it with every request in the
Authorizationheader:
curl https://app.teammateapp.au/api/v1/venues \
-H "Authorization: Bearer tm_live_YOUR_KEY_HERE"- Base URL:
https://app.teammateapp.au/api/v1(a self-hosted install: its own web address followed by/api/v1). - A key belongs to one business and reads only that business. It keeps working if the person who made it leaves; a revoked key stops at once, for good. A business can have 10 keys switched on at a time.
- Treat a key like a password. Call the API from your server only: never put a key in a web page or a phone app.
- Answers are JSON, times are ISO 8601 in UTC, and days are YYYY-MM-DD.
2. Endpoints
All four are GET and answer { "data": [ ... ], "nextCursor": "..." | null } (see Paging).
GET /venues
Every venue of the business, archived ones too.
| Field | Type | What it is |
|---|---|---|
| id | string | The venue's id (venueId everywhere else). |
| name | string | The venue's name. |
| timezone | string | IANA time zone, e.g. Australia/Melbourne. Timesheet days are in this zone. |
| address | string | null | Street address, if set. |
| phone | string | null | The venue's phone number, if set. |
| latitude, longitude | number | null | Where the venue is, if set. |
| archived | boolean | True for a venue that's been archived (still listed, so old shifts make sense). |
| archivedAt | string | null | When it was archived. |
| createdAt, updatedAt | string | When the record was made and last changed. |
GET /people
Everyone on the team (staff records). Optional: status=active, invited or deactivated.
| Field | Type | What it is |
|---|---|---|
| id | string | The person's id (personId on shifts and timesheets). |
| firstName, lastName | string | Their name. |
| string | The email they sign in with, so you can match them to your own records. | |
| status | string | ACTIVE, INVITED (hasn't set up their account yet) or DEACTIVATED (switched off). |
| employmentType | string | FULL_TIME, PART_TIME or CASUAL. |
| startDate | string | null | First day, YYYY-MM-DD. |
| finishDate | string | null | Last day, YYYY-MM-DD, once one is set. |
| venueIds | string[] | The venues they work at. |
| positions | { id, name }[] | Their jobs, e.g. Barista. |
| createdAt, updatedAt | string | When the record was made and last changed. |
GET /shifts?from=…&to=…
Published shifts that start from from up to (not including) to, soonest first. Drafts are never included: staff can’t see them either. Both are required: a date (2026-10-13 means 00:00 UTC that day) or a date and time with its offset (2026-10-13T00:00:00+11:00; write the + as %2B in a URL). At most 93 days apart. Optional: venueId, personId, includeCancelled=true (adds published shifts that were later cancelled).
| Field | Type | What it is |
|---|---|---|
| id | string | The shift's id. |
| venueId | string | Where it is. |
| personId | string | null | Who's working it; null for an open shift nobody has taken yet. |
| open | boolean | True when personId is null. |
| startAt, endAt | string | Start and finish, ISO 8601 in UTC. |
| breakMinutes | number | Unpaid break, in minutes. |
| sleepover | { startAt, endAt } | null | A sleepover's sleep time inside the shift, ISO 8601 in UTC; null when it isn't a sleepover. |
| onCall | boolean | true when the shift is on call: not rostered work. A call-out shows as a timesheet day like any other. |
| clientCancelled | { at, pay, minutes, makeUpBy, closedAt } | null | Set when the client cancelled the shift (status CANCELLED): pay is pay_anyway (the person is paid the minutes anyway) or make_up (the minutes are owed as make-up shifts, to use by makeUpBy, YYYY-MM-DD); closedAt = a manager closed what was left, or paid it out (then pay is pay_anyway, minutes is what was paid out and makeUpBy stays set). Closing or paying out sends no webhook: read the shift again. Null otherwise. |
| makeUpForShiftId | string | null | On a make-up shift: the id of the client-cancelled shift whose owed hours it uses. |
| status | string | PUBLISHED, or CANCELLED (only with includeCancelled=true). |
| publishedAt | string | When it was published (staff could see it from then). |
| area | { id, name } | The area of the venue, e.g. Bar. |
| position | { id, name } | The job, e.g. Barista. |
| notes | string | null | The shift's note, as staff see it. |
| createdAt, updatedAt | string | When the record was made and last changed. |
GET /timesheets?from=…&to=…
Timesheets for the days from to to (YYYY-MM-DD, both included, each venue’s own days), oldest first. At most 93 days. Optional: status=pending or approved, venueId, personId. Hours are in minutes; there are no dollar amounts.
| Field | Type | What it is |
|---|---|---|
| id | string | The timesheet's id. |
| personId | string | Whose it is. |
| venueId | string | Where they worked. |
| date | string | The day, YYYY-MM-DD, in the venue's time zone. |
| clockInAt, clockOutAt | string | null | When they clocked in and out, ISO 8601 in UTC (null while still on, or if not clocked). |
| breakMinutes | number | Breaks taken, in minutes. |
| workedMinutes | number | Clocked time less breaks, in minutes. On a sleepover, the sleep time isn't counted; night work the manager added is. |
| roundedMinutes | number | workedMinutes after the business's own rounding settings. |
| shiftId | string | null | The rostered shift it belongs to, if any. |
| positionId | string | null | The job they worked. |
| status | string | PENDING or APPROVED. |
| approved | boolean | True once a manager has approved it. |
| approvedAt | string | null | When it was approved. |
| createdAt, updatedAt | string | When the record was made and last changed. |
3. Paging
limitsets the page size: 1 to 500, 100 if you leave it out.- When there’s more, the answer has a
nextCursor. Ask again with the same query andcursor=that value.nextCursor: nullmeans that was the last page. - Treat the cursor as opaque and pass it back unchanged; it can’t reach anything outside the business the key belongs to.
const base = "https://app.teammateapp.au/api/v1";
const headers = { Authorization: "Bearer " + process.env.TEAMMATE_API_KEY };
async function allShifts(from, to) {
const shifts = [];
let cursor = null;
for (;;) {
const query = new URLSearchParams({ from, to, limit: "500" });
if (cursor) query.set("cursor", cursor);
const res = await fetch(base + "/shifts?" + query, { headers });
if (res.status === 429) {
// Over the limit: wait as long as we say, then ask for the same page again.
await new Promise((r) => setTimeout(r, Number(res.headers.get("Retry-After")) * 1000));
continue;
}
if (!res.ok) throw new Error((await res.json()).error.message);
const page = await res.json();
shifts.push(...page.data);
if (!page.nextCursor) return shifts;
cursor = page.nextCursor;
}
}
allShifts("2026-10-13", "2026-10-20").then((s) => console.log(s.length, "shifts"));4. Limits and errors
Each key can make 120 requests a minute. Every answer carries X-RateLimit-Limit and X-RateLimit-Remaining; over the limit you get a 429 with Retry-After (seconds). Errors look like this:
{ "error": { "code": "BAD_REQUEST", "message": "to must be after from" } }- 400 BAD_REQUEST: something in the query string is wrong; the message says what.
- 401 UNAUTHORIZED: no key, or a key that’s wrong or revoked (all the same answer, on purpose).
- 429 RATE_LIMITED: wait for
Retry-Afterseconds, then try again. - 500 INTERNAL: our fault. Try again later; tell us at support@teammateapp.au if it keeps happening.
5. Webhooks
An owner or admin adds your web address in Settings → Integrations → Webhooks and ticks the events to send. They get a signing secret (starts tm_whsec_, shown once) to give you. A business can have 5 addresses. We only send to public https:// addresses.
Events
shift.published: A shift was published (staff can see it now).shift.updated: A published shift changed: its time, person, job, area, break or notes.shift.cancelled: A published shift was cancelled.timesheet.approved: A timesheet was approved.timesheet.unapproved: An approved timesheet was unapproved so it can be changed (approving it again sends timesheet.approved again).person.created: Someone was added to the team.person.updated: Someone's details changed (name, email, job, venue, start or last day, accepted their invite, or switched back on).person.deactivated: Someone was switched off (they can't sign in or be rostered).
What we send
A POST with a JSON body. data is exactly the object the matching GET gives for that shift, timesheet or person (same fields as above).
POST /your/address HTTP/1.1
Content-Type: application/json
User-Agent: TEAM-MATE-Webhooks/1.0
TeamMate-Event-Id: evt_5c2f0d6e1a9b4c3d2e1f0a9b
TeamMate-Event-Type: shift.published
TeamMate-Signature: t=1791849600,v1=3f1c…(64 hex characters)
{
"id": "evt_5c2f0d6e1a9b4c3d2e1f0a9b",
"type": "shift.published",
"createdAt": "2026-10-13T00:00:00.000Z",
"data": { "id": "…", "venueId": "…", "personId": "…", "startAt": "…", … }
}Answering, retries and pausing
- Answer with any 2xx within 10 seconds. Answer first, then do the work. Redirects aren’t followed (a 3xx counts as a failure).
- Otherwise we try again after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours: 6 tries over about 8½ hours, then we give up on that message.
- After 3 messages in a row are given up on, we pause the address and the business sees that in Settings. Nothing is sent, or saved up, while it’s paused; an owner or admin switches it back on there.
- The same event can arrive more than once (a retry after a slow answer, say): every try carries the same
TeamMate-Event-Id, so skip ids you’ve already handled. - Events can arrive out of order. Use
data.updatedAt, or read the latest from the API, if order matters.
6. Checking a webhook’s signature
Check every message before you trust it. TeamMate-Signature is t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of <t>.<the raw body>, keyed with the whole signing secret (including tm_whsec_). Use the body exactly as it arrived, compare in constant time, and refuse a t more than 5 minutes from now so an old message can’t be replayed. Accept the message if any v1 matches (there may be more than one in future). In Node:
const { createHmac, timingSafeEqual } = require("node:crypto");
// rawBody: the request body exactly as it arrived, as a string,
// read BEFORE any JSON parsing (re-encoding JSON changes the bytes).
function isFromTeamMate(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
if (!signatureHeader) return false;
let t = null;
const signatures = [];
for (const part of signatureHeader.split(",")) {
const [key, value] = part.trim().split("=", 2);
if (key === "t" && /^\d+$/.test(value)) t = Number(value);
if (key === "v1" && value) signatures.push(value);
}
// Too old (or from the future): could be a replay, refuse it.
if (t === null || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(t + "." + rawBody).digest();
return signatures.some((s) => {
const got = Buffer.from(s, "hex");
return got.length === expected.length && timingSafeEqual(got, expected);
});
}A small receiver using it:
const http = require("node:http");
const seen = new Set(); // use your database in real life
http
.createServer((req, res) => {
let rawBody = "";
req.setEncoding("utf8");
req.on("data", (chunk) => (rawBody += chunk));
req.on("end", () => {
const ok = isFromTeamMate(rawBody, req.headers["teammate-signature"], process.env.TEAMMATE_WEBHOOK_SECRET);
if (!ok) return res.writeHead(401).end();
const event = JSON.parse(rawBody);
res.writeHead(200).end(); // answer first (within 10 seconds)...
if (seen.has(event.id)) return; // ...a retry carries the same id
seen.add(event.id);
// ...then do the work: event.type, event.data
});
})
.listen(8080);When the business presses New secret, the old one stops at once: update yours straight away.
7. What we promise (versioning)
- In
/api/v1and its webhooks, we won’t remove or rename a field, or change what type it is. - We may add fields, query options, events and values (a new
employmentType, say). Ignore what you don’t know rather than failing on it. - Anything that would break your app goes in a new version (
/api/v2), with notice, while v1 keeps working.
Questions, or something you need that isn’t here? Email support@teammateapp.au.