Teamscop

Export API

Read-only access to one company's work-hours data. You will need an API key and secret from the company administrator, and your server's IP on the key's allowlist.

Base https://teamscop.com/api/v2 Auth X-Api-Key + X-Api-Secret Version 2.3.0 Plain text
TEAMSCOP EXPORT API v2
Base URL : https://teamscop.com/api/v2
Purpose  : read-only export of one company's monitoring data to a single external consumer.
Format   : JSON (UTF-8) unless stated. Timestamps are ISO-8601 UTC unless named "business*".
 
QUICKSTART
Five minutes, in order. Every step is a real command; substitute your key and secret.
 
  1. Find the address we will see you from. Needs credentials only - it works before your
     allowlist is set, and it works when you are locked out.
 
       curl -s https://teamscop.com/api/v2/ip-allowlist \
         -H "X-Api-Key: $KEY" -H "X-Api-Secret: $SECRET"
       -> {"allowedIps":[],"callerIp":"203.0.113.7"}
 
     Use the callerIp it reports. Do NOT guess, and do not use `curl ifconfig.me` - if your
     host is dual-stack that returns your IPv6 while this connection arrives over IPv4, and
     you will ask for an address you are not calling from.
 
  2. Send that callerIp to the Teamscop operator, who allowlists it on the server (see
     IP ALLOWLIST below). The key alone cannot change its own allowlist.
 
  3. Confirm data flows.
 
       curl -s "https://teamscop.com/api/v2/staff" \
         -H "X-Api-Key: $KEY" -H "X-Api-Secret: $SECRET"
 
  4. Read the company's timezone once and keep it. Every "business*" field is in this zone.
 
       curl -s https://teamscop.com/api/v2/business-time \
         -H "X-Api-Key: $KEY" -H "X-Api-Secret: $SECRET"
 
  5. Pull a bar for one employee for one day.
 
       curl -s "https://teamscop.com/api/v2/timetrack/timeline\
  ?staffUserId=<id>&from=2026-09-05T00:00:00Z&to=2026-09-06T00:00:00Z" \
         -H "X-Api-Key: $KEY" -H "X-Api-Secret: $SECRET"
 
AUTHENTICATION
Every request except this document requires two headers:
 
  X-Api-Key:    tsk-...     (public identifier)
  X-Api-Secret: tss-...     (secret; treat like a password)
 
There is exactly one credential pair. It is bound to one company and is READ-ONLY: it cannot
create, modify or delete anything, and it cannot reach any other company's data.
 
IP ALLOWLIST (required before any data endpoint answers)
Data endpoints serve ONLY source IPs on this key's allowlist. Until one is set, every data
endpoint returns 403. Fail-closed by design: an empty allowlist never means "allow all".
 
THE OPERATOR SETS IT, NOT THE KEY. The allowlist is the second factor: if your key and secret
ever leak, they are useless from any address but yours. A key that could rewrite its own
allowlist would defeat that, so since 2.1.0 only the Teamscop operator changes it, on the
server:
 
  Teamscop.Api --set-api-allowlist <key id> <ip>[,<ip>...]
 
Send the operator the callerIp from GET below whenever your egress changes (new region, new
NAT gateway, a scaling event).
 
  GET /api/v2/ip-allowlist
    Credentials only - works from anywhere, allowlisted or not.
    200 : {"allowedIps":["203.0.113.7"], "callerIp":"203.0.113.7"}
 
    callerIp is the address WE see, after the reverse proxy hop. It is the authoritative
    answer to "what should be allowlisted", and it cannot be spoofed by sending your own
    X-Forwarded-For. Always take the value from here.
 
  POST /api/v2/ip-allowlist
    405. Removed in 2.1.0 - see above.
 
Rules the operator's command enforces: 1 to 20 single IPv4 or IPv6 addresses, mixed freely; no
CIDR ranges (list the individual addresses, or put a fixed-IP NAT gateway in front of your
caller). The command REPLACES the whole list.
 
