📄 API Documentation v1

Overview

The API provides read-only access to paddling test data and related reference tables. All endpoints live under the /api/v1 base path and return JSON.

Base URL https://cloud.quko.es/api/v1

Quick Start Example

Fetch results from the API and display them in your application.

 fetch_data.js
const API_BASE = "https://cloud.quko.es/api/v1";
const PUBLIC_KEY = "YOUR_PUBLIC_KEY";
const SECRET_KEY = "YOUR_SECRET_KEY";

async function fetchResults(page = 1) {
  const res = await fetch(`${API_BASE}/results?page=${page}&per_page=10`, {
    headers: { "Authorization": `Bearer ${PUBLIC_KEY}.${SECRET_KEY}` }
  });
  return res.json();
}
 display_data.html
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom"></script>

<div style="position:relative;height:50vh;width:100%;margin-top:20px;background-color:white;">
  <canvas id="strokesPlot"></canvas>
</div>

<div id="tabs" style="margin-top:20px;display:flex;gap:10px;flex-wrap:wrap;justify-content:center;">
  <button style="background:transparent;color:#FF33FF;border:2px solid #FF33FF;border-radius:5px;padding:5px 10px;" onclick="plotData('filtered_speed')">Velocity</button>
  <button style="background:transparent;color:#33AA00;border:2px solid #33AA00;border-radius:5px;padding:5px 10px;" onclick="plotData('rate')">Stroke Rate</button>
  <button style="background:transparent;color:#FF6633;border:2px solid #FF6633;border-radius:5px;padding:5px 10px;" onclick="plotData('stroke_distance')">Stroke Distance</button>
  <button style="background:transparent;color:#00B3E6;border:2px solid #00B3E6;border-radius:5px;padding:5px 10px;" onclick="plotData('max_accel_x')">Max. Accel.</button>
  <button style="background:transparent;color:#999966;border:2px solid #999966;border-radius:5px;padding:5px 10px;" onclick="plotData('min_accel_x')">Min. Accel.</button>
  <button style="background:transparent;color:#E6B333;border:2px solid #E6B333;border-radius:5px;padding:5px 10px;" onclick="plotData('max_pitch')">Pitch</button>
  <button style="background:transparent;color:#3366E6;border:2px solid #3366E6;border-radius:5px;padding:5px 10px;" onclick="plotData('max_roll')">Roll</button>
</div>

