SMARTfit Public API — Developer Guide

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.


Contents


Authentication

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

Start here: the call chain

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."

Step 1 — Find out who you are

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.

Step 2 — Branch on account type

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

Step 3 — Get session data

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.

Step 4 — Drill into one session

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.

Step 5 — Resolve the IDs into names

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.


How data connects

                          /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.


Two ways to reach a client

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.

Path A — Trainership (direct coaching relationship)

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.

Path B — Group membership (organizational)

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.

Which should you use?

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.


Reaction data: which games have it

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.

Categories that return reaction data

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

Categories that return null

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.

Guarantees

When a category is on the first list, you can rely on the following:

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.


Hardware configurations

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 mask

It 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.

The panel rule

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 035 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 08. They're correlated by multiPanelSessionId — use it to group stations from one session.

podLayout is deprecated. Ignore it.


Target numbering

Target numbers are zero-based. A single board uses 08. 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.

Sentinel values — Chase the Target and GO-NO-GO only

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 two accuracy fields

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.


Pagination

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.

Paged endpoints — the envelope

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.

Bare-array endpoints — no envelope, no paging

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.

Valid sortBy values

An 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.


Filtering games

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

Date formats and time zone

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

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.


Rate limits

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:


Endpoint reference

All endpoints are GET. All are relative to the base URL.

Identity and reference

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

Trainers

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

Clients

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

Groups

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

Reference data

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

Recipes

A performance dashboard for one client

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.

Per-hit analysis of a single session

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:

For 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.

A facility-wide roster

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.

Syncing new sessions

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.


Notes

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.