Skip to content

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.

curl https://api.fitizens.io/livez
import requests

response = requests.get("https://api.fitizens.io/livez")
response.raise_for_status()
print(response.json())
{
  "status": "ok",
  "version": "<deployed-version>"
}

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>

curl -H "Authorization: Bearer <key>" \
  https://api.fitizens.io/v1/identity
import requests

headers = {"Authorization": "Bearer <key>"}

response = requests.get("https://api.fitizens.io/v1/identity", headers=headers)
response.raise_for_status()
print(response.json())
{
  "partner_id": "acme-labs",
  "partner_name": "Acme Labs",
  "status": "active"
}

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>

curl -H "Authorization: Bearer <key>" \
  https://api.fitizens.io/v1/exercises
import requests

headers = {"Authorization": "Bearer <key>"}

response = requests.get("https://api.fitizens.io/v1/exercises", headers=headers)
response.raise_for_status()
catalog = response.json()

for exercise in catalog["exercises"]:
    print(exercise["exercise_id"], "--", exercise["name"])
{
  "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>

curl -X POST -H "Authorization: Bearer <key>" \
  https://api.fitizens.io/v1/uploads
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:

curl -X PUT --data-binary @video.mp4 \
  -H "Content-Type: video/mp4" \
  "https://storage.googleapis.com/<bucket>/3f8a1c2e9b7d4f6a8c1e2b3d4f5a6b7c?<signed-query-parameters>"
import requests

# upload_url comes from the POST /v1/uploads response above.
with open("video.mp4", "rb") as f:
    response = requests.put(
        upload_url,
        data=f,
        headers={"Content-Type": "video/mp4"},
    )
response.raise_for_status()

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>

curl -H "Authorization: Bearer <key>" \
  https://api.fitizens.io/v1/uploads/3f8a1c2e9b7d4f6a8c1e2b3d4f5a6b7c
import requests

headers = {"Authorization": "Bearer <key>"}
handle = "3f8a1c2e9b7d4f6a8c1e2b3d4f5a6b7c"

response = requests.get(
    f"https://api.fitizens.io/v1/uploads/{handle}",
    headers=headers,
)
response.raise_for_status()
print(response.json()["status"])
{
  "status": "minted"
}

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:

{
  "upload_handle": "3f8a1c2e9b7d4f6a8c1e2b3d4f5a6b7c",
  "exercise_id": "90-90-hip-stretch"
}

Requires: Authorization: Bearer <key>

curl -X POST -H "Authorization: Bearer <key>" \
  -H "Content-Type: application/json" \
  -d '{"upload_handle":"3f8a1c2e9b7d4f6a8c1e2b3d4f5a6b7c","exercise_id":"90-90-hip-stretch"}' \
  https://api.fitizens.io/v1/jobs
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']}")
{
  "job_id": "b3a1c2d4-8f2a-4e11-9c3d-1a2b3c4d5e6f",
  "status": "queued"
}

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>

curl -H "Authorization: Bearer <key>" \
  https://api.fitizens.io/v1/jobs/b3a1c2d4-8f2a-4e11-9c3d-1a2b3c4d5e6f
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:

{
  "job_id": "b3a1c2d4-8f2a-4e11-9c3d-1a2b3c4d5e6f",
  "status": "processing"
}

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>

curl -H "Authorization: Bearer <key>" \
  https://api.fitizens.io/v1/jobs
import requests

headers = {"Authorization": "Bearer <key>"}

response = requests.get("https://api.fitizens.io/v1/jobs", headers=headers)
response.raise_for_status()

for job in response.json()["jobs"]:
    print(job["job_id"], job["status"])
{
  "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:

{
  "jobs": []
}

That {"jobs": []} response is a 200, a normal success case, not a failure to be handled specially.