REST API · v1

API reference

Base URL https://app.oxynet.net. Every request and response is JSON except the multipart upload. The authoritative, generated reference is the service's own OpenAPI schema and app.oxynet.net/docs; this page adds what a schema cannot say.

API v1 · docs 1.0 · updated 2026-09-22

The one rule

Hand Oxynet the file, work with the handle. Upload the vendor export unchanged and receive a cpet_id; every later call names it, and the signals never travel back through the caller unless a picture is being drawn. The lifecycle is the same for a hospital integration, a script and an agent:

  1. 1GET/v1/capabilities# what may this key do
  2. 2POST/v1/cpet# upload → cpet_id
  3. 3POST/v1/cpet/{id}/analyze# thresholds, substrate, oscillation
  4. 4POST/v1/cpet/{id}/compute# derived quantities, input checks
  5. 5DELETE/v1/cpet/{id}# or let it expire

Authentication

An API key in the X-API-Key header on every request. Keys are issued by Oxynet (request one). Each key carries a tier, which governs the models it can reach, and a set of products, which governs the analyses it can run, plus an optional monthly limit and expiry. Records are partitioned per key: one key cannot read another's uploads.

Without a key, only these answer: /v1/openapi.json, /v1/sample, /health, /llms.txt, /llms-full.txt. An upload URL minted by POST /v1/uploads carries its own short-lived credential and takes no key.

curl -s -H "X-API-Key: $OXYNET_API_KEY" https://app.oxynet.net/v1/capabilities

Endpoints

Read from https://app.oxynet.net/v1/openapi.json (Oxynet CPET API, info.version v1, OpenAPI 3.0.3) when this page was built.

POST/v1/cpet/contentUpload a CPET recording as JSON (no multipart)

JSON upload for callers that cannot send multipart (GPT Actions, function calling). Body: filename (keep the real extension), content (text) or content_base64 (XLS/XLSX), optional retain_hours.

POST/v1/uploadsGet a one-shot URL to POST a file to

The right call when the file is on disk. Returns a one-shot upload_url (and a ready-to-run curl line) valid for 5 minutes; POST the file to it with no API key, because the ticket is the credential. {"count": N} mints up to 200 at once for a folder.

GET/v1/cpet/{cpet_id}Summary of a stored recording

The upload summary again: detected format, channels with coverage and the reason for any that are absent, sampling, external load, parser flags, and thresholds the metabolimeter itself embedded, where present.

DELETE/v1/cpet/{cpet_id}Delete a stored recording now

Delete the parsed record now rather than at expiry.

GET/v1/cpet/{cpet_id}/seriesDownsampled series, for plotting

Downsampled signals for plotting only. Every measurement runs server-side on the full-resolution record; never compute from these.

POST/v1/cpet/{cpet_id}/analyzeRun models on a stored recording

Body: {"analyses": ["vt", "eov", "substrate"]}. Returns {cpet_id, results: [envelope, ...]}, one envelope per analysis. Omit "model" unless you need a specific one; the default matches the web application. Unknown fields are a 422.

GET/v1/metricsThe CPET function registry

The derived-quantity registry: each metric with its unit, required channels, minimum duration and reading notes.

POST/v1/cpet/{cpet_id}/computeRun registry functions

Body: {"metrics": ["vo2max", "ve_vco2_slope", "gas_quality"]}. Each metric returns {status: "ok", value} or {status: "unavailable", error, message, context}. Never a bare null.

GET/v1/capabilitiesWhat this key can do

Call first. The analyses and metrics this key may run, the models it can reach, its limits (upload size, monthly limit, retention) and the retention policy.

GET/v1/sampleA synthetic CPET you can analyse immediately

A synthetic recording as {filename, content, note, expected}. No key needed. ?oscillating=true for one that exercises the oscillation detector.

GET/v1/formatsSupported metabolimeter formats

Every format id with a label saying what it is, how detection works, and if_unsupported: the canonical shape to convert a file into when nothing reads it.

POST/v1/cpetMultipart upload (field "file"), optional retain_hoursnot in the importer schema

The upload to use from your own code. Returns the same summary as the JSON upload.

POST/v1/uploads/{ticket}Post a file to a one-shot upload URLnot in the importer schema

No API key: the signed ticket is the credential. You receive this URL from POST /v1/uploads.

The public schema is shaped for importers such as GPT Actions, which reject multipart operations, so the two multipart uploads are documented here and omitted there. The schema declares request bodies; response shapes are documented below and in Outputs.

Upload and input

Three ways in, one result. POST /v1/cpet takes multipart field file from your own code. POST /v1/cpet/content takes {filename, content | content_base64} for callers that cannot send multipart; keep the real extension in filename, because it is part of the evidence for detection. POST /v1/uploads returns a one-shot URL for a file on disk, so an agent never holds the bytes. Every route returns the upload summary: detected format, each channel with its coverage and, if absent, the reason; the sampling the file arrived at; the external load; parser flags.

