Skip to content

Quickstart

This page walks a new partner through submitting one video for analysis, end to end: request an upload spot, upload the video, confirm it landed, submit the job, poll it, and retrieve the result. It shows just enough of each request and response to follow the flow. For every route's full request and response shape, see the endpoint reference; for every error code, see the error catalog.

Every request in this guide goes to the production base URL:

https://api.fitizens.io

Every request also needs your partner API key, sent as a bearer credential:

Authorization: Bearer ftz_your_key_here

ftz_your_key_here is a placeholder, not a real key. If you do not have one yet, see onboarding and key lifecycle for how to request one from support@fitizens.io.

Prerequisites

  • A partner API key, in the shape shown above.
  • curl and jq, a small command-line JSON processor. The cURL examples below pipe every response through jq to pull out a field (a handle, a job id) and carry it forward as a shell variable into the next command.
  • Python 3.12 or newer with the requests library installed, if you prefer the Python examples. Run each step's Python snippet in the same session or script as the ones before it, so the variables it sets (handle, upload_url, job_id, and so on) are still in scope.
  • A video file to analyze. The service enforces no size or duration ceiling (see limits and quotas), but it also does not check that the file is a genuine video: an unsuitable file is analyzed anyway and comes back as a confident, wrong result rather than an error, so validate your input before you upload it.

Step 1. Request an upload spot

Mint an upload spot: a single-use handle paired with a short-lived signed URL you upload the video bytes to directly. The spot is single-use and expires 15 minutes (900 seconds) after it is minted, in the expires_at field of the response -- see limits and quotas for the full picture. Minting the spot does not send any video bytes; it only hands you somewhere to put them next.

export FITIZENS_KEY="ftz_your_key_here"
export FITIZENS_BASE_URL="https://api.fitizens.io"

RESPONSE=$(curl -s -X POST \
  -H "Authorization: Bearer $FITIZENS_KEY" \
  "$FITIZENS_BASE_URL/v1/uploads")

HANDLE=$(echo "$RESPONSE" | jq -r '.handle')
if [ "$HANDLE" = "null" ] || [ -z "$HANDLE" ]; then
  echo "$RESPONSE" | jq .
  exit 1
fi

UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.upload_url')
EXPIRES_AT=$(echo "$RESPONSE" | jq -r '.expires_at')

echo "handle=$HANDLE expires_at=$EXPIRES_AT"
import requests

api_key = "ftz_your_key_here"
base_url = "https://api.fitizens.io"
headers = {"Authorization": f"Bearer {api_key}"}

response = requests.post(f"{base_url}/v1/uploads", headers=headers)
response.raise_for_status()
upload_spot = response.json()

handle = upload_spot["handle"]
upload_url = upload_spot["upload_url"]
expires_at = upload_spot["expires_at"]
print(f"handle={handle} expires_at={expires_at}")

A successful mint returns 201 with a body like this:

{
  "handle": "3f8a1c2e9b7d4f6a8c1e2b3d4f5a6b7c",
  "upload_url": "https://storage.googleapis.com/<bucket>/3f8a1c2e9b7d4f6a8c1e2b3d4f5a6b7c?<signed-query-parameters>",
  "expires_at": "2026-07-16T19:05:00+00:00"
}

Read upload_url from the response rather than assembling it yourself: the signed query parameters authorizing the write are not reconstructable.

Step 2. Upload the video bytes

Upload the video by issuing a PUT straight to upload_url from step 1; a successful upload returns an empty body with HTTP status 200. That URL is hosted on Google Cloud Storage, not on api.fitizens.io, and it is a bearer credential in its own right -- do not send your Authorization header to it, and do not log the URL. Send the raw video bytes as the request body with the matching Content-Type; do not form-encode them, since that would corrupt a binary file.

curl -s -X PUT \
  --data-binary @video.mp4 \
  -H "Content-Type: video/mp4" \
  "$UPLOAD_URL"

echo "upload complete"
video_path = "video.mp4"  # path to the video you want analyzed

with open(video_path, "rb") as f:
    video_bytes = f.read()

