JoyVelo

Reference · V3.0

JoyVelo API

The HTTP API behind JoyOne, JoyAnalyze, JoyTrain, and the rest of the JoyVelo platform. Use it to wire your activities into CI, push analysis-complete events to your own backend, or bulk-import a season of rides for offline analysis.

11 shipped · 1 preview · 12 endpoints total. All times are ISO 8601 UTC. All IDs are ULIDs.

TL;DR
  1. Sign in to joyvelo.com in your browser, then call POST /api/v1/auth/token with a signed-in cookie to mint a bearer.
  2. Send that bearer in the Authorization header. Required scopes appear in the table below.
  3. Read endpoints are live today. Project write / patch and webhook registration are preview — handlers return 501 until the G19 webhook UI ships.

1. Authentication

Issue, list, and revoke long-lived bearer tokens for scripts and CI.

POST/api/v1/auth/tokenshippedsigned-in session

Issue a new API token

Create a long-lived bearer for scripts / CI. The plaintext token is returned EXACTLY ONCE — the server only stores the SHA-256 hash. Use a signed-in Supabase session (cookie) to call this endpoint.

Parameters

NameInTypeRequiredDescription
labelbodystringyesHuman-readable label; shown in the token list.e.g. CI pipeline
scopesbodystring[]yesSubset of allowed scopes.e.g. projects:read
expiresAtbodyiso-datenoOptional ISO 8601 expiry; null = never.

Errors

StatusCodeMeaning
400label_requiredNo label supplied.
400scopes_requiredNo valid scopes in the body.
401unauthorizedNo signed-in user.

Example

Request
curl -X POST https://joyvelo.com/api/v1/auth/token \
  -H "Content-Type: application/json" \
  -H "Cookie: <sb-session>" \
  -d '{ "label": "CI", "scopes": ["projects:read", "activities:read"] }'
Response
{
  "plain": "7f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a",
  "meta": {
    "id": "tok_01H...",
    "preview": "7f2a3b4c",
    "scopes": ["projects:read", "activities:read"],
    "createdAt": "2026-07-25T12:00:00Z",
    "lastUsedAt": null,
    "expiresAt": null,
    "revokedAt": null
  }
}
GET/api/v1/auth/tokensshippedsigned-in session

List your tokens (metadata only)

Returns the metadata of every active + revoked token you own. The plain text and the SHA-256 hash are never included. Requires a signed-in session.

Errors

StatusCodeMeaning
401unauthorizedNo signed-in user.

Example

Request
curl https://joyvelo.com/api/v1/auth/tokens -H "Cookie: <sb-session>"
Response
{
  "tokens": [
    { "id": "tok_01H...", "preview": "7f2a3b4c", "scopes": ["projects:read"], "createdAt": "...", "lastUsedAt": "...", "expiresAt": null, "revokedAt": null }
  ]
}
DELETE/api/v1/auth/token/:idshippedsigned-in session

Revoke a token

Sets `revoked_at = now()` on the token. Idempotent: revoking a revoked token returns 200. The bearer can never be re-derived from the row.

Errors

StatusCodeMeaning
404not_foundNo token with that id.
403forbiddenToken belongs to another user.

Example

Request
curl -X DELETE https://joyvelo.com/api/v1/auth/token/tok_01H... -H "Cookie: <sb-session>"
Response
{ "ok": true }

2. Projects

The JoyAnalyze project document. Read is live; write / patch are preview until the R-1 (G19) webhook UI ships.

GET/api/v1/projectsshippedscope: projects:read

List your projects

Returns every JoyAnalyze project you own, newest updated first. Same shape as the export/import JSON envelope `kind:"joyanalyze-projects"`, but flat.

Errors

StatusCodeMeaning
401unauthorizedMissing or invalid bearer.
403insufficient_scopeToken lacks `projects:read`.

Example

Request
curl https://joyvelo.com/api/v1/projects \
  -H "Authorization: Bearer <token>"
Response
{
  "projects": [
    { "id": "p_01H...", "name": "Quarq DZero vs 4iiii", "visibility": "private", "updatedAt": "2026-07-24T08:30:00Z" }
  ],
  "count": 1
}
GET/api/v1/projects/:idpreviewscope: projects:read

Fetch one project (incl. streams)

Returns the full project document — metadata, slots, per-stream labels, bias tables, KPI summary. Will be served from the same RLS-gated `projects` table the v1 list endpoint reads.

POST/api/v1/projectsshippedscope: projects:write

Create a project

Body must follow the `kind:"joyanalyze-projects"`, `version:1` JSON envelope (see "JSON envelopes" below). The server strips the `id` and assigns a fresh one. Gated to Pro+ via `stream_slots_20`.

Errors

StatusCodeMeaning
400name_requiredBody `name` is empty after trim.
400unsupported_kindOnly `kind:"joyanalyze"` is supported in V3.0.
401unauthorizedMissing or invalid bearer.
402insufficient_tierToken has `projects:write` but the user is on `free`; upgrade required.

Example

