Endpoint reference¶
This page documents every partner-facing operation the API exposes: what
each one is for, whether it needs an API key, and a representative request
and response. Every route lives under the base URL
https://api.fitizens.io, with one exception noted where it occurs: the
signed URL you upload a video to is hosted on Google Cloud Storage, not on
the API itself.
For the meaning of any error code you see in a response, see the error catalog.
GET /livez¶
A liveness probe. It confirms the service process is up and answers requests; it carries no information about your account or any job. No API key is required.
GET /v1/identity¶
Confirms which partner an API key belongs to and whether that key is currently active. Use it as a cheap way to verify a key is wired up correctly before calling anything else.
Requires: Authorization: Bearer <key>
GET /v1/exercises¶
Returns the catalog of exercises the analysis pipeline currently
recognizes. This catalog changes over time, so always fetch it live from
this endpoint immediately before you need it rather than relying on a list
printed in these docs or cached from a previous call. Only an
exercise_id returned by this endpoint is guaranteed to be accepted by
job submission.
Requires: Authorization: Bearer <key>
{
"exercises": [
{
"exercise_id": "90-90-hip-stretch",
"name": "90/90 Hip Stretch",
"description": "A seated hip mobility exercise that targets internal and external hip rotation."
}
]
}
The real response carries the full catalog, not just one entry.
POST /v1/uploads¶
Mints an upload spot: a handle you will attach to a job, paired with a short-lived signed URL you upload the video bytes to directly. The API itself never receives the video body on this call; it only hands you somewhere to put it.
Requires: Authorization: Bearer <key>
import requests
headers = {"Authorization": "Bearer <key>"}
response = requests.post("https://api.fitizens.io/v1/uploads", headers=headers)
response.raise_for_status()
upload_spot = response.json()
handle = upload_spot["handle"]
upload_url = upload_spot["upload_url"]
print(f"handle={handle} expires_at={upload_spot['expires_at']}")
{
"handle": "3f8a1c2e9b7d4f6a8c1e2b3d4f5a6b7c",
"upload_url": "https://storage.googleapis.com/<bucket>/3f8a1c2e9b7d4f6a8c1e2b3d4f5a6b7c?<signed-query-parameters>",
"expires_at": "2026-07-16T19:05:00+00:00"
}
The object name in upload_url is the handle itself. Read the URL from the
response rather than assembling it yourself: the signed query parameters are
what authorize the write, and they are not reconstructable.
Upload the video by issuing a PUT straight to upload_url. That URL is
hosted on Google Cloud Storage, not on api.fitizens.io, because it is a
signed URL your client talks to directly:
Send the raw bytes as the request body: --data-binary in cURL, or a
data= argument in Python. Passing the open file object rather than
f.read() streams the video from disk instead of holding all of it in
memory at once, which matters because the service sets no ceiling on how
large a video may be. A form-encoded body (-d, or files=) corrupts a
binary file. Do not send your Authorization header to this URL; the
signed parameters already authorize the write, and the URL is a bearer
credential in its own right.
GET /v1/uploads/{handle}¶
Reports the current status of an upload spot, so you can confirm a video
finished uploading before submitting a job against it. A spot moves
through the observable states minted, uploaded, claimed, and
expired.
Requires: Authorization: Bearer <key>
An unrecognized handle returns 404 upload_not_found rather than any of
the four states above; see the error catalog for the envelope shape.
POST /v1/jobs¶
Submits an analysis job against a previously uploaded video. The request body names the upload spot and the exercise being performed:
Requires: Authorization: Bearer <key>
import requests
headers = {"Authorization": "Bearer <key>"}
response = requests.post(
"https://api.fitizens.io/v1/jobs",
headers={**headers, "Content-Type": "application/json"},
json={
"upload_handle": "3f8a1c2e9b7d4f6a8c1e2b3d4f5a6b7c",
"exercise_id": "90-90-hip-stretch",
},
)
response.raise_for_status()
job = response.json()
print(f"job_id={job['job_id']} status={job['status']}")
An older request shape that passed a video_url field directly is
refused: it fails with a 400 malformed_request error, because
upload_handle is required. Upload the video through the two-step
handle flow above before submitting a job.
Submitting a job against a handle that already has a job attached is
handled in two different ways depending on what you send. If the
exercise_id matches the one the handle was originally claimed with, the
call is idempotent: it returns 201 with the original job_id rather than
creating a second job. If the exercise_id differs from the original
claim, the call is refused with a 409 upload_already_claimed error.
GET /v1/jobs/{job_id}¶
Polls a job's status and, once it is finished, retrieves its result. A
response always carries job_id and status. The status field takes
one of five values: queued, processing, completed, failed, or
cancelled.
Requires: Authorization: Bearer <key>
import requests
headers = {"Authorization": "Bearer <key>"}
job_id = "b3a1c2d4-8f2a-4e11-9c3d-1a2b3c4d5e6f"
response = requests.get(
f"https://api.fitizens.io/v1/jobs/{job_id}",
headers=headers,
)
response.raise_for_status()
job = response.json()
if job["status"] == "completed":
print(job["result"]["overall_feedback"]["brief_summary"])
elif job["status"] == "failed":
print(job["error_code"], "--", job["message"])
else:
print(job["status"])
This shows a single read. For a polling loop that stops on every terminal status, see the quickstart.
While a job is in flight, the response carries no extra fields beyond
job_id and status:
Typical turnaround
Most jobs finish in about 2-3 minutes. Treat that as a rough expectation for sizing a polling loop, not a promised turnaround time: it is not a figure the API commits to.
Once a job reaches completed, the response adds a result key holding
the analysis:
{
"job_id": "b3a1c2d4-8f2a-4e11-9c3d-1a2b3c4d5e6f",
"status": "completed",
"result": {
"repetitions": [
{
"attempt_number": 1,
"start_time_ms": 1200,
"end_time_ms": 3400,
"is_valid_rep": true,
"quality_score": 82.0,
"issues": [
{
"category": "knee_alignment",
"name": "Knees cave inward",
"description": "Knees cave inward at the bottom of the movement.",
"correction": "Track the knees over the toes on the descent.",
"severity": "moderate",
"impact_on_score": -12.0,
"evidence_timestamp_ms": 2800
}
],
"strengths": [
{
"category": "tempo",
"name": "Consistent tempo",
"description": "Consistent tempo throughout the repetition."
}
]
}
],
"overall_feedback": {
"brief_summary": "Solid form overall, with one recurring knee-alignment issue.",
"key_improvement_areas": [
{
"priority": 1,
"category": "knee_alignment",
"name": "Knees cave inward",
"description": "The knees drift inward as the hips descend.",
"correction": "Keep the knees tracking over the toes on the descent.",
"impact": "moderate",
"pattern_of_occurrence": "recurring",
"affected_reps": [1]
},
{
"priority": 2,
"category": "stance",
"name": "Narrow foot placement",
"description": "The feet sit slightly inside shoulder width, leaving the knees little room to track outward.",
"correction": "Set the feet at shoulder width before starting the descent.",
"impact": "minor",
"pattern_of_occurrence": "isolated",
"affected_reps": [1]
}
],
"strengths": ["Good depth and consistent pacing across repetitions."],
"visibility_context": {
"camera_angle": "frontal",
"recommended_angle": "diagonal_front",
"angle_recommendation": "A diagonal-front angle would make knee tracking easier to judge."
}
}
}
}
A completed job's result is retrievable by its job id. All feedback text is a plain English string; there is no per-language variant to select between.
Reading the result¶
quality_score is a percentage on a 0-100 scale, not a 0-1 ratio: the
82.0 above means 82%, and a perfect repetition scores 100. Compare it
against thresholds on that scale, and divide by 100 before feeding it to
anything that expects a fraction.
Most of the fields that read like sentences are objects, not strings. A
repetition's issues and strengths are each a list of objects, so the
human-readable sentence lives in description, not in the list entry
itself: rep["issues"][0]["description"], never rep["issues"][0]. The
same holds for overall_feedback.key_improvement_areas and
overall_feedback.visibility_context. The one exception is
overall_feedback.strengths, which really is a list of plain strings.
Two more shapes are worth knowing before you write a parser against them.
key_improvement_areas always carries two or three entries, ordered by a
priority of 1 to 3, so code that assumes a single area will drop
feedback. And impact_on_score is a negative number: it is the penalty an
issue already applied to quality_score, not a figure to subtract again.
If a job reaches failed instead, the response adds error_code and
message describing why, in place of result:
{
"job_id": "b3a1c2d4-8f2a-4e11-9c3d-1a2b3c4d5e6f",
"status": "failed",
"error_code": "video_processing_failed",
"message": "The video could not be processed for analysis."
}
The two failure reasons you may see are video_processing_failed
("The video could not be processed for analysis.") and
provider_temporarily_unavailable ("The analysis provider was
temporarily unavailable and retries were exhausted."). Any other reason
falls back to the generic message "The analysis job failed." This
error_code is a job-status field, not an entry in the error catalog:
it describes why an accepted job did not finish, not why a request was
rejected.
A status of cancelled adds no extra field beyond job_id and
status. There is no partner-facing way to cancel a job; a job only
reaches cancelled through operator action, never through anything your
integration can call.
Requesting a job_id that does not exist, and requesting one that
belongs to a different partner, are indistinguishable from your side:
both return 404 job_not_found.
GET /v1/jobs¶
Lists jobs submitted by the calling partner. Each entry is a
{job_id, status} summary only; it never carries a result, so use
GET /v1/jobs/{job_id} to retrieve the outcome of any specific job.
Requires: Authorization: Bearer <key>
{
"jobs": [
{"job_id": "b3a1c2d4-8f2a-4e11-9c3d-1a2b3c4d5e6f", "status": "processing"},
{"job_id": "7c9e6f2a-1b3d-4a5e-8f6c-2d3e4f5a6b7c", "status": "completed"}
]
}
A partner with no submitted jobs, or none currently outstanding, gets back an empty list rather than an error:
That {"jobs": []} response is a 200, a normal success case, not a
failure to be handled specially.