A NOTE ON DUAL-STACK HOSTS
The commonest integration failure is allowlisting the wrong family. If your server has both
IPv4 and IPv6, `curl ifconfig.me` may report the v6 address while your call to this API goes
out over v4. You will allowlist a real address of yours, and still get 403 forever.
 
GET /ip-allowlist and read callerIp. That is the address this connection actually arrived
from, which is the only one that matters - send that one to the operator.
 
ENDPOINTS
GET /api/v2/business-time
  The company's configured timezone and its current wall-clock time. Every "business*" field in
  this API is expressed in this zone. Read this first; it is what makes exported times
  interpretable.
  200 : {"companyId","companyName","timeZoneId","businessLocalNow","utcNow","utcOffsetHours"}
 
GET /api/v2/staff
  All monitored employees.
  200 : {"staff":[{"staffUserId","username","status","statusReason","lastHeartbeatAt","createdAt"}]}
  status is one of: online | offline | broken | uninstalled | null (not yet classified).
    online      - reporting and capture healthy
    offline     - nothing reporting; machine off, asleep or disconnected
    broken      - a live reporter on the machine says the other half is broken
    uninstalled - removed with authorization, or never installed
  statusReason carries the detail, e.g. "components_missing:Teamscop.App.exe".
 
GET /api/v2/team-leaders
  One row per team that has a leader.
  200 : {"leaders":[{"staffUserId","username","teamId","teamName"}]}
 
GET /api/v2/teams/{teamId}/staff
  One team, its leader and its members.
  200 : {"teamId","teamName","leader":{...}|null,"members":[{"staffUserId","username","joinedAt"}]}
  404 : team not in this company.
 
GET /api/v2/screenshots?staffUserId={guid}&from={iso}&to={iso}&take={1-500}
  Screenshot METADATA for a period, newest first. Image bytes are NOT inlined — each display
  carries an imageUrl to fetch separately, so a day's export stays small and only the frames you
  want cost bandwidth.
  200 : {"staffUserId","from","to","count","truncated","nextTo","screenshots":[{
          "eventId","staffUserId","occurredAt","businessOccurredAt",
          "displays":[{"displayIndex","width","height","size","imageUrl"}]}]}
  Display 1 is the machine's PRIMARY monitor, matching Windows Display Settings.
 
  PAGING. One response holds at most `take` captures (default 200, max 500) — plus any that
  share the oldest one's occurredAt, so a page never splits an instant. When there are older
  captures in the period the response says "truncated": true and gives "nextTo": call again
  with the same `from` and `to` = nextTo. `to` is exclusive, so nothing repeats and nothing is
  skipped. Stop when truncated is false (nextTo is then null).
 
GET /api/v2/screenshots/{eventId}/image?display={n}
  The image bytes for one display. Content-Type is image/webp (older captures may be image/jpeg).
  Returns the stored bytes verbatim; there is no re-encode and no quality loss.
  404 : unknown event, wrong company, or that display was not captured.
 
GET /api/v2/timetrack?staffUserId={guid}&from={iso}&to={iso}
  Worked and idle time for a period, plus the segments it was derived from. Segments are clipped
  to the requested window, so the totals always describe exactly the period you asked for.
  200 : {"staffUserId","from","to","workedSeconds","idleSeconds",
         "segments":[{"kind":"working|rest","start","end","durationSeconds"}]}
  Overlapping reports are merged before totalling, so a machine that briefly ran two capture
  helpers cannot inflate worked time. A minute that mixed work and idle is split: its worked
  seconds come first, then its idle seconds, exactly as the console draws it and exactly the
  seconds the console's leaderboard counts. For a bar you can draw, use /timetrack/timeline.
 