Request
curl -X POST https://joyvelo.com/api/v1/projects \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "joyanalyze",
    "version": 1,
    "name": "Quarq DZero vs 4iiii (after G11 batch)",
    "visibility": "private",
    "payload": { "slots": [], "bias": {} }
  }'
Response
{
  "project": {
    "id": "p_01H...",
    "owner_id": "u_01H...",
    "name": "Quarq DZero vs 4iiii (after G11 batch)",
    "kind": "joyanalyze",
    "visibility": "private",
    "payload": { "slots": [], "bias": {} },
    "created_at": "2026-07-26T08:00:00Z",
    "updated_at": "2026-07-26T08:00:00Z"
  }
}
PATCH/api/v1/projects/:idshippedscope: projects:write

Update a project (merge)

Newer-edits-wins per field. Returns 404 if the id does not exist or is not owned by the caller. Gated to Pro+ via `stream_slots_20`.

Errors

StatusCodeMeaning
400no_fields_to_updateBody had no recognised fields.
401unauthorizedMissing or invalid bearer.
402insufficient_tierToken has `projects:write` but the user is on `free`.
404not_found_or_forbiddenNo project with that id owned by the caller.

Example

Request
curl -X PATCH https://joyvelo.com/api/v1/projects/p_01H... \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Quarq vs 4iiii (renamed)", "visibility": "team" }'
Response
{
  "project": {
    "id": "p_01H...",
    "name": "Quarq vs 4iiii (renamed)",
    "visibility": "team",
    "updated_at": "2026-07-26T08:05:00Z"
  }
}
DELETE/api/v1/projects/:idshippedscope: projects:write

Delete a project

Soft-delete: sets `payload.deleted_at`. The schema does not have a real `deleted_at` column yet (added in the next migration). Gated to Pro+ via `stream_slots_20`.

Errors

StatusCodeMeaning
401unauthorizedMissing or invalid bearer.
402insufficient_tierToken has `projects:write` but the user is on `free`.
404not_found_or_forbiddenNo project with that id owned by the caller.

Example

Request
curl -X DELETE https://joyvelo.com/api/v1/projects/p_01H... \
  -H "Authorization: Bearer <token>"
Response
{
  "ok": true,
  "project": {
    "id": "p_01H...",
    "payload": { "deleted_at": "2026-07-26T08:10:00Z", "...": "..." }
  }
}

3. Activities

Your JoyOne activity archive. Used by the JoyTrain → JoyAnalyze one-click import (G6) and by the batch upload (G11).

GET/api/v1/activitiesshippedscope: activities:read

List your activities

Returns your JoyOne activities, newest first. Activities carry the reconstructed second-by-second streams that the JoyTrain → JoyAnalyze one-click import (G6) consumes.

Parameters

NameInTypeRequiredDescription
sincequeryiso-datenoOnly return activities with start_time ≥ this. Useful for incremental sync.
limitquerynumbernoCap on row count, 1..500; default 100.e.g. 100

Errors

StatusCodeMeaning
401unauthorizedMissing or invalid bearer.
403insufficient_scopeToken lacks `activities:read`.

Example

Request
curl "https://joyvelo.com/api/v1/activities?since=2026-07-01T00:00:00Z&limit=50" \
  -H "Authorization: Bearer <token>"
Response
{
  "activities": [
    { "id": "act_01H...", "startTime": "2026-07-20T08:30:00Z", "durationSeconds": 5400, "name": "Dole climb repeat" }
  ],
  "count": 1
}

4. Webhooks

Push analysis-complete events to your own endpoint. HMAC-SHA-256 signed; 5-minute replay window. Preview until G19.

POST/api/v1/webhooksshippedscope: webhooks:write

Register a webhook

Body: { url, events?, secret? }. Server auto-generates the HMAC secret if absent (returned EXACTLY ONCE in the response). URL must be https:// (or http://localhost:… for dev). Gated to Pro+ via `webhooks`.

Errors

StatusCodeMeaning
400invalid_urlURL must be https:// (or http://localhost:… for dev).
400invalid_events`events[]` had no known event types.
401unauthorizedMissing or invalid bearer.
402insufficient_tierToken has `webhooks:write` but the user is on `free` or `pro`; Pro+ required.

Example

Request
curl -X POST https://joyvelo.com/api/v1/webhooks \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/joyvelo-hook",
    "events": ["project.analysis_complete", "batch.report_ready"]
  }'
Response
{
  "webhook": {
    "id": "wh_01H...",
    "url": "https://example.com/joyvelo-hook",
    "event_filter": ["project.analysis_complete", "batch.report_ready"],
    "active": true,
    "created_at": "2026-07-26T08:15:00Z",
    "updated_at": "2026-07-26T08:15:00Z"
  },
  "secret": "a1b2c3d4e5f6...32chars..."
}
GET/api/v1/webhooksshippedscope: webhooks:read

List your webhooks

Returns metadata only (id, url, event_filter, active, created_at, updated_at). Secret is never re-served after the initial POST response. Gated to Pro+ via `webhooks`.

Errors

