Writes
Create, update and delete athletes, coach assignments, teams, exercises, load-velocity profiles and injury reports, and retry creates safely with an Idempotency-Key.
The API also accepts the writes an external system, such as an athlete management system, a roster or a testing tool, needs to keep Vitruve in sync. Programs, workouts, templates and measurements are created in the Vitruve apps.
What you can write
| Resource | Endpoints | Reference |
|---|---|---|
| Athletes | POST /athletes, PATCH /athletes/{id}, DELETE /athletes/{id} | Organization and people |
| Coaches | PUT /coaches/{id}/athletes, PUT /coaches/{id}/athletes/{athleteId}, DELETE /coaches/{id}/athletes/{athleteId}, DELETE /coaches/{id} | Organization and people |
| Teams | POST /teams, PATCH /teams/{id}, PUT /teams/{id}/athletes/{athleteId}, DELETE /teams/{id}/athletes/{athleteId}, DELETE /teams/{id} | Organization and people |
| Exercises | POST /exercises, PATCH /exercises/{id}, DELETE /exercises/{id} | Catalogs |
| Load-velocity profiles | POST /equations, PATCH /equations/{id}, DELETE /equations/{id} | Measurements |
| Injury reports | POST /injury-reports, PATCH /injury-reports/{id}, DELETE /injury-reports/{id} | Measurements |
Rules
- Only your organization's own API key writes. It writes on behalf of the organization, with the permissions its plan gives the owner.
- The plan decides. Without an active subscription every write answers
403, and so does an action the plan doesn't include or whose limit is reached, such as the plan's athlete limit. - Send JSON with
Content-Type: application/json. Unknown body fields answer400, like unknown query parameters. PATCHchanges only the fields you send. Sendnullto clear an optional field. An empty body answers400.- Ids in a body are checked. An athlete, coach, team, exercise, metric or muscle that isn't in your organization or in Vitruve's catalogs answers
400, andmessagenames the field. An id in the path that isn't in your organization answers404. - Dates in a body are a bare date (
YYYY-MM-DD, read as midnight UTC) or an ISO 8601 datetime withZor an offset, such as2026-10-27T09:00:00+02:00. Anything else, a number,trueor a loose string, answers400. - Links (
youtubeUrl,googleUrl) must behttporhttps. - Writes answer
200with the created, updated or deleted resource, in the shape of the matchingGET /…/{id}. - Deletes are permanent, as in the app, except exercises, which are archived so past workouts keep showing them.
- Bodies are limited to 1 MB. A larger one answers
413. - Writes count toward the same rate limit as reads.
Some records can't be changed through the API and answer 403: the profile of an athlete who claimed their own Vitruve account (they manage it themselves), an exercise of Vitruve's library, and the organization's owner, who can't be removed.
Retry creates with an Idempotency-Key
Every POST that creates something accepts an Idempotency-Key header: 1 to 255 visible ASCII characters you choose per operation, such as a UUID or your own record's id. Send it to retry a create safely after a timeout or a dropped connection.
POST /v1/athletes
Idempotency-Key: roster-sync-2026-10-13-athlete-8812| Case | Answer |
|---|---|
| First request with the key | Runs as usual. A 2xx answer is kept for an hour. |
| Same key, same request (method, path and body) | The kept answer again, with an Idempotent-Replayed: true header. Nothing is created twice. |
| Same key, same request, when the first answer was over 32 KB | 409 This request already completed; fetch the resource instead. Nothing is created twice. |
| Same key while the first request is still running | 409. Retry after a moment. |
| Same key, a different request | 422. |
The first request failed (any non-2xx answer) | The key is released: a retry runs again. |
Keys belong to the API key that sent them and expire an hour after the first request completes: retry within it. Without the header, a retried POST creates again. PATCH, PUT and DELETE don't take the header: repeating them leaves the same result.
- Pick the key before the first attempt and store it with your own record, so every retry sends the same one. A new key per attempt creates again.
- Resend the same bytes. The body is compared as sent: serialize it once and reuse the string, rather than rebuilding it with keys in another order.
- A
503runs nothing. If the key can't be recorded, the create answers503 Idempotency store unavailablewithout running. Retry with backoff.
# Pick the key once per operation and reuse it on every retry.
curl --retry 5 --retry-all-errors -X POST "https://api-next.vitruve.fit/v1/athletes" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: roster-sync-athlete-8812" \
-d '{ "name": "Lucas", "surname": "Moreau" }'// One key per operation, stored with your record before the first attempt.
const idempotencyKey = `roster-sync-athlete-${record.id}`
// Serialized once: every retry sends the same body.
const body = JSON.stringify({ name: 'Lucas', surname: 'Moreau' })
for (let attempt = 0; ; attempt++) {
const response = await fetch('https://api-next.vitruve.fit/v1/athletes', {
method: 'POST',
headers: {
'X-API-Key': process.env.VITRUVE_API_KEY,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey
},
body
}).catch(() => null) // timeout or dropped connection
if (response?.ok) {
const { data } = await response.json()
console.log(data.id, response.headers.get('idempotent-replayed') === 'true')
break
}
// 409: the first attempt is still running. 429 and 5xx: transient.
const retryable = !response || [409, 429, 500, 503].includes(response.status)
if (!retryable || attempt === 5) throw new Error(`Create failed: ${response?.status}`)
const waitSeconds = Number(response?.headers.get('retry-after')) || 2 ** attempt
await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000))
}import json
import os
import time
import requests
# One key per operation, stored with your record before the first attempt.
idempotency_key = f"roster-sync-athlete-{record_id}"
# Serialized once: every retry sends the same body.
body = json.dumps({"name": "Lucas", "surname": "Moreau"})
for attempt in range(6):
try:
response = requests.post(
"https://api-next.vitruve.fit/v1/athletes",
data=body,
headers={
"X-API-Key": os.environ["VITRUVE_API_KEY"],
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
},
timeout=30,
)
except (requests.ConnectionError, requests.Timeout):
response = None # timeout or dropped connection
if response is not None and response.ok:
print(response.json()["data"]["id"], response.headers.get("Idempotent-Replayed"))
break
# 409: the first attempt is still running. 429 and 5xx: transient.
retryable = response is None or response.status_code in (409, 429, 500, 503)
if not retryable or attempt == 5:
raise RuntimeError(f"Create failed: {response and response.status_code}")
wait = response is not None and response.headers.get("Retry-After")
time.sleep(int(wait) if wait else 2**attempt)Errors on writes
| Status | When |
|---|---|
400 | A body field is missing, unknown or invalid, or an id in it isn't in your organization. |
403 | No active subscription; the plan doesn't include the action or its limit is reached; a record the API can't change. |
404 | The resource isn't in your organization. Injury reports and load-velocity profiles of an athlete who left it answer 404 too. |
409 | The email belongs to another user, a record the write refers to was changed or removed meanwhile, or the first request with the same Idempotency-Key is still running or answered too large to keep. |
413 | The body is larger than 1 MB. |
422 | The Idempotency-Key was already used for a different request. |
Every status, with its messages, is in Error codes.