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

  1. An owner or admin of the business goes to Settings → Integrations → API keys, names the key after your app, and presses Make API key.
  2. 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.
  3. Send it with every request in the Authorization header:
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.

FieldTypeWhat it is
idstringThe venue's id (venueId everywhere else).
namestringThe venue's name.
timezonestringIANA time zone, e.g. Australia/Melbourne. Timesheet days are in this zone.
addressstring | nullStreet address, if set.
phonestring | nullThe venue's phone number, if set.
latitude, longitudenumber | nullWhere the venue is, if set.
archivedbooleanTrue for a venue that's been archived (still listed, so old shifts make sense).
archivedAtstring | nullWhen it was archived.
createdAt, updatedAtstringWhen the record was made and last changed.

GET /people

Everyone on the team (staff records). Optional: status=active, invited or deactivated.

FieldTypeWhat it is
idstringThe person's id (personId on shifts and timesheets).
firstName, lastNamestringTheir name.
emailstringThe email they sign in with, so you can match them to your own records.
statusstringACTIVE, INVITED (hasn't set up their account yet) or DEACTIVATED (switched off).
employmentTypestringFULL_TIME, PART_TIME or CASUAL.
startDatestring | nullFirst day, YYYY-MM-DD.
finishDatestring | nullLast day, YYYY-MM-DD, once one is set.
venueIdsstring[]The venues they work at.
positions{ id, name }[]Their jobs, e.g. Barista.
createdAt, updatedAtstringWhen 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).

FieldTypeWhat it is
idstringThe shift's id.
venueIdstringWhere it is.
personIdstring | nullWho's working it; null for an open shift nobody has taken yet.
openbooleanTrue when personId is null.
startAt, endAtstringStart and finish, ISO 8601 in UTC.
breakMinutesnumberUnpaid break, in minutes.
sleepover{ startAt, endAt } | nullA sleepover's sleep time inside the shift, ISO 8601 in UTC; null when it isn't a sleepover.
onCallbooleantrue 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 } | nullSet 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.
makeUpForShiftIdstring | nullOn a make-up shift: the id of the client-cancelled shift whose owed hours it uses.
statusstringPUBLISHED, or CANCELLED (only with includeCancelled=true).
publishedAtstringWhen 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.
notesstring | nullThe shift's note, as staff see it.
createdAt, updatedAtstringWhen 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.

FieldTypeWhat it is
idstringThe timesheet's id.
personIdstringWhose it is.
venueIdstringWhere they worked.
datestringThe day, YYYY-MM-DD, in the venue's time zone.
clockInAt, clockOutAtstring | nullWhen they clocked in and out, ISO 8601 in UTC (null while still on, or if not clocked).
breakMinutesnumberBreaks taken, in minutes.
workedMinutesnumberClocked time less breaks, in minutes. On a sleepover, the sleep time isn't counted; night work the manager added is.
roundedMinutesnumberworkedMinutes after the business's own rounding settings.
shiftIdstring | nullThe rostered shift it belongs to, if any.
positionIdstring | nullThe job they worked.
statusstringPENDING or APPROVED.
approvedbooleanTrue once a manager has approved it.
approvedAtstring | nullWhen it was approved.
createdAt, updatedAtstringWhen the record was made and last changed.

3. Paging

  • limit sets 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 and cursor= that value. nextCursor: null means 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-After seconds, 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/v1 and 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.