StatusCodeMeaning
401unauthorizedMissing or invalid bearer.
402insufficient_tierToken has `webhooks:read` but the user is on `free` or `pro`; Pro+ required.

Example

Request
curl https://joyvelo.com/api/v1/webhooks \
  -H "Authorization: Bearer <token>"
Response
{
  "webhooks": [
    { "id": "wh_01H...", "url": "https://example.com/joyvelo-hook", "event_filter": ["project.analysis_complete"], "active": true, "created_at": "...", "updated_at": "..." }
  ],
  "count": 1
}
DELETE/api/v1/webhooks/:idshippedscope: webhooks:write

Revoke a webhook

Sets `active = false` (idempotent). Pending deliveries in flight may still arrive. Gated to Pro+ via `webhooks`.

Errors

StatusCodeMeaning
401unauthorizedMissing or invalid bearer.
402insufficient_tierToken has `webhooks:write` but the user is on `free` or `pro`; Pro+ required.
404not_foundNo webhook with that id owned by the caller.

Example

Request
curl -X DELETE https://joyvelo.com/api/v1/webhooks/wh_01H... \
  -H "Authorization: Bearer <token>"
Response
{ "ok": true, "id": "wh_01H..." }

Scopes

Every token is a subset of the six scopes below. The verification layer rejects any request that asks for a scope the token doesn't carry. activities:write and the two webhook scopes are reserved for upcoming G13 / G19 features; their endpoints return 501 today.

projects:read

Read project metadata + slot labels.

projects:write

Create / update / delete projects.

activities:read

Read the JoyOne activity archive.

activities:write

Create / update / delete activities (preview).

webhooks:read

List your registered webhooks (preview).

webhooks:write

Register / revoke webhooks (preview).

JSON envelopes

Every persisted document carries a top-level kind discriminator and a version integer. New versions add fields; old clients ignore unknown fields.

joyanalyze-projectsv1

Project export (G4) + the upcoming POST /api/v1/projects body.

joyanalyze-segment-templatesv1

Segment template library (G10) export/import.

Webhook signing

Every analysis-complete POST carries a single header:

X-JoyAnalyze-Signature: t=<unix-seconds>, v1=<hex-digest>

v1 is the HMAC-SHA-256 hex of${t}.${rawBody} using the shared secret returned at registration. Verify on the receiver side:

  1. Reject if the timestamp is more than 5 minutes from your local clock (replay protection).
  2. Compute the HMAC against the raw request body — do not pre-parse JSON.
  3. Compare with a constant-time string compare.

Reference implementation: lib/joyanalyze-webhook.ts (browser + Node 18+ via SubtleCrypto).

Error model

Every non-2xx response is a JSON object with a single error field — the human-readable code. Status codes follow HTTP norms: 400 for bad input, 401 for missing auth, 403 for insufficient scope, 404 for unknown id, 409 for conflicts, 429 for rate-limit, 5xx for server fault. We never leak Supabase internals in the body.

{ "error": "insufficient_scope", "requiredScope": "projects:write" }

Versioning

The URL is the version (/api/v1/...). Within a version we only add fields and endpoints; we never rename or repurpose them. A v2 cut will ship alongside a parallel v1 maintenance window of at least 6 months.

See the PRD for the rollout plan; JoyAnalyze PRD for the feature-level roadmap (V1 free, V2 Pro, V3 Pro+).

Product tiers — V1 / V2 / V3

JoyAnalyze ships in three product tiers. The tier controls UI limits (how many files per project, how many streams you can compare at once); the API surface above is the same for every tier, with the only difference being which scopes your token carries.

V1.0Free

2 files per project

Original V1.0 PRD cap. The current default is 3 slots (A/B/C) so free users can compare a power meter + HR strap + cadence sensor without upgrade friction. Pro/Pro+ users can add up to 20 slots per project via the "+ Add stream" button.

API access: Read-only bearer token (projects:read, activities:read).

V2.0Pro

20 streams per project

Up to 20 files per project, advanced cleaning rules, ANT+ / BLE sensor library, custom zones, JoyOne / Reviews / JoyTrain deep linking, PNG / SVG / CSV export, share-link tiers + TTL, iframe embed.

API access: Read + write bearer tokens (projects:read + projects:write, activities:read + activities:write).

V3.0Pro+

Pro + batch + team + API

Adds one-click PDF reports, R² / SEE / Mahalanobis / CdA / dynamic-response latency, batch upload / batch project / batch report, team comments + activity log + tags, custom metric DSL, this public API, HMAC-signed webhooks.

API access: All 6 scopes, including webhooks:read + webhooks:write.

Tier enforcement lives in the UI for the V1.0 cap (stream-add button) and in the Vercel cron + Supabase row-level security for the V2 / V3 feature gates. Bearer-token scope checks are the source of truth on the API side: a V1.0 token can never mutate state regardless of what the UI lets through.

Open JoyAnalyzeMint a token from your accountJoyAnalyze PRD (V1 / V2 / V3)Sign in

Generated from lib/api-endpoints.ts. The manifest and the route handlers stay in sync because the page reads from the same source the tests assert against.