GET /api/v2/timetrack/timeline?staffUserId={guid}&from={iso}&to={iso}
  THE BAR. The whole requested window covered end to end, with no holes, exactly as the
  Teamscop console draws it. Use this to reproduce the red/green bar rather than /timetrack,
  which returns only the recorded segments and leaves you to work out what the holes mean.
 
  Spans are ordered, contiguous and non-overlapping, and their fractions sum to 1, so laying
  them out in order with width = fraction gives you the bar. Four kinds:
 
    working    #16A34A  green        keyboard or mouse activity
    rest       #DC2626  red          reporting, but idle past the 3-minute threshold
    gap        #DC2626  red          should have been reporting and was not: PC off,
                                     asleep, or the agent was not running
    unknown    transparent           no claim either way: before the machine enrolled,
                                     older than the 30 days retention keeps, the last few
                                     minutes (not uploaded yet), or still in the future.
                                     Draw nothing at all.
 
  gap and unknown are BOTH "no data" and are deliberately not the same thing. A gap is a
  claim about the machine; unknown is the absence of one. Painting unknown red tells a
  manager an employee idled all night on the day they were hired, or all of a day whose data
  retention has already deleted.
 
  "color" is the exact value the product paints, so your bar matches the console's.
  "offsetSeconds" is the span's start relative to `from`; "fraction" is its share of the
  window. business* fields are the same instants in the company's timezone.
 
  200 : {"staffUserId","username","from","to","businessTimeZoneId","totalSeconds",
         "workedSeconds","idleSeconds","gapSeconds","unknownSeconds",
         "spans":[{"kind":"working|rest|gap|unknown","color","start","end",
                   "businessStart","businessEnd","durationSeconds","offsetSeconds","fraction"}]}
 
  Rendering it, in any language:
    for each span: draw a rect of width (fraction * barWidth) filled with span.color
  The spans already tile the window, so no sorting, gap-filling or arithmetic is needed.
 
GET /api/v2/browsing?staffUserId={guid}&from={iso}&to={iso}&take={1-5000}
  Browser history for a period, newest first, de-duplicated. A visit belongs to the period it
  HAPPENED in (visitedAt), whenever Chrome was polled for it.
  200 : {"staffUserId","from","to","visits":[{"url","domain","title","visitedAt"}],
         "truncated","nextTo"}
 
  PAGING. `take` is the page size in visits (default 200, max 5000). When "truncated" is true
  the period holds older visits than this page: call again with the same `from` and with
  `to` = "nextTo" (the oldest visitedAt in this page; `to` is exclusive, and a page never
  splits an instant, so nothing repeats and nothing is skipped). "nextTo" is null when
  "truncated" is false.
 
DRAWING THE BAR
The timeline is shaped so that rendering needs no arithmetic on your side. The spans already
tile the window in order, so the whole algorithm is: for each span, draw a block whose width
is its fraction of the container, filled with its colour.
 
HTML + CSS, no JavaScript needed once you have the JSON:
 
  <div style="display:flex;width:100%;height:28px;border-radius:4px;overflow:hidden">
    <!-- one div per span, in order -->
    <div style="width:39.37%;background:#DC2626"></div>   <!-- gap     00:00-09:27 -->
    <div style="width:4.86%; background:#16A34A"></div>   <!-- working 09:27-10:37 -->
    <div style="width:0.56%; background:#DC2626"></div>   <!-- rest    10:37-10:45 -->
  </div>
 
Generating that from the response:
 
  const r = await fetch(
    `https://teamscop.com/api/v2/timetrack/timeline?staffUserId=${id}` +
    `&from=${from}&to=${to}`,
    { headers: { 'X-Api-Key': KEY, 'X-Api-Secret': SECRET } }).then(r => r.json());
 
  bar.innerHTML = r.spans.map(s =>
    `<div title="${s.kind} ${s.businessStart}-${s.businessEnd}"
          style="width:${s.fraction * 100}%;background:${s.color}"></div>`).join('');
 
Three things that make this work, and are worth relying on:
 
  - The spans are ORDERED and CONTIGUOUS. span[n].end always equals span[n+1].start. You
    never have to sort them or discover holes.
  - The fractions SUM TO 1 (within float tolerance). Widths always fill the container.
  - "unknown" carries colour "transparent". Emit the div anyway - it holds its place in the
    layout, which is what keeps the timeline aligned to the clock.
 
