A read-only REST API for SMARTfit performance and configuration data. Use it to build analytics dashboards, pull session results into your own platform, generate reports, or run research on training data.
Everything returns JSON. Every endpoint is a GET.
Every request needs two keys, both sent as headers.
| Header | What it identifies | Where you get it |
|---|---|---|
Ocp-Apim-Subscription-Key |
You, the developer | Issued by SMARTfit |
X-User-Api-Key |
Whose data you may read | Generated by the SMARTfit user in the mobile app, then shared with you |
The subscription key controls whether you can call the API at all. The user key
determines whose data comes back. Both are required on every endpoint except /health.
curl "https://smartfit.azure-api.net/sandbox/me" \
-H "Ocp-Apim-Subscription-Key: YOUR_SUBSCRIPTION_KEY" \
-H "X-User-Api-Key: USER_KEY"
A user key does not expire on a timer. It stays valid until the user regenerates it, which revokes the previous key. Revocation propagates within a few minutes — the API caches key lookups briefly, so a regenerated key can continue to work for a short period before it stops.
Base URL
https://smartfit.azure-api.net/sandbox
Most endpoints need an ID in the path, and those IDs come from earlier responses. This is the order that gets you from "I have a key" to "I have performance data."
GET /me
{
"id": "1f24fafa-1698-42b9-a88d-a9d2478498f2",
"type": "Trainer",
"name": "Sample Trainer"
}
This resolves your X-User-Api-Key to an account. The id is what you use as
{trainerId} or {clientId} in every subsequent call, and type tells you which
branch to follow below. Always call this first — don't hard-code IDs.
If type is Trainer, you have access to your own records plus your clients':
GET /trainers/{trainerId} → your profile
GET /trainers/{trainerId}/clients → the clients you can read ← gives you clientId
GET /trainers/{trainerId}/groups → groups you belong to ← gives you groupId
If type is Client, you have access to your own records only:
GET /clients/{clientId} → your profile
GET /clients/{clientId}/games → your sessions ← gives you gameId
GET /clients/{clientId}/groups → groups you belong to
With a clientId from step 2:
GET /trainers/{trainerId}/clients/{clientId}/games
Returns a paginated list of GameSummaryDto. Each entry carries the IDs you need next:
id (the game), activityId, programId, and category.
GET /trainers/{trainerId}/clients/{clientId}/games/{gameId}
Same fields as the summary, plus six per-hit arrays: reactionTimes,
reactionTargetHitNums, reactionTargetIntendedNums, reactionPoints,
reactionAccuracyAverage, reactionSumScore. One entry per hit, in chronological
order.
List endpoints deliberately omit these arrays — they'd make paged responses very large.
These arrays are null for game categories that don't record per-hit data. See
Reaction data for which categories do.
Game records reference activities, programs, and categories by ID. Resolve them:
GET /categories → all 60 game types, static. Cache this.
GET /activities?ids=id1,id2,id3 → up to 50 activities in one call
GET /programs/{programId} → one program
GET /programs/{programId}/activities → the activities inside it, in order
Collect the distinct activityId values from a page of games and resolve them in one
batched call rather than one lookup per game.
/me
│
┌────────────┴────────────┐
type=Trainer type=Client
│ │
├── /trainers/{id} ├── /clients/{id}
├── /trainers/{id}/clients ├── /clients/{id}/games ──┐
│ │ └── /clients/{id}/groups │
│ └─ clientId │
│ │ │
├── /trainers/{id}/groups │
│ └─ groupId │
│ │ │
└──────────────┴─────────────────────────────────────┤
│
games carry ─────────┤
│
┌──────────────────────────────────────┤
│ │
activityId category
│ │
GET /activities/{id} GET /categories
│
┌─────────────┼──────────────┬───────────────┐
│ │ │ │
targetSelectionId targetSequenceId wordListId equipment1-6Id
│ │ │ │
/targetselections /targetsequences /wordlists /equipment
The rule of thumb: any field ending in Id is a pointer to another endpoint, and
category is a GUID that resolves through /categories.
A trainer can reach a client's data through either of two independent paths. They have different permission rules, so which one applies depends on how the trainer and client are related in SMARTfit.
GET /trainers/{trainerId}/clients/{clientId}/...
Works when the trainer has an approved trainership with canRead = true for that
client. Check what you have:
GET /trainers/{trainerId}/trainerships
Each record shows clientId, canRead, and isPrimary. Only clients with
canRead: true are reachable this way — and those are exactly the ones returned by
GET /trainers/{trainerId}/clients.
GET /groups/{groupId}/clients/{clientId}/...
Works when the trainer and client are in the same group and the client's membership
has canShareData = true. This is how an organization shares data without needing a
one-to-one trainership for every pairing.
GET /trainers/{trainerId}/groups → groups you belong to
GET /groups/{groupId}/clients → clients in that group who share data
GET /groups/{groupId}/clients?includeSubgroups=true
Groups form hierarchies. A trainer who is an admin (authority = 1) of a parent
group can reach clients in all descendant subgroups. includeSubgroups=true rolls
them up in a single call, and each returned client includes groupId and groupName
so you can tell which subgroup they came from.
If you're building around one trainer and their roster, use Path A. If you're building around a facility or organization with many trainers, use Path B — it survives staff changes, since access follows the group rather than an individual relationship.
The six per-hit arrays on the game detail endpoint are populated for 27 of the 60
categories and null for the other 33. Which you get is determined entirely by the
game's category, so you can predict it before making the call.
The rule is straightforward: every Seek, Track, and Chase category, plus GO-NO-GO, plus nine cognitive tests.
| Chase the Target | Seek the Color |
| GO-NO-GO | Seek the Smiley |
| Track the Target | Seek the Letter |
| Track the Color | Seek the Letter – Skip |
| Track the Letter | Seek the Letter - Random Skip |
| Track the Panel | Seek Numbers – Multiplication Tables |
| Track Left, Right, Both - Fixed | Seek Numbers – Step Counting |
| Track Left, Right, Both - Random | Seek Numbers – Reverse Step Counting |
| Track Numbers – Multiplication Tables | Trail Making A |
| Track Numbers – Step Counting | Trail Making B |
| Digit Span Forwards | Corsi Block |
| Digit Span Backwards | Flanker |
| Symbol Comparison | Serial 7's |
| Pattern Recognition |
Everything else: Rallywall, Knock the Lights Out (both variants), all Pairing categories, all Memory Pairs and Memory Sequence categories, Memory Symbols, all Equations categories, Tic-Tac-Toe, Spelling, all Stroop variants, N-Back, Timer, Stopwatch, and both Metronome categories.
When a category is on the first list, you can rely on the following:
n of every array describes the same hit, in
chronological order.Those three together are what make it safe to zip the arrays into per-hit records without defensive length checks.
What the arrays contain is covered under Target numbering — zero-based values, the joined-panel range, and the two sentinel values used by Chase the Target and GO-NO-GO.
Two fields determine how a session's target numbers can be read: supportedConfigurations
and, in one specific case, the panel flags. Get this wrong and every positional figure
downstream is meaningless, so it's worth reading before you touch reactionTargetHitNums.
supportedConfigurations is a bit maskIt is not a comma-separated list. It's a five-character string where exactly one position is set:
| Value | Configuration | Can panels join? |
|---|---|---|
10000 |
Strike Targets only (pods) | No |
01000 |
Single | Yes |
00100 |
Mini | No |
00010 |
Single with Pods | No |
00001 |
Mini with Pods | No |
Compare the whole string. Don't split on commas — there are none.
if supportedConfigurations != "01000":
single board, targets 0-8
ignore usePanel1-4 and joinPanels entirely, whatever they contain
else:
joinPanels == 1 -> ProTrainer
joinPanels == 0 -> MultiTrainer
Only plain Single (01000) supports joined panels. On every other configuration —
including Single with Pods — the panel flags and joinPanels may hold any value and
carry no meaning. Reading them outside 01000 is the single most common way to
misclassify a session.
ProTrainer (joinPanels = 1) is one game across a continuous surface. It produces a
single record, and target numbers run 0–35 across all four boards.
MultiTrainer (joinPanels = 0, panel flags set) is up to four independent stations
playing at once. Each produces its own game record, each numbered locally 0–8.
They're correlated by multiPanelSessionId — use it to group stations from one session.
podLayout is deprecated. Ignore it.
Target numbers are zero-based. A single board uses 0–8. 0 is a real target, not
a sentinel or a missing value.
On a joined ProTrainer surface, numbering continues across boards:
panel 1 = 0-8 panel 2 = 9-17 panel 3 = 18-26 panel 4 = 27-35
panel = (n / 9) + 1 // integer division
localPosition = n % 9 // 0-based position on that board
Assuming 1-based numbering shifts every position by one. On the Single layout that inverts left/right, which produces a report that looks clinical and means nothing.
Two categories use values outside the normal target range. No other category produces them, so you only need this handling for these two:
| Value | Meaning | Counts as |
|---|---|---|
254 |
A bad target was correctly avoided | Success |
255 |
No target was hit — a miss | Failure |
They are opposites. Treating both as "no hit" merges a success with a failure and understates accuracy.
When the hit value is 255, the reaction time is forced to 0.0. It's a placeholder,
not a measurement. Averaging reactionTimes without excluding those entries drags the
average toward zero.
realTimes = [t for h, t in zip(hitNums, reactionTimes) if h != 255]
For every other category, all values in the reaction arrays are real target numbers.
The API has two fields with "accuracy" in the name, on different scales:
| Field | Scale | Meaning |
|---|---|---|
accuracy |
0–100 | Session accuracy percentage |
reactionAccuracyAverage[n] |
0–1 | Running cumulative average after hit n |
Charting them on the same axis shows a performance collapse that isn't there.
reactionAccuracyAverage is cumulative: each element is the running average up to that
point, so the final element is the session accuracy — the same figure as accuracy,
expressed 0–1 rather than 0–100. reactionSumScore works the same way; its final element
is the session's total score.
Not every endpoint that returns a list is paged. There are two response shapes, and
mixing them up is a silent failure — reading data on a bare array yields nothing, with
no error.
Everything under /clients/, /trainers/, and /groups/ returns this:
{
"data": [ ... ],
"page": 1,
"pageSize": 50,
"totalCount": 327,
"totalPages": 7
}
| Parameter | Default | Notes |
|---|---|---|
page |
1 | 1-based |
pageSize |
50 | Maximum 200 |
sortBy |
varies | See the sort field table below |
sortOrder |
asc |
desc on game endpoints |
The envelope is identical across all of these, so one helper covers them.
These seven return a plain JSON array. There is no data property, no totalCount, and
no paging:
/categories /activities /programs
/targetselections /targetsequences /equipment /wordlists
[ { "id": "...", "name": "..." }, { "id": "...", "name": "..." } ]
They're bounded by their inputs rather than by paging — /categories returns a fixed 60
entries, and the batch lookups return at most the 50 IDs you asked for. Handle them as
arrays.
sortBy valuesAn unrecognised value silently falls back to the endpoint default rather than erroring, so a typo produces data in an order you didn't ask for with no indication. The valid fields:
| Endpoint group | Valid sortBy |
Default |
|---|---|---|
| Games | date, score, accuracy, reactiontime |
date (descending) |
| Clients | name, role, sport, createdat |
name |
| Groups | name, createdat |
name |
| Memberships | createdat, groupindex |
createdat |
| Trainerships | createdat |
createdat |
| Activities, programs, target selections and sequences, equipment, word lists | name, createdat |
name |
Values are matched case-insensitively.
Game endpoints accept these query parameters:
| Parameter | Purpose |
|---|---|
activityId |
Only sessions of one activity |
programId |
Only sessions from one program |
programSessionId |
Sessions played together in a single program run |
multiPanelSessionId |
Sessions played across multiple panels at once |
category |
One game type — GUID from /categories |
startDate / endDate |
Date range, inclusive |
masterGroupId |
Organization-wide. Requires both dates, max 90-day range, page size capped at 100 |
startDate and endDate accept either a plain date (2026-01-01) or a full ISO 8601
timestamp (2026-01-01T00:00:00Z). A plain date is interpreted as midnight.
date is when the game was played. createdAt is when the record reached the server.
Use date for anything about the session itself.
Both are UTC. date is returned without an offset — 2024-08-07T21:30:04.713 — because
of how the column is typed, but the value is UTC. createdAt and updatedAt carry an
explicit +00:00.
Two consequences worth planning for:
Don't let your deserializer guess. Binding an offset-less value to a type that assumes
local time makes the same response deserialize differently on a developer's laptop than on
a server in another region. Treat date as UTC explicitly.
Convert before grouping by day. A session played at 6pm Pacific is 2am UTC the next day. A report that buckets by UTC date puts evening sessions on the following day, which will look wrong to facility staff. Convert to the facility's local time zone first.
Example — one client's reflex sessions in Q1, highest score first:
GET /trainers/{trainerId}/clients/{clientId}/games
?category=c9a47b9b-e65b-4e52-8cc7-33d11b2f993a
&startDate=2026-01-01
&endDate=2026-03-31
&sortBy=score
&sortOrder=desc
Errors return a consistent shape:
{
"code": "UNAUTHORIZED",
"message": "X-User-Api-Key header is required."
}
| Status | Meaning |
|---|---|
400 |
Malformed request or invalid parameter |
401 |
Missing or invalid key |
403 |
Authenticated, but not permitted to read this record |
404 |
Record doesn't exist, or you lack access to it |
429 |
Rate limit exceeded. Retry after the interval in the response header |
500 |
Server error |
A 500 can be a query timeout. Queries have a server-side time limit. A request
spanning a very large date range, or one returning a great many rows, can exceed it and
currently surfaces as a generic 500 rather than a timeout-specific code. If a broad
request fails but a narrower one succeeds, that's what happened — reduce the date range
or the page size rather than retrying the same request.
| Limit | Value | Scope |
|---|---|---|
| Requests per minute | 300 | Per subscription key |
| Requests per month | 50,000 | Per subscription key |
Exceeding either returns 429.
Both are scoped to the subscription key, not the user key. This matters if you're building a tool used by several SMARTfit accounts: every user of your integration shares one budget. Size your polling and back-off accordingly, and plan around the monthly quota rather than the per-minute one — 50,000 per month averages out to roughly 70 per hour.
Ways to stay well inside it:
/categories at startup. It's static; there is no reason to fetch it twice.?ids= rather than one call per record.pageSize=200 when pulling history, to cut the number of round trips.All endpoints are GET. All are relative to the base URL.
| Endpoint | Returns |
|---|---|
/health |
Service status. No authentication required |
/me |
Your ID, account type, and name |
/categories |
All game types with names and difficulty. Static — cache it |
/categories/{categoryId} |
One game type |
Your own records:
| Endpoint | Returns |
|---|---|
/trainers/{trainerId} |
Your profile |
/trainers/{trainerId}/clients |
Clients you can read |
/trainers/{trainerId}/clients/by-email/{email} |
One of your clients, looked up by email |
/trainers/{trainerId}/peers/by-email/{email} |
Another trainer, if you share a group |
/trainers/{trainerId}/trainerships |
Your coaching relationships and their permissions |
/trainers/{trainerId}/groups |
Groups you belong to |
/trainers/{trainerId}/memberships |
Your group permissions |
/trainers/{trainerId}/games |
Sessions you supervised. Requires clientId |
/trainers/{trainerId}/games/{gameId} |
One supervised session, full detail |
/trainers/{trainerId}/activities |
Activities you own |
/trainers/{trainerId}/programs |
Programs you own |
/trainers/{trainerId}/targetselections |
Target selections you own |
/trainers/{trainerId}/targetsequences |
Target sequences you own |
/trainers/{trainerId}/equipment |
Equipment records you own |
/trainers/{trainerId}/wordlists |
Word lists you own |
A client's records, via trainership:
| Endpoint | Returns |
|---|---|
/trainers/{trainerId}/clients/{clientId} |
Client profile |
/trainers/{trainerId}/clients/{clientId}/games |
Their sessions |
/trainers/{trainerId}/clients/{clientId}/games/{gameId} |
One session, full detail |
/trainers/{trainerId}/clients/{clientId}/groups |
Groups they belong to |
/trainers/{trainerId}/clients/{clientId}/memberships |
Their group permissions |
/trainers/{trainerId}/clients/{clientId}/activities |
Activities they own |
/trainers/{trainerId}/clients/{clientId}/programs |
Programs they own |
/trainers/{trainerId}/clients/{clientId}/targetselections |
Their target selections |
/trainers/{trainerId}/clients/{clientId}/targetsequences |
Their target sequences |
/trainers/{trainerId}/clients/{clientId}/equipment |
Their equipment |
/trainers/{trainerId}/clients/{clientId}/wordlists |
Their word lists |
For a client reading their own data:
| Endpoint | Returns |
|---|---|
/clients/{clientId} |
Your profile |
/clients/{clientId}/games |
Your sessions |
/clients/{clientId}/games/{gameId} |
One session, full detail |
/clients/{clientId}/groups |
Groups you belong to |
/clients/{clientId}/memberships |
Your group permissions |
/clients/{clientId}/trainerships |
Trainers who manage you |
/clients/{clientId}/activities |
Activities you own |
/clients/{clientId}/programs |
Programs you own |
/clients/{clientId}/targetselections |
Your target selections |
/clients/{clientId}/targetsequences |
Your target sequences |
/clients/{clientId}/equipment |
Your equipment |
/clients/{clientId}/wordlists |
Your word lists |
| Endpoint | Returns |
|---|---|
/groups/{groupId} |
Group profile |
/groups/{groupId}/memberships |
Everyone in the group and their permissions |
/groups/{groupId}/clients |
Clients sharing data. Add ?includeSubgroups=true to roll up |
/groups/{groupId}/clients/{clientId} |
Client profile via group |
/groups/{groupId}/clients/{clientId}/games |
Their sessions via group |
/groups/{groupId}/clients/{clientId}/games/{gameId} |
One session, full detail |
/groups/{groupId}/clients/{clientId}/activities |
Their activities |
/groups/{groupId}/clients/{clientId}/programs |
Their programs |
/groups/{groupId}/clients/{clientId}/targetselections |
Their target selections |
/groups/{groupId}/clients/{clientId}/targetsequences |
Their target sequences |
/groups/{groupId}/clients/{clientId}/equipment |
Their equipment |
/groups/{groupId}/clients/{clientId}/wordlists |
Their word lists |
Look up any record referenced by ID. Batch endpoints accept up to 50 comma-separated
IDs; supplying more returns 400 rather than silently dropping the extras, so chunk
larger lists into multiple calls.
| Endpoint | Returns |
|---|---|
/activities/{activityId} |
One activity and its full configuration |
/activities?ids= |
Up to 50 activities |
/programs/{programId} |
One program |
/programs/{programId}/activities |
Activities in the program, in order |
/programs?ids= |
Up to 50 programs |
/targetselections/{targetSelectionId} |
Which targets are active |
/targetselections?ids= |
Up to 50 target selections |
/targetsequences/{targetSequenceId} |
The order targets activate |
/targetsequences?ids= |
Up to 50 target sequences |
/equipment/{equipmentId} |
One equipment record |
/equipment?ids= |
Up to 50 equipment records |
/wordlists/{wordListId} |
One word list |
/wordlists?ids= |
Up to 50 word lists |
/folders/{folderId} |
Folder metadata |
/folders/{folderId}/items |
Items in the folder |
1. GET /me → trainerId
2. GET /trainers/{trainerId}/clients → pick a clientId
3. GET /categories → cache the GUID → name map
4. GET /trainers/{trainerId}/clients/{clientId}/games?pageSize=200&sortOrder=desc
5. Collect distinct activityId values from the results
6. GET /activities?ids={comma-separated} → resolve names in one call
7. Join in memory and render
Steps 3 and 6 are what keep this efficient. Categories never change, so fetch them once at startup. Activity names resolve in a single batched call instead of one per game.
1. GET /trainers/{trainerId}/clients/{clientId}/games → pick a gameId
2. GET /trainers/{trainerId}/clients/{clientId}/games/{gameId}
If the game's category records reaction data, the detail response carries the six
arrays. Index n across all six describes the same hit, so you can zip them into
per-hit records:
reactionTargetIntendedNums[n] which target should have been hit
reactionTargetHitNums[n] which was actually hit
reactionTimes[n] how long it took, in seconds
reactionPoints[n] points earned
reactionAccuracyAverage[n] running accuracy after this hit
reactionSumScore[n] running score after this hit
Comparing intended against actual gives you an error map for the session.
If the category doesn't record reaction data, all six come back null — check the
category first rather than null-checking each array.
Before you read the target numbers spatially, three things determine what they mean:
0–35 on a joined ProTrainer surfacesupportedConfigurations, not on the panel flags alone254 and 255For Symbol Comparison and Pattern Recognition, reactionTargetIntendedNums is set equal
to the hit value on a correct answer rather than recording what was expected — so an
error map built by comparing the two arrays is not meaningful for those two categories.
1. GET /me → trainerId
2. GET /trainers/{trainerId}/groups → find the top-level group
3. GET /groups/{groupId}/clients?includeSubgroups=true&pageSize=200
4. For each client: GET /groups/{groupId}/clients/{clientId}/games
Only clients with canShareData = true appear in step 3, so anything you get back is
already cleared for access.
There are no webhooks yet, so poll on a date range:
GET /trainers/{trainerId}/clients/{clientId}/games
?startDate={recent}
&pageSize=200
&sortOrder=desc
date is when the game was played. createdAt is when it reached the server. The two
usually match closely, but not always — a session can arrive after the day it was played.
That matters if you sync incrementally: filtering on date and advancing a high-water mark
past it will miss anything that arrives later. Poll an overlapping window and de-duplicate
on the game id, which is stable, so re-fetching the same session is harmless.
Query parameters are case-insensitive. The examples here use camelCase
(startDate, pageSize); the OpenAPI document declares them PascalCase
(StartDate, PageSize). Both bind. If you generate a client from the spec, its URLs
will look different from these examples and both will work.
Client summaries and client profiles are different shapes. A list of clients returns
a 5-field summary — id, name, role, sport, position. Fetching one client returns
the full 14-field profile, which additionally includes username, gender, classroom,
notes, dob, condition, and email. They overlap on name and role, so binding
the wrong type appears to work until you touch a field that exists on only one.
Deleted records are invisible. Anything deleted in SMARTfit is filtered out of every response. An empty list often means the records were deleted, not that the endpoint failed.
Reaction arrays are detail-only. List endpoints omit them by design. Fetch the single-game endpoint when you need per-hit data — and only for categories that record it.
Activity settings live on the activity, not the game. A game record tells you what was
played and how it went; GET /activities/{id} tells you how it was configured.
clientId as a query parameter. It's required on /trainers/{trainerId}/games, which
has no client in its route. On the nested /trainers/{trainerId}/clients/{clientId}/games
the client is already in the path, so the query parameter is redundant there and is
ignored. The OpenAPI description is attached to both and doesn't make that distinction.
Finding IDs manually. Trainer accounts can be flagged as developer accounts by the SMARTfit team. With Developer Mode on, swiping right on an activity, program, folder, or folder item in the app reveals a Show ID option — useful when you want to test against one specific record.