upload_response = requests.put(
    upload_url,
    data=video_bytes,
    headers={"Content-Type": "video/mp4"},
)
upload_response.raise_for_status()
print(f"uploaded {len(video_bytes)} bytes")

Use --data-binary (or, in Python, a data= argument holding raw bytes) -- never -d or a form-encoded body, both of which mangle binary content.

Step 3. Confirm the video landed

Before submitting a job, confirm the upload spot reports the video as uploaded. This also doubles as a check on the upload handle itself: an unrecognized handle returns 404 upload_not_found rather than any status.

CONFIRM_RESPONSE=$(curl -s \
  -H "Authorization: Bearer $FITIZENS_KEY" \
  "$FITIZENS_BASE_URL/v1/uploads/$HANDLE")

UPLOAD_STATUS=$(echo "$CONFIRM_RESPONSE" | jq -r '.status')
if [ "$UPLOAD_STATUS" != "uploaded" ]; then
  echo "$CONFIRM_RESPONSE" | jq .
  exit 1
fi

echo "$CONFIRM_RESPONSE" | jq .
status_response = requests.get(f"{base_url}/v1/uploads/{handle}", headers=headers)
status_response.raise_for_status()
print(status_response.json())
{
  "status": "uploaded"
}

Step 4. Submit the analysis job

Submit the job by naming the upload handle and the exercise being performed. Every job needs a valid exercise_id: fetch the current catalog live from GET /v1/exercises (see the endpoint reference) rather than relying on a list printed anywhere else, since only an id that endpoint currently returns is guaranteed to be accepted. This example uses front-squat, one of the ids published there.

JOB_RESPONSE=$(curl -s -X POST \
  -H "Authorization: Bearer $FITIZENS_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"upload_handle\":\"$HANDLE\",\"exercise_id\":\"front-squat\"}" \
  "$FITIZENS_BASE_URL/v1/jobs")

JOB_ID=$(echo "$JOB_RESPONSE" | jq -r '.job_id')
if [ "$JOB_ID" = "null" ] || [ -z "$JOB_ID" ]; then
  echo "$JOB_RESPONSE" | jq .
  exit 1
fi

echo "job_id=$JOB_ID"
job_payload = {"upload_handle": handle, "exercise_id": "front-squat"}
job_response = requests.post(
    f"{base_url}/v1/jobs",
    headers={**headers, "Content-Type": "application/json"},
    json=job_payload,
)
job_response.raise_for_status()
job = job_response.json()
job_id = job["job_id"]
print(f"job_id={job_id} status={job['status']}")

A successful submission returns 201:

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

Submitting the handle again with the same exercise_id is safe: it is idempotent and returns the original job_id. Submitting it with a different exercise_id is refused instead, because a handle can only ever be claimed by one job.

If the request is rejected, you get the API's standard error envelope instead, for example a malformed body:

{
  "error": {
    "code": "malformed_request",
    "message": "`upload_handle` is required and must be a non-empty string."
  }
}

code is the stable string to branch your handling on. See the error catalog for every code the API can return and what to do about each one.

Step 5. Poll job status

Poll GET /v1/jobs/{job_id} every 10 seconds until the job reaches a terminal status. While it is in flight you will see queued and then processing; a poll must be prepared to see any of five statuses, and must stop on all three terminal ones -- completed, failed, and cancelled -- not only on success. A job that failed or was cancelled will never become completed no matter how long you keep polling it.

MAX_POLLS=60

POLL=0
while [ "$POLL" -lt "$MAX_POLLS" ]; do
  POLL_RESPONSE=$(curl -s \
    -H "Authorization: Bearer $FITIZENS_KEY" \
    "$FITIZENS_BASE_URL/v1/jobs/$JOB_ID")
  STATUS=$(echo "$POLL_RESPONSE" | jq -r '.status')
  echo "attempt $((POLL + 1)): status=$STATUS"

  if [ "$STATUS" = "completed" ] || [ "$STATUS" = "failed" ] || [ "$STATUS" = "cancelled" ]; then
    break
  fi

  if [ "$STATUS" = "null" ] || [ -z "$STATUS" ]; then
    echo "$POLL_RESPONSE" | jq .
    exit 1
  fi

  POLL=$((POLL + 1))
  sleep 10
