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, and reports that key's usage against its analysis limit. Use it as a cheap way to verify a key is wired up correctly before calling anything else, or to check your remaining headroom before submitting a batch of jobs.

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",
  "usage": {
    "limit": 50,
    "used": 12,
    "remaining": 38
  }
}

partner_id, partner_name, and status identify the key exactly as before; usage is the key's analysis limit and where it currently stands. used counts jobs that have actually run the engine (status completed or failed) -- a job still queued or processing, or one cancelled before it ran, is not counted there yet. remaining is net of those still-live jobs, though: each one holds its unit of the limit from the moment it is accepted, so during a burst of in-flight submissions used + remaining can sit below limit until those jobs finish, and remaining is always the figure your next submission is actually checked against.

A key with no configured limit is uncapped: it reports limit and remaining as null rather than omitting them, so the shape of the usage object never changes between a capped and an uncapped key.

{
  "usage": {
    "limit": null,
    "used": 34,
    "remaining": null
  }
}

A key can also be capped at zero -- a real limit that permits no analyses at all. That reports integer zeros, not null, so it stays distinguishable from an uncapped key:

{
  "usage": {
    "limit": 0,
    "used": 0,
    "remaining": 0
  }
}

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.

If your key carries an analysis budget that is used up, the submission is refused with a 429 usage_limit_exceeded error and no job is created, so it does not count against the budget. Read your remaining budget from the usage object on GET /v1/identity, and see limits and quotas for how the budget is counted. A key with no budget never returns this 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. A job reaches cancelled either through operator action or because you cancelled it yourself while it was still queued, via POST /v1/jobs/{job_id}/cancel.

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.

POST /v1/jobs/{job_id}/cancel

Cancels one of your own jobs while it is still queued, for example to withdraw a job submitted by mistake, or one that has sat queued longer than you are willing to wait. Cancellation applies only before analysis starts: once a job is processing it runs to completion, and there is no way to abort it mid-analysis.

Requires: Authorization: Bearer <key>

The request takes no body. Anything you send as one is ignored.

curl -X POST \
  -H "Authorization: Bearer <key>" \
  https://api.fitizens.io/v1/jobs/b3a1c2d4-8f2a-4e11-9c3d-1a2b3c4d5e6f/cancel
import requests

headers = {"Authorization": "Bearer <key>"}
job_id = "b3a1c2d4-8f2a-4e11-9c3d-1a2b3c4d5e6f"

response = requests.post(
    f"https://api.fitizens.io/v1/jobs/{job_id}/cancel",
    headers=headers,
)
response.raise_for_status()

print(response.json()["status"])

A successful cancellation returns 200:

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

Cancellation is refused when the job has moved past the point where it applies, with the reason distinguished by error code:

Job status when you call Response
queued 200, job becomes cancelled
processing 409 job_already_started
completed, failed, cancelled 409 job_already_finished

Cancelling a job that is already cancelled is refused as job_already_finished rather than succeeding again, so cancellation is not idempotent: treat a 409 on a retried cancel as confirmation the job is no longer queued, not as a failure.

As with the other job routes, a job_id that does not exist and one belonging to a different partner both return 404 job_not_found, and a cancel aimed at another partner's job never touches that job.