<script>
  const jsonFileUrls = ["/static/json/example1.json"];
  let loadedFiles = [], chartInstance = null;
  const colors = ['#FF6633', '#00B3E6', '#E6B333', '#3366E6', '#999966'];
  const magnitudeLabels = {
    filtered_speed: 'Velocity [m/s]', rate: 'Stroke Rate [spm]',
    stroke_distance: 'Stroke Distance [m]', max_accel_x: 'Max. Accel. [m/s²]',
    min_accel_x: 'Min. Accel. [m/s²]', max_pitch: 'Pitch [º]', max_roll: 'Roll [º]'
  };

  window.onload = async function() {
    loadedFiles = [];
    for (let i = 0; i < jsonFileUrls.length; i++) {
      try {
        const r = await fetch(jsonFileUrls[i]);
        const json = await r.json();
        const displayName = json.data.competition || jsonFileUrls[i].split('/').pop();
        loadedFiles.push({ name: displayName, strokes: json.data.data_strokes.strokes, vesselType: json.data.vessel?.type || 'K', color: colors[i % colors.length] });
      } catch (e) { console.error('Error loading JSON file:', jsonFileUrls[i], e); }
    }
    if (loadedFiles.length > 0) plotData('filtered_speed');
  };

  const mandatoryAssetImg = new Image();
  mandatoryAssetImg.src = "https://dev.quko.es/static/images/logo-large-nobg.png";

  const customPlugin = {
    id: 'dataplugin',
    beforeDraw: (chart) => {
      if (mandatoryAssetImg.complete) {
        const { ctx, chartArea } = chart;
        if (!chartArea) return;
        const imgWidth = 125, imgHeight = 47, margin = 15;
        ctx.save();
        ctx.globalAlpha = 0.85;
        ctx.drawImage(mandatoryAssetImg, chartArea.right - imgWidth - margin, chartArea.bottom - imgHeight - margin, imgWidth, imgHeight);
        ctx.restore();
      } else { mandatoryAssetImg.onload = () => chart.draw(); }
    }
  };

  function plotData(magnitude) {
    if (loadedFiles.length === 0) return alert("No data loaded yet.");
    const ctx = document.getElementById('strokesPlot');
    if (Chart.getChart('strokesPlot')) Chart.getChart('strokesPlot').destroy();
    const datasets = [];
    loadedFiles.forEach(file => {
      const base = { borderColor: file.color, backgroundColor: file.color, showLine: true, pointRadius: 0, pointHitRadius: 10 };
      if (magnitude === 'stroke_distance') {
        if (file.vesselType === 'C') {
          datasets.push({ ...base, label: `${file.name} - Stroke Distance`,
            data: file.strokes.filter(s => s.stroke_distance != null).map(s => ({ x: s.distance, y: s.stroke_distance })) });
        } else {
          datasets.push({ ...base, label: `${file.name} - Left Stroke`,
            data: file.strokes.filter(s => s.left_stroke_distance != null).map(s => ({ x: s.distance, y: s.left_stroke_distance })) });
          datasets.push({ ...base, label: `${file.name} - Right Stroke`, borderDash: [5, 5],
            data: file.strokes.filter(s => s.right_stroke_distance != null).map(s => ({ x: s.distance, y: s.right_stroke_distance })) });
        }
      } else {
        datasets.push({ ...base, label: `${file.name} - ${magnitudeLabels[magnitude] || magnitude.replace(/_/g, ' ')}`,
          data: file.strokes.filter(s => s[magnitude] != null).map(s => ({ x: s.distance, y: s[magnitude] })) });
      }
    });
    chartInstance = new Chart(ctx, {
      type: 'line',
      data: { datasets },
      plugins: [customPlugin],
      options: {
        normalized: true, responsive: true, maintainAspectRatio: false, parsing: false,
        hover: { mode: 'nearest', intersect: true },
        plugins: {
          tooltip: { mode: 'nearest', callbacks: { label: (t) => t.dataset.label + ': ' + t.parsed.y.toFixed(2) + ' @ ' + t.parsed.x.toFixed(2) } },
          legend: { labels: { pointStyle: 'rectRounded', usePointStyle: true, padding: 15 } },
          zoom: { pan: { enabled: true, mode: 'x' }, zoom: { wheel: { enabled: true, speed: 0.05 }, pinch: { enabled: true }, mode: 'x' }, limits: { x: { min: 'original', max: 'original' } } }
        },
        scales: {
          x: { type: 'linear', title: { display: true, text: 'Distance' } },
          y: { title: { display: true, text: magnitudeLabels[magnitude] || magnitude.replace(/_/g, ' ') } }
        }
      }
    });
  }
</script>

Authentication

The API uses key-pair authentication. You receive a public key (identifier) and a secret key (credential) from the developer dashboard.

Full authentication Secret key

Most endpoints require your secret key in the Authorization header:

curl -H "Authorization: Bearer YOUR_PUBLIC_KEY.YOUR_SECRET_KEY" \
     https://cloud.quko.es/api/v1/results

Public-key-only endpoints Public key

Reference table endpoints (vessels, categories, paces) accept either the public key or the secret key:

curl -H "Authorization: Bearer YOUR_PUBLIC_KEY" \
     https://cloud.quko.es/api/v1/vessels
⚠ Keep your secret key safe. Never expose it in client-side code or public repositories. If compromised, revoke it immediately from the dashboard and generate a new pair.

Rate Limits

All API endpoints are rate-limited on a per-key basis. The default limit is 100 requests per minute. If you exceed the limit you will receive a 429 Too Many Requests response.

Error Handling

Errors return a JSON body with error and message fields:

{
  "error": "Not found",
  "message": "Result not found."
}
StatusMeaning
400Bad request – invalid or missing parameters
401Unauthorized – missing or invalid API key
403Forbidden – key valid but insufficient permissions
404Not found – resource doesn't exist or access denied
413Payload too large – uploaded file exceeds the maximum size
422Unprocessable – the uploaded session file could not be decoded/processed
429Rate limit exceeded
500Internal server error

Results (Tests / Sessions)

GET /api/v1/results Secret key

Returns a paginated, filterable list of results the authenticated user has access to. The response never includes raw sensor data.

Query parameters
ParameterTypeDescription
pageintPage number
per_pageintResults per page
start_dateYYYY-MM-DDFilter from this date
end_dateYYYY-MM-DDFilter until this date
start_datetimeISO 8601Filter from this datetime. Use the T separator, e.g. 2025-03-15T14:00
end_datetimeISO 8601Filter until this datetime. Use the T separator, e.g. 2025-03-15T18:30
vessel_idintFilter by vessel
track_idintFilter by track
category_idintFilter by category
pace_idintFilter by pace
hardware_idintFilter by hardware device
athlete_idintFilter by athlete (result must include this athlete)
distintFilter by distance
competitionstringPartial match on competition name
descriptionstringPartial match on description
sortstringSort field: date (default), dist, time, creation_date
orderstringasc or desc (default)
Example response
{
  "data": [
    {
      "id": 42,
      "dist": 1000,
      "date": "2025-03-15",
      "time_of_day": "10:42:07+01:00",
      "competition": "Spring Cup",
      "description": "Final A K1",
      "duration": 243.7,
      "creation_date": "2025-03-15",
      "software_version": "2.1.0",
      "hardware": "PaddleLogger v3",
      "owner": { "id": 7, "name": "García, Ana" },
      "track": { "id": 3, "name": "Idroscalo di Milano" },
      "category": { "id": 1, "name": "Senior" },
      "vessel": { "id": 2, "type": "K1", "npeople": 1 },
      "pace": { "id": 1, "name": "Competition" },
      "athletes": [
        { "id": 12, "full_name": "Ana García López" }
      ]
    }
  ],
  "pagination": {
    "page": 1, "per_page": 20, "total": 1,
    "pages": 1, "has_next": false, "has_prev": false
  }
}
GET /api/v1/results/{id} Secret key

Returns full detail for a single result including processed stroke data (xeraJSON format), meteorological conditions, and athlete details. Raw sensor data is never exposed.

Additional response fields
FieldTypeDescription
data_strokesobject / nullProcessed stroke-by-stroke data in xeraJSON format
external_athletesobject / nullExternal athlete metadata (if any)
meteoobject / nullWeather conditions at time of test
athletes_detailobject / nullOrdered athlete names & surnames
Meteo object shape
{
  "meteo": {
    "wind_speed": 3.2,
    "wind_direction": 180,
    "air_temperature": 22.5,
    "water_temperature": 18.0
  }
}
DELETE /api/v1/results/{id} Secret key

Permanently deletes a result the authenticated user has access to, together with its athlete and meteo associations. Your role must allow modifications.

Example request
curl -X DELETE https://cloud.quko.es/api/v1/results/137 \
     -H "Authorization: Bearer YOUR_PUBLIC_KEY.YOUR_SECRET_KEY"
Example response
{
  "data": { "id": 137, "deleted": true }
}
Status codes
CodeMeaning
200The result was deleted.
401Missing or invalid credentials (this endpoint needs Bearer public.secret).
403Your role is not allowed to delete sessions.
404The result does not exist or you do not have access to it.

Upload a Session (Kosoku devices)

POST /api/v1/results Secret key

Upload and process a new session from a Kosoku device file (.qk) in a single call. The new result is owned by the authenticated user. The start of the effort is detected automatically, and the session date & time are read from the device clock. Your role must be allowed to create sessions.

