Fitness intelligence
Ranked, gym-safe actions for a trainer (what to program, how hard to push, which movements to avoid), an at-risk roster across your members, and a way to ask Norvya to send a member its standard re-engagement nudge. A member's training (workouts, estimated one-rep max per lift, weekly hard sets against volume landmarks, the load ratio, and a readiness band only while wearables are shared), your trainers' notes both ways (attributed to your system, never to a person), and your gym's retention and engagement-versus-results as counts with small cells withheld. Never names a diagnosis, medicine or reading.
- Who calls it
- Core for Fitness chains; useful for Concierge doctors, Federal (via primes)
- Before production
- No gate of its own; your market's “Before production” applies.
- Sandbox
- Build against synthetic members first. What each sandbox holds
Markets that use this family
- Concierge doctors (useful)
- Fitness chains (core)
- Federal (via primes) (useful)
Scopes
| Scope | Allows |
|---|---|
fitness.analytics.read | Read your gym's member retention against your own baseline and whether more-engaged members get better results, as aggregate counts with small cells withheld; members who do not share activity are excluded (gyms, fitness and personal-training organisations only). |
fitness.guidance.read | Read gym-safe trainer guidance for a member (consent-filtered; no diagnoses, drugs or readings). |
fitness.notes.read | Read your trainers' notes about a member (your organisation's own notes; the member does not see them) (gyms, fitness and personal-training organisations only). |
fitness.notes.write | Add a trainer note about a member: training notes only, length-capped, kept as your organisation's record (gyms, fitness and personal-training organisations only). |
fitness.nudge.write | Ask Norvya to send a member its standard re-engagement push (gyms only; Norvya's own wording, deduplicated; never your text). |
fitness.roster.read | Read the at-risk roster: your active members ranked for a trainer's attention (churn level, gym-safe flags, the one priority action), consent-filtered; no diagnoses, drugs or readings. |
fitness.training.read | Read a member's training: completed workouts, estimated one-rep max per lift, weekly hard sets per muscle group against the volume landmarks, and the training-load ratio while the member shares activity with you; the readiness band (never a recovery score or a reading) only while they share wearables (gyms, fitness and personal-training organisations only). |
Endpoints
Trainer guidance for one member
Ranked, gym-safe actions for a trainer: what to program, how hard to push, what to avoid. Filtered by what the member shares with your organisation; never names a diagnosis, medication, lab value or reading. 404 member_not_found for anyone who is not your active member.
Parameters
EMR vendors also pass vendor (the practice) with a vendor-issued token. EMR vendor tokens
Errors specific to this call
404member_not_foundAll responses
200Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Validation Error429Too Many Requests503Service UnavailableCodes every call can return are in Errors & limits.
curl -s "https://norvya.fly.dev/v1/members/$MEMBER_REF/guidance" \
-H "Authorization: Bearer $TOKEN"import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
member_ref = os.environ["MEMBER_REF"]
resp = requests.get(
f"{BASE}/v1/members/{member_ref}/guidance",
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
resp.raise_for_status()
print(resp.json())// Node.js 18+ (ES module). Keep production credentials on your server.
const BASE = "https://norvya.fly.dev";
const member_ref = process.env.MEMBER_REF;
const url = new URL(`${BASE}/v1/members/${encodeURIComponent(member_ref)}/guidance`);
const res = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.TOKEN}`,
},
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(await res.json());{
"member_ref": "string",
"external_id": "string",
"available": false,
"priority": {},
"actions": [
{}
],
"shared": [
{}
],
"withheld": [
{}
],
"cannot_assess": [
{}
],
"consent": {}
}The response is the shape from the schema, not live data.
Try it in your sandbox
At-risk roster: your members ranked for a trainer's attention
Your active members ranked for attention: churn risk (from club visits when your booking system is linked, else app activity), gym-safe flags and the one priority action from trainer guidance. Each member's sharing switches for your organisation apply: without activity sharing a member has no churn, no last-active date and no activity flags. Never a diagnosis, medication, lab value or reading. One chained audit row per call names the members returned. 404 member_not_found for a member_ref that is not yours.
Parameters
high | medium | low
One member (mr_... or ext:...).
EMR vendors also pass vendor (the practice) with a vendor-issued token. EMR vendor tokens
Errors specific to this call
404member_not_foundAll responses
200Successful Response401Unauthorized403Forbidden404Not Found422Unprocessable Entity429Too Many Requests503Service UnavailableCodes every call can return are in Errors & limits.
curl -s "https://norvya.fly.dev/v1/members/at-risk" \
-H "Authorization: Bearer $TOKEN"import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
resp = requests.get(
BASE + "/v1/members/at-risk",
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
resp.raise_for_status()
print(resp.json())// Node.js 18+ (ES module). Keep production credentials on your server.
const BASE = "https://norvya.fly.dev";
const url = new URL(`${BASE}/v1/members/at-risk`);
const res = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.TOKEN}`,
},
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(await res.json());{
"total": 0,
"offset": 0,
"limit": 0,
"ranking": "string",
"items": [
{
"member_ref": "string",
"external_id": "string",
"rank": 0,
"churn": {
"level": "string",
"score": 0,
"driver": "string"
},
"flags": [
{}
],
"priority_action": {
"title": "string",
"severity": "string",
"based_on": []
},
"last_active_date": "string"
}
]
}The response is the shape from the schema, not live data.
Try it in your sandbox
Ask Norvya to send a member its re-engagement push
Norvya sends its standard re-engagement push ("We miss you at the gym" / "N days since your last session", opening Gym Mode) — Norvya's wording, never yours: the optional reason is one of a fixed list and is only recorded. At most one nudge per member per 14 days (the same dedup as the in-app trainer button and Norvya's own retention job), and the member's notification limits apply. {sent: false, reason} explains a skip: recently_nudged, no_device_token, not_sharing_activity, suppressed, send_failed, in_progress. Gym, fitness and personal-training organisations only (409 not_a_fitness_organisation).
Parameters
EMR vendors also pass vendor (the practice) with a vendor-issued token. EMR vendor tokens
Request body
Content type: application/json
Errors specific to this call
409not_a_fitness_organisationAll responses
200Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service UnavailableCodes every call can return are in Errors & limits.
curl -s -X POST "https://norvya.fly.dev/v1/members/$MEMBER_REF/nudge" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"reason": "string"
}'import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
member_ref = os.environ["MEMBER_REF"]
resp = requests.post(
f"{BASE}/v1/members/{member_ref}/nudge",
headers={"Authorization": f"Bearer {TOKEN}"},
json={
"reason": "string",
},
timeout=30,
)
resp.raise_for_status()
print(resp.json())// Node.js 18+ (ES module). Keep production credentials on your server.
const BASE = "https://norvya.fly.dev";
const member_ref = process.env.MEMBER_REF;
const url = new URL(`${BASE}/v1/members/${encodeURIComponent(member_ref)}/nudge`);
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"reason": "string"
}),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(await res.json());{
"member_ref": "string",
"external_id": "string",
"sent": false,
"reason": "string",
"requested_reason": "string",
"simulated": false
}The response is the shape from the schema, not live data.
Try it in your sandbox
A member's training: workouts, estimated 1RM per lift, weekly sets, load
What the member's own Progress card shows, for their trainer: completed workouts, estimated one-rep max per lift with its trend and series, weekly hard sets per muscle group against the volume landmarks, and the training-load ratio, while the member shares activity with you. The readiness band (ready / caution / rest) only while they share wearables; the recovery score behind it, and any reading, diagnosis or medicine, never. withheld lists what the member keeps from you.
Parameters
EMR vendors also pass vendor (the practice) with a vendor-issued token. EMR vendor tokens
All responses
200Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service UnavailableCodes every call can return are in Errors & limits.
curl -s "https://norvya.fly.dev/v1/members/$MEMBER_REF/training" \
-H "Authorization: Bearer $TOKEN"import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
member_ref = os.environ["MEMBER_REF"]
resp = requests.get(
f"{BASE}/v1/members/{member_ref}/training",
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
resp.raise_for_status()
print(resp.json())// Node.js 18+ (ES module). Keep production credentials on your server.
const BASE = "https://norvya.fly.dev";
const member_ref = process.env.MEMBER_REF;
const url = new URL(`${BASE}/v1/members/${encodeURIComponent(member_ref)}/training`);
const res = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.TOKEN}`,
},
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(await res.json());{
"member_ref": "string",
"external_id": "string",
"shares_activity": false,
"workouts": {},
"window_weeks": 0,
"prs_this_week": 0,
"lifts": [
{}
],
"weekly_sets": {},
"load": {},
"readiness": {},
"withheld": [
"string"
],
"note": "string"
}The response is the shape from the schema, not live data.
Try it in your sandbox
Member retention against your own baseline (aggregate, small cells withheld)
Active in 7 / 30 days, a weekly active trend (8 weeks), and the share of members still active 4, 8 and 12 weeks after joining, against the pre-Norvya retention baseline your organisation entered in Norvya. Members who do not share activity are counted only as not_sharing. Counts from 1 to min_cell - 1 are withheld (complementary cells too). The windows are the engine's fixed ones; there is no period parameter.
All responses
200Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service UnavailableCodes every call can return are in Errors & limits.
curl -s "https://norvya.fly.dev/v1/gym/retention" \
-H "Authorization: Bearer $TOKEN"import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
resp = requests.get(
BASE + "/v1/gym/retention",
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
resp.raise_for_status()
print(resp.json())// Node.js 18+ (ES module). Keep production credentials on your server.
const BASE = "https://norvya.fly.dev";
const url = new URL(`${BASE}/v1/gym/retention`);
const res = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.TOKEN}`,
},
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(await res.json());{
"min_cell": 0,
"enrolled": {
"count": 0,
"suppressed": false
},
"cohort": {
"count": 0,
"suppressed": false
},
"not_sharing": {
"count": 0,
"suppressed": false
},
"active_7d": {
"count": 0,
"suppressed": false
},
"active_30d": {
"count": 0,
"suppressed": false
},
"engagement_rate_30d": 0.0,
"avg_streak_days": 0.0,
"weekly_active_trend": [
{}
],
"retention_curve": {},
"baseline_retention_rate": 0.0,
"vs_baseline": {},
"low_confidence": false,
"definition": "string",
"suppression_rule": "string"
}The response is the shape from the schema, not live data.
Try it in your sandbox
Do your more-engaged members get better results? (aggregate)
Members who share activity and body composition, split at the median number of active days over 90 days, with each tier's average fat-loss quality score and weight lost. Locked (available false, reason building) until 10 members share; a tier with fewer than 5 members with the outcome is not compared. An association, not proof.
All responses
200Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service UnavailableCodes every call can return are in Errors & limits.
curl -s "https://norvya.fly.dev/v1/gym/engagement-vs-results" \
-H "Authorization: Bearer $TOKEN"import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
resp = requests.get(
BASE + "/v1/gym/engagement-vs-results",
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
resp.raise_for_status()
print(resp.json())// Node.js 18+ (ES module). Keep production credentials on your server.
const BASE = "https://norvya.fly.dev";
const url = new URL(`${BASE}/v1/gym/engagement-vs-results`);
const res = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.TOKEN}`,
},
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(await res.json());{
"min_cell": 0,
"available": false,
"reason": "string",
"cohort": {
"count": 0,
"suppressed": false
},
"unlock_at": 0,
"engagement_metric": "string",
"median_active_days": 0,
"comparisons": {},
"low_confidence": false,
"note": "string"
}The response is the shape from the schema, not live data.
Try it in your sandbox
Your trainers' notes about a member
Notes written by your trainers in Norvya's gym screen or through this API, newest first (at most 200). The member does not see trainer notes. Authors' names are not returned.
Parameters
EMR vendors also pass vendor (the practice) with a vendor-issued token. EMR vendor tokens
All responses
200Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service UnavailableCodes every call can return are in Errors & limits.
curl -s "https://norvya.fly.dev/v1/members/$MEMBER_REF/trainer-notes" \
-H "Authorization: Bearer $TOKEN"import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
member_ref = os.environ["MEMBER_REF"]
resp = requests.get(
f"{BASE}/v1/members/{member_ref}/trainer-notes",
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
resp.raise_for_status()
print(resp.json())// Node.js 18+ (ES module). Keep production credentials on your server.
const BASE = "https://norvya.fly.dev";
const member_ref = process.env.MEMBER_REF;
const url = new URL(`${BASE}/v1/members/${encodeURIComponent(member_ref)}/trainer-notes`);
const res = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.TOKEN}`,
},
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(await res.json());{
"member_ref": "string",
"external_id": "string",
"count": 0,
"notes": [
{
"note_ref": "string",
"text": "string",
"author": "string",
"created_at": "string"
}
]
}The response is the shape from the schema, not live data.
Try it in your sandbox
Add a trainer note about a member
A plain-text training note (at most 2000 characters), kept as your organisation's record and shown to your trainers in Norvya's gym screen; the member does not see it. Training notes only: do not record medical information. Attributed in Norvya to this client (shown to trainers as "Partner system: <client name>"), never to a person.
Parameters
EMR vendors also pass vendor (the practice) with a vendor-issued token. EMR vendor tokens
Request body
Content type: application/json (required)
A training note (plain text). The member does not see trainer notes. Do not put medical information here.
All responses
201Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service UnavailableCodes every call can return are in Errors & limits.
curl -s -X POST "https://norvya.fly.dev/v1/members/$MEMBER_REF/trainer-notes" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"text": "Form check on squat: depth improving. Add 5 lb next session."
}'import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
member_ref = os.environ["MEMBER_REF"]
resp = requests.post(
f"{BASE}/v1/members/{member_ref}/trainer-notes",
headers={"Authorization": f"Bearer {TOKEN}"},
json={
"text": "Form check on squat: depth improving. Add 5 lb next session.",
},
timeout=30,
)
resp.raise_for_status()
print(resp.json())// Node.js 18+ (ES module). Keep production credentials on your server.
const BASE = "https://norvya.fly.dev";
const member_ref = process.env.MEMBER_REF;
const url = new URL(`${BASE}/v1/members/${encodeURIComponent(member_ref)}/trainer-notes`);
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"text": "Form check on squat: depth improving. Add 5 lb next session."
}),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(await res.json());{
"member_ref": "string",
"note": {
"note_ref": "string",
"text": "string",
"author": "string",
"created_at": "string"
}
}The response is the shape from the schema, not live data.
Try it in your sandbox
Webhook events
Payloads carry no health data, only a reference to fetch with your token. Each event needs its scope as well as webhooks.manage. Signing and retries
| Event | Scope | When |
|---|---|---|
member.at_risk | fitness.roster.read | A member newly entered churn level high in the daily scan. |
Generated from the Partner API router and developers_catalog.py. The router docstring is the source of each description.