Detection reads the content and refuses when no candidate is clearly best. If nothing reads a file, the UNSUPPORTED_FORMAT error returns the canonical JSON shape to convert into; that is the only case in which converting a file yourself is correct. See Supported formats.

Analyses and the envelope

POST /v1/cpet/{id}/analyze with {"analyses": ["vt", "eov", "substrate"]} returns {cpet_id, results: [...]}, one envelope per analysis in the order asked. An analysis the recording cannot support comes back as not_analysable inside the 200, with the reason, and the others still run. "signal" is not an analysis name: signal processing is read from GET /v1/cpet/{id} and the input-quality metrics.

analysis"vt", "eov" or "substrate".
status"ok", or "not_analysable" when the recording cannot support it.
findingsThe measurements. Shape depends on the analysis; null when not analysable.
quality"good", "acceptable" or "poor". Describes the INPUT recording (coverage of VE, VO2, VCO2 and whether the sampling interval is known). It is not a confidence in the result, and no field is.
notesPlain-language caveats that must travel with the numbers into any report built on them.
error, message, contextPresent when status is "not_analysable": the code, the reason, and what was needed.
provenanceanalysis_version, schema_version, computed_at, the model that ran and latency_ms; for thresholds also model_version and model_tier.

Refusal codes inside a 200

vtINSUFFICIENT_SIGNAL, MISSING_CHANNEL, TIER_INSUFFICIENT, MODEL_NOT_FOUND, INFERENCE_FAILED
eovNO_USABLE_DATA, INSUFFICIENT_DURATION, MISSING_CHANNEL, EOV_FAILED
substrateMISSING_CHANNEL, INSUFFICIENT_SIGNAL, SUBSTRATE_FAILED

What each analysis measures, and its evidence status →

Errors

Every error response has one shape, and context says what would have worked:

{
  "error": "MISSING_CHANNEL",
  "message": "...",
  "context": { "required": [...], "missing": [...] }
}
HTTPerrorMeaning
401MISSING_API_KEYNo X-API-Key header.
403INVALID_API_KEYThe key is not recognised.
403KEY_EXPIREDThe key has passed its expiry date.
403PRODUCT_NOT_ENABLEDThe key does not include that analysis. Analyses are licensed separately.
403TIER_INSUFFICIENTThe requested model needs a higher tier.
403MODEL_NOT_ALLOWEDThe key is restricted to other models.
403UPLOAD_TICKET_INVALIDThe upload URL is expired, used, or malformed.
404CPET_NOT_FOUNDUnknown id, or the record passed its expiry.
404MODEL_NOT_FOUNDNo model by that name or version.
413FILE_TOO_LARGEOver 25 MB.
413RECORDING_TOO_LONGMore than 20,000 samples.
422UNSUPPORTED_FORMATNot recognised as any supported export. context.recovery gives the canonical shape to convert into.
422PARSER_ERRORFormat recognised, contents unreadable.
422UNKNOWN_ANALYSISNot one of vt, eov, substrate. "signal" is not an analysis: use GET /v1/cpet/{id} and compute.
422VALIDATION_ERRORMalformed body, or an unrecognised field. context.errors says which.
429RATE_LIMITEDBack off and honour Retry-After.
429QUOTA_EXCEEDEDMonthly analysis limit reached.
500INFERENCE_FAILEDThe model could not run. Worth reporting.
503UPLOAD_TICKETS_UNAVAILABLEUpload tickets are temporarily unavailable; use POST /v1/cpet.

Limits, quotas and retention

Upload size25 MB (413 FILE_TOO_LARGE)
Samples per recording20,000 (413 RECORDING_TOO_LONG)
Upload ticketvalid 5 minutes, one file each, up to 200 per request
Retention, default24 hours after upload
Retention, maximum168 hours (7 days), when requested with retain_hours
Rate limitingper key; a 429 RATE_LIMITED carries a Retry-After header
Monthly quotaset per key, reported in capabilities.limits.monthly_limit (429 QUOTA_EXCEEDED)
Billing unitsone per analysis that returns status "ok"; one per compute request with any metric ok. Uploads and refusals are not billed

Your key's own values are in GET /v1/capabilities under limits. Uploaded bytes are parsed and discarded, never stored; the parsed record is deleted at expiry or on DELETE. See Data handling.

Not yet documented

A published rate-limit figure and a latency or availability commitment. Neither is stated today; the API reports latency per result in provenance.latency_ms.

Versioning

APIv1 in the path and in the schema's info.version. Breaking changes would ship as /v2 beside it, documented at /developers/api/v2.
Analysisprovenance.analysis_version, currently 2026.08.
Response schemaprovenance.schema_version and the upload summary's schema_version.
Modelprovenance.model and model_version. Omit model in requests to get the default, which is what the web application uses.
DeterminismThe same recording returns the same numbers on the same model version.
Request strictnessAn unrecognised request field is a 422 naming it, so a typo cannot silently run on defaults.

Not yet documented

A public changelog and a deprecation policy for model and analysis versions. Until they exist, pin on the provenance fields and store them with every result.