Request format: multipart/form-data with two parts — file (the binary .qk) and metadata (a JSON string).
Metadata fields
FieldTypeDescription
distanceintRace distance in metres (50–1000). Provide either this…
duration_msintor the race duration in milliseconds (1–300000). Exactly one of the two is required.
vessel_idintRequired. Vessel id (see Vessels).
track_idintRequired. Track id — must be one you have access to.
category_idintRequired. Category id.
pace_idintRequired. Pace id.
athletesarrayRequired. One entry per seat, in boat order; length must equal the vessel crew size. See below.
competitionstringOptional. Max 64 characters.
descriptionstringOptional. Max 64 characters.
importantboolOptional. Flag the session for later review.
meteoobjectOptional weather conditions: wind_speed (0–20), wind_direction (0–359), air_temperature (-20–50), water_temperature (0–40).
Specifying athletes

Each entry of athletes is one seat, in order:

FormMeaning
12 or {"id": 12}Internal athlete — must be one you have access to (and with an active subscription).
{"id": -1, "name": "A", "surname": "B", "weight": 75}External athlete entered by hand. The id may be omitted; name is required, surname/weight (0–150 kg) optional.

Internal ids must be unique. Unless your account has the unlimited externals privilege, at most half of the crew may be external athletes.

Example request (curl)

A C2 (two-seater) where the first seat is an athlete from your database (id 12) and the second is an external paddler entered by hand.

curl -X POST https://cloud.quko.es/api/v1/results \
     -H "Authorization: Bearer YOUR_PUBLIC_KEY.YOUR_SECRET_KEY" \
     -F "file=@session.qk" \
     -F 'metadata={
           "distance": 1000,
           "vessel_id": 2,
           "track_id": 3,
           "category_id": 1,
           "pace_id": 1,
           "competition": "Spring Cup",
           "important": true,
           "meteo": {"wind_speed": 3, "wind_direction": 180},
           "athletes": [
             12,
             {"id": -1, "name": "John", "surname": "Doe", "weight": 80}
           ]
         }'
Example response (201 Created)
{
  "data": {
    "id": 137,
    "dist": 1000,
    "date": "2025-03-15",
    "time_of_day": "10:42:07+01:00",
    "duration": 243.7,
    "software_version": "0.274 β",
    "vessel": { "id": 2, "type": "C", "npeople": 2 },
    "track": { "id": 3, "name": "Idroscalo di Milano" },
    "athletes_count": 2
  }
}
Errors & their causes

Every error returns a JSON body with error and message fields. The message identifies the exact problem.

StatusCause
400The file part is missing or empty.
400The uploaded filename does not end in .qk.
400The metadata part is missing, is not valid JSON, or is not a JSON object.
400Neither or both of distance / duration_ms were given (exactly one is required).
400distance outside 50–1000 m, or duration_ms outside 1–300000 ms, or non-numeric.
400competition / description is not a string or exceeds 64 characters.
400A required field is missing: vessel_id, track_id, category_id or pace_id.
400vessel_id, category_id or pace_id does not exist, or the vessel type is unsupported.
400A meteo value is non-numeric or out of range (see field ranges above).
400athletes is not a list, has the wrong length for the crew, contains an invalid entry, repeats an internal id, or an external athlete is missing name / has a name > 128 chars / has a weight that is non-numeric or outside 0–150 kg.
401Missing Authorization header, or the secret key was not supplied (this endpoint needs Bearer public.secret).
401The public key or the secret key is invalid.
403The request was not made over HTTPS.
403Your role is not allowed to create sessions (accesoNuevo).
403track_id is unknown or you do not have access to it.
403An internal athlete was not found or you do not have access to it.
403An internal athlete does not have an active subscription.
403More than half of the crew are external athletes and you lack the unlimited externals privilege.
413The uploaded file exceeds the server's maximum allowed size.
422The .qk file could not be decoded or processed — it may be corrupt, truncated, or not a valid Kosoku session.
429You exceeded the per-key rate limit for this (CPU-intensive) endpoint.
500Server misconfiguration — the Kosoku hardware row or the external-athlete placeholder is not present in the database — or an unexpected internal error.