If you want a fixed axis (hour ticks, a "now" marker), use offsetSeconds and totalSeconds
rather than accumulating widths yourself.
 
CHOOSING THE WINDOW
Pass from/to as UTC instants. A "day" means the company's business day, not yours, so derive
the boundaries from /business-time. For Asia/Tokyo (UTC+9), the business day 2026-09-05 is:
 
  from = 2026-09-04T15:00:00Z
  to   = 2026-09-05T15:00:00Z
 
Ask for exactly the window you want to draw. The response is clipped to it, so the totals
always describe the period you requested and nothing spills over the edges.
 
PERIOD RULES
from and to are REQUIRED on every period endpoint and must both parse as ISO-8601.
to must be after from. Maximum span is 31 company calendar days per request (a whole month,
even one with a daylight-saving change); page by calling repeatedly. Years outside 1970-9000
are refused. Every period error is a 400 with "code": "invalid_period".
Always send an explicit offset (Z or +hh:mm). An instant without one is ambiguous.
 
STATUS CODES
200 ok
400 malformed request (bad or missing period, invalid IP, too many addresses)
401 missing or invalid X-Api-Key / X-Api-Secret
    "unknown key" and "wrong secret" are deliberately indistinguishable
403 credentials valid but source IP not allowlisted, no allowlist set, or key disabled
404 the requested company-scoped object does not exist
429 rate limited (60 requests per minute per source address)
 
COMMON MISTAKES
403 on every data endpoint, but GET /ip-allowlist works
  Your source IP is not on the list. GET /ip-allowlist, read callerIp, and send that exact
  value to the Teamscop operator.
  On a dual-stack host this is almost always an IPv4/IPv6 mismatch - see above.
 
403 that started without you changing anything
  Your egress address moved: a scaling event, a NAT gateway replacement, a failover to another
  region. Re-read callerIp and send it to the operator. Nothing about the key has changed.
 
401 on everything
  Key or secret wrong, or the headers are missing. "Unknown key" and "wrong secret" return the
  same 401 on purpose, so the response cannot be used to discover which keys exist.
 
400 "from and to are required ISO-8601 timestamps"
  Both ends are mandatory - there is no "everything" query. Send a Z-suffixed instant, e.g.
  2026-09-05T00:00:00Z. A bare date does not parse.
 
400 "Period must not exceed 31 days"
  Split the request. Day-at-a-time is the usual pattern and keeps each response small.
 
The bar is all red / one long gap
  That is a real answer, not an error: the agent was not reporting for that window. Check the
  employee's "status" in /staff - "uninstalled" or "offline" explains it. gap means we expected
  data and got none; it is not the same as unknown.
 
Worked totals disagree with your own sum of the segments
  Sum the timeline's WORKED spans only, or just read workedSeconds. Do not add durationSeconds
  across every span - that is the whole window, which always equals totalSeconds.
 
Employee names changed and your rows duplicated
  Key your storage on staffUserId. Usernames are set at install time and can be edited.
 
NOTES FOR AUTOMATED CONSUMERS
- Poll no faster than you need. 60 req/min per key is the ceiling.
- Screenshot images are immutable: cache them by eventId+display forever.
- staffUserId values are stable; usernames are not - key your storage on the id.
- An employee removed from the product keeps their historical data until retention drops it.
- All times without a "business" prefix are UTC. Convert using /business-time.
- /timetrack answers "how much time"; /timetrack/timeline answers "what did the day look
  like". The timeline covers the window with no holes; the former has holes by design.
- Worked totals from the two agree: both merge overlapping reports before counting.
- Retries: 429 means you exceeded 60 req/min on this key - back off and retry, the limit is a
  fixed window. 5xx is us; retry with backoff. 4xx will not succeed on retry, so fix the
  request instead of looping.
- Data older than 30 days is deleted by retention and cannot be recovered. If you need history
  beyond that, pull daily and keep your own copy.
- This API is read-only. Nothing you can call here changes what the product records, and no
  credential issued here can reach another company's data.