done

if [ "$POLL" -ge "$MAX_POLLS" ]; then
  echo "timed out waiting for the job to finish; stop polling and check back later"
fi

echo "$POLL_RESPONSE" | jq .
import time

TERMINAL_STATUSES = {"completed", "failed", "cancelled"}
MAX_POLLS = 60  # ~10 minutes at a 10-second interval

status = "queued"
job = None
for attempt in range(MAX_POLLS):
    poll_response = requests.get(f"{base_url}/v1/jobs/{job_id}", headers=headers)
    poll_response.raise_for_status()
    job = poll_response.json()
    status = job["status"]
    print(f"attempt {attempt + 1}: status={status}")
    if status in TERMINAL_STATUSES:
        break
    time.sleep(10)
else:
    raise TimeoutError(
        f"job {job_id} did not finish within {MAX_POLLS} polls; "
        "stop polling and check back later, or contact support if this persists"
    )

Both loops above are bounded: after MAX_POLLS attempts with no terminal status, stop polling. The job itself is still running in the background and is not lost -- its result stays retrievable by the same job id whenever you next poll it, so check back later (or, if it still has not finished after a much longer wait, contact support).

Typical turnaround

Most jobs finish in about 2-3 minutes. Treat that as a rough expectation for sizing a polling loop, not a guarantee: it is not a figure the API commits to, so do not build logic that assumes a job will always finish by a fixed time.

Polling much more often than every 10 seconds gives you no benefit -- status does not change any faster because you ask more frequently -- and aggressive polling may get you rate-limited. There is no advertised poll rate limit to size against today; just avoid hammering the endpoint.

While a job is in flight, the response carries no fields beyond job_id and status:

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

If a job reaches failed instead of completed, the response adds error_code and message in place of a result:

{
  "job_id": "b3a1c2d4-8f2a-4e11-9c3d-1a2b3c4d5e6f",
  "status": "failed",
  "error_code": "video_processing_failed",
  "message": "The video could not be processed for analysis."
}

This error_code and message pair 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; there is no partner-facing way to cause a cancellation yourself, but a poller must still recognize it and stop.

Step 6. Retrieve the completed result

The final poll's response in step 5 already carries the result once the job is completed: the loop above stores it on job["result"], and you do not need another call to see it for the first time. Fetching it again by job id, on its own, is still useful, for example from a different process or later on -- this is what that standalone fetch looks like:

curl -s \
  -H "Authorization: Bearer $FITIZENS_KEY" \
  "$FITIZENS_BASE_URL/v1/jobs/$JOB_ID" | jq .
result_response = requests.get(f"{base_url}/v1/jobs/{job_id}", headers=headers)
result_response.raise_for_status()
completed_job = result_response.json()

if completed_job["status"] != "completed":
    raise RuntimeError(f"job {job_id} is not completed (status={completed_job['status']})")

result = completed_job["result"]
print(result["overall_feedback"]["brief_summary"])
for rep in result["repetitions"]:
    print(rep["attempt_number"], rep["is_valid_rep"], rep["quality_score"])
{
  "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."
      }
    }
  }
}

Each entry in repetitions carries is_valid_rep, quality_score, issues, and strengths for that attempt; overall_feedback summarizes the whole set, starting with brief_summary.

Two details are worth catching now, because they are easy to guess wrong. quality_score is a percentage on a 0-100 scale, not a 0-1 ratio: the 82.0 above means 82%. And issues, strengths and key_improvement_areas are lists of objects rather than lists of sentences, so the readable text sits in each entry's description field. All of that text is plain English. For the full field-by-field breakdown, see the endpoint reference.

That is the whole flow: an upload spot, an upload, a confirmation, a submission, a poll, and a result. From here, the endpoint reference covers every route in full, and the error catalog covers every code you might see along the way.