Share a Session

POST /api/v1/results/{id}/share Secret key

Create a shareable link for a session you have access to. The returned URL lets external, non-authenticated people open an interactive view of the session until the link expires. This is also the link the PDF report's QR code points to.

Example request (curl)
curl -X POST https://cloud.quko.es/api/v1/results/137/share \
     -H "Authorization: Bearer YOUR_PUBLIC_KEY.YOUR_SECRET_KEY"
Example response (201 Created)
{
  "data": {
    "short_code": "Ab3xY9",
    "url": "https://cloud.quko.es/share/Ab3xY9",
    "expires_at": "2025-03-25T10:42:07Z",
    "clicks": 0
  }
}
Links expire after a fixed period (default 10 days). A request for a session you cannot access returns 404.

Session Report (PDF)

GET /api/v1/results/{id}/pdf Secret key

Generate and download the compact PDF report for a session. The document embeds a QR code linking to a freshly-created shareable view of the session, so a reader can jump straight to the interactive analysis. The response is a PDF file (application/pdf) returned as an attachment.

Query parameters
ParameterTypeDescription
splitsintNumber of splits (1–20). Optional, default 4.
langstringPDF language: en (default) or es.
Example request (curl)
# default: 4 splits, English
curl -o report.pdf \
     -H "Authorization: Bearer YOUR_PUBLIC_KEY.YOUR_SECRET_KEY" \
     "https://cloud.quko.es/api/v1/results/137/pdf"

# 6 splits, Spanish
curl -o informe.pdf \
     -H "Authorization: Bearer YOUR_PUBLIC_KEY.YOUR_SECRET_KEY" \
     "https://cloud.quko.es/api/v1/results/137/pdf?splits=6&lang=es"
Errors & their causes
StatusCause
400splits is not an integer or is outside 1–20, or lang is not en / es.
404The session does not exist or you do not have access to it.
422The report could not be generated from the session's data.
429Per-key rate limit for this (CPU-intensive) endpoint exceeded.

Vessels

GET /api/v1/vessels Public key

Paginated list of vessel types.

Example response
{
  "data": [
    { "id": 1, "type": "C1", "npeople": 1 },
    { "id": 2, "type": "C2", "npeople": 2 },
  ],
  "pagination": { "..." }
}
GET /api/v1/vessels/{id} Public key

Single vessel by ID.

Categories

GET /api/v1/categories Public key

Paginated list of categories (e.g. Senior, Junior, Cadet).

Example response
{
  "data": [
    { "id": 1, "name": "Senior" },
    { "id": 2, "name": "U23" }
  ],
  "pagination": { "..." }
}
GET /api/v1/categories/{id} Public key

Single category by ID.

Paces

GET /api/v1/paces Public key

Paginated list of pace types (e.g. Competition, Training, Warm-up).

Example response
{
  "data": [
    { "id": 1, "name": "Competition" },
    { "id": 2, "name": "Training" }
  ],
  "pagination": { "..." }
}
GET /api/v1/paces/{id} Public key

Single pace by ID.

Tracks

GET /api/v1/tracks Secret key

Paginated list of tracks (water venues) the authenticated user has access to.

Example response
{
  "data": [
    { "id": 3, "name": "Idroscalo di Milano" }
  ],
  "pagination": { "..." }
}
GET /api/v1/tracks/{id} Secret key

Single track by ID. Returns 404 if you don't have access.

Athletes

GET /api/v1/athletes Secret key

Paginated list of athletes the authenticated user has access to.

Query parameters
ParameterTypeDescription
modalitystringPartial match on athlete modality
Example response
{
  "data": [
    {
      "id": 12,
      "name": "Ana García López",
      "date_of_birth": "1998-05-23",
      "modality": "K1"
    }
  ],
  "pagination": { "..." }
}
GET /api/v1/athletes/{id} Secret key

Single athlete by ID. Returns 404 if you don't have access.

Health Check

GET /api/v1/health No auth

Simple health check. No authentication required.

Response
{ "status": "ok" }