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.
https://cloud.quko.es/api/v1
Fetch results from the API and display them in your application.
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();
}
<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>
The API uses key-pair authentication. You receive a public key (identifier) and a secret key (credential) from the developer dashboard.
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
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
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.
List endpoints return paginated results. Control pagination with query parameters:
| Parameter | Type | Description |
|---|---|---|
page | integer | Page number (default: 1) |
per_page | integer | Items per page (default: 20, capped by server max) |
Every paginated response includes a pagination object:
{
"data": [ "..." ],
"pagination": {
"page": 1,
"per_page": 20,
"total": 142,
"pages": 8,
"has_next": true,
"has_prev": false
}
}
Errors return a JSON body with error and message fields:
{
"error": "Not found",
"message": "Result not found."
}
| Status | Meaning |
|---|---|
400 | Bad request – invalid or missing parameters |
401 | Unauthorized – missing or invalid API key |
403 | Forbidden – key valid but insufficient permissions |
404 | Not found – resource doesn't exist or access denied |
413 | Payload too large – uploaded file exceeds the maximum size |
422 | Unprocessable – the uploaded session file could not be decoded/processed |
429 | Rate limit exceeded |
500 | Internal server error |
/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.
| Parameter | Type | Description |
|---|---|---|
page | int | Page number |
per_page | int | Results per page |
start_date | YYYY-MM-DD | Filter from this date |
end_date | YYYY-MM-DD | Filter until this date |
start_datetime | ISO 8601 | Filter from this datetime. Use the T separator, e.g. 2025-03-15T14:00 |
end_datetime | ISO 8601 | Filter until this datetime. Use the T separator, e.g. 2025-03-15T18:30 |
vessel_id | int | Filter by vessel |
track_id | int | Filter by track |
category_id | int | Filter by category |
pace_id | int | Filter by pace |
hardware_id | int | Filter by hardware device |
athlete_id | int | Filter by athlete (result must include this athlete) |
dist | int | Filter by distance |
competition | string | Partial match on competition name |
description | string | Partial match on description |
sort | string | Sort field: date (default), dist, time, creation_date |
order | string | asc or desc (default) |
{
"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
}
}
/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.
| Field | Type | Description |
|---|---|---|
data_strokes | object / null | Processed stroke-by-stroke data in xeraJSON format |
external_athletes | object / null | External athlete metadata (if any) |
meteo | object / null | Weather conditions at time of test |
athletes_detail | object / null | Ordered athlete names & surnames |
{
"meteo": {
"wind_speed": 3.2,
"wind_direction": 180,
"air_temperature": 22.5,
"water_temperature": 18.0
}
}
/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.
curl -X DELETE https://cloud.quko.es/api/v1/results/137 \
-H "Authorization: Bearer YOUR_PUBLIC_KEY.YOUR_SECRET_KEY"
{
"data": { "id": 137, "deleted": true }
}
| Code | Meaning |
|---|---|
200 | The result was deleted. |
401 | Missing or invalid credentials (this endpoint needs Bearer public.secret). |
403 | Your role is not allowed to delete sessions. |
404 | The result does not exist or you do not have access to it. |
/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.
multipart/form-data with two parts —
file (the binary .qk) and metadata (a JSON string).
| Field | Type | Description |
|---|---|---|
distance | int | Race distance in metres (50–1000). Provide either this… |
duration_ms | int | …or the race duration in milliseconds (1–300000). Exactly one of the two is required. |
vessel_id | int | Required. Vessel id (see Vessels). |
track_id | int | Required. Track id — must be one you have access to. |
category_id | int | Required. Category id. |
pace_id | int | Required. Pace id. |
athletes | array | Required. One entry per seat, in boat order; length must equal the vessel crew size. See below. |
competition | string | Optional. Max 64 characters. |
description | string | Optional. Max 64 characters. |
important | bool | Optional. Flag the session for later review. |
meteo | object | Optional weather conditions: wind_speed (0–20), wind_direction (0–359), air_temperature (-20–50), water_temperature (0–40). |
Each entry of athletes is one seat, in order:
| Form | Meaning |
|---|---|
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.
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}
]
}'
{
"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
}
}
Every error returns a JSON body with
error and message fields. The
message identifies the exact problem.
| Status | Cause |
|---|---|
400 | The file part is missing or empty. |
400 | The uploaded filename does not end in .qk. |
400 | The metadata part is missing, is not valid JSON, or is not a JSON object. |
400 | Neither or both of distance / duration_ms were given (exactly one is required). |
400 | distance outside 50–1000 m, or duration_ms outside 1–300000 ms, or non-numeric. |
400 | competition / description is not a string or exceeds 64 characters. |
400 | A required field is missing: vessel_id, track_id, category_id or pace_id. |
400 | vessel_id, category_id or pace_id does not exist, or the vessel type is unsupported. |
400 | A meteo value is non-numeric or out of range (see field ranges above). |
400 | athletes 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. |
401 | Missing Authorization header, or the secret key was not supplied (this endpoint needs Bearer public.secret). |
401 | The public key or the secret key is invalid. |
403 | The request was not made over HTTPS. |
403 | Your role is not allowed to create sessions (accesoNuevo). |
403 | track_id is unknown or you do not have access to it. |
403 | An internal athlete was not found or you do not have access to it. |
403 | An internal athlete does not have an active subscription. |
403 | More than half of the crew are external athletes and you lack the unlimited externals privilege. |
413 | The uploaded file exceeds the server's maximum allowed size. |
422 | The .qk file could not be decoded or processed — it may be corrupt, truncated, or not a valid Kosoku session. |
429 | You exceeded the per-key rate limit for this (CPU-intensive) endpoint. |
500 | Server misconfiguration — the Kosoku hardware row or the external-athlete placeholder is not present in the database — or an unexpected internal error. |
/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.
| Parameter | Type | Description |
|---|---|---|
splits | int | Number of splits (1–20). Optional, default 4. |
lang | string | PDF language: en (default) or es. |
# 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"
| Status | Cause |
|---|---|
400 | splits is not an integer or is outside 1–20, or lang is not en / es. |
404 | The session does not exist or you do not have access to it. |
422 | The report could not be generated from the session's data. |
429 | Per-key rate limit for this (CPU-intensive) endpoint exceeded. |
/api/v1/vessels
Public key
Paginated list of vessel types.
{
"data": [
{ "id": 1, "type": "C1", "npeople": 1 },
{ "id": 2, "type": "C2", "npeople": 2 },
],
"pagination": { "..." }
}
/api/v1/vessels/{id}
Public key
Single vessel by ID.
/api/v1/categories
Public key
Paginated list of categories (e.g. Senior, Junior, Cadet).
{
"data": [
{ "id": 1, "name": "Senior" },
{ "id": 2, "name": "U23" }
],
"pagination": { "..." }
}
/api/v1/categories/{id}
Public key
Single category by ID.
/api/v1/paces
Public key
Paginated list of pace types (e.g. Competition, Training, Warm-up).
{
"data": [
{ "id": 1, "name": "Competition" },
{ "id": 2, "name": "Training" }
],
"pagination": { "..." }
}
/api/v1/paces/{id}
Public key
Single pace by ID.
/api/v1/tracks
Secret key
Paginated list of tracks (water venues) the authenticated user has access to.
{
"data": [
{ "id": 3, "name": "Idroscalo di Milano" }
],
"pagination": { "..." }
}
/api/v1/tracks/{id}
Secret key
Single track by ID. Returns 404 if you don't have access.
/api/v1/athletes
Secret key
Paginated list of athletes the authenticated user has access to.
| Parameter | Type | Description |
|---|---|---|
modality | string | Partial match on athlete modality |
{
"data": [
{
"id": 12,
"name": "Ana García López",
"date_of_birth": "1998-05-23",
"modality": "K1"
}
],
"pagination": { "..." }
}
/api/v1/athletes/{id}
Secret key
Single athlete by ID. Returns 404 if you don't have access.
/api/v1/health
No auth
Simple health check. No authentication required.
{ "status": "ok" }