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=&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 [,...] 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:
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 => `
`).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.