All pages
HTTP API
The v2 API is three POST endpoints that take JSON event envelopes:
/api/v2/events for organization events and reads, authenticated with an
organization API key; /api/v2/experiments/{experimentId}/events for one run's
lifecycle, metrics and annotations, authenticated with that experiment's token;
and /api/v2/query/series, which streams series as NDJSON. Every event has a
messageId, every reply names it in correlationId, and a failed event is a
syvain.v2.error reply inside an HTTP 200. The collector, the
Python API client, DuckDB and the
command line are built on it; use it directly from any other language.
Base URL and authentication
The API is served at https://metrics.041.io and at
https://metrics.syvain.com; both are the same service. The published clients
default to https://metrics.syvain.com.
Send the credential as a bearer token and JSON bodies with their content type:
POST /api/v2/events HTTP/1.1
Host: metrics.041.io
Authorization: Bearer ak_...
Content-Type: application/json| endpoint | credential | accepts |
|---|---|---|
POST /api/v2/events |
organization API key ak_... |
folder events, experiment.open, experiment.tokenRefresh, reads |
POST /api/v2/experiments/{experimentId}/events |
experiment token ik_... from experiment.open |
lifecycle, metric, annotate, tokenRevoke |
POST /api/v2/query/series |
organization API key | one query.series or query.experimentseries |
GET /api/v2/auth/status |
organization API key | nothing; returns the key's organization |
Keys and tokens are explained in organizations and keys.
GET /api/openapi.json is the OpenAPI document with every request and reply
schema; API events lists every event with its data and reply.
curl -sS https://metrics.041.io/api/v2/auth/status -H "authorization: Bearer $SYVAIN_METRICS_API_KEY"{
"data": {
"auth": { "kind": "api_key" },
"organization": { "id": "org_...", "name": "My Lab" }
},
"error": null
}The envelope
A request event:
{
"messageId": "7f0c1b9e-2f4e-4d37-9a51-6f1f6f0b8e11",
"event": "syvain.v2.folder.create",
"data": { "name": "sweeps", "parentFolderId": null }
}Its reply:
{
"messageId": "0d9a...",
"correlationId": "7f0c1b9e-2f4e-4d37-9a51-6f1f6f0b8e11",
"event": "syvain.v2.folder.created",
"data": { "name": "sweeps", "folderId": "019e...", "parentFolderId": null }
}messageIdis a nonempty string of at most 200 characters; use a UUID.correlationIdis optional in a request. In every reply it equals the request'smessageId, and the reply has a newmessageIdof its own.eventnames are case-sensitive and carry thesyvain.v2.prefix.- Every required field must be present, including empty objects and arrays.
Unknown fields, in the envelope or in
data, are rejected.
Batches
Both /events endpoints take one envelope or an array of 1 to 1,000. One
envelope gets one reply object; an array gets an array of replies in request
order.
- The whole body is validated before anything runs; one malformed event rejects the request with an HTTP error.
- Events run in order, but a batch is not a transaction: writes that succeeded stay when a later event fails.
- In an experiment batch every
messageIdmust be unique; a duplicate rejects the whole request with HTTP 400 and codeduplicate_message_idbefore any event runs.tokenRevokemust be the last event. - Every
experimentIdin an experiment batch must equal the one in the URL; a mismatch is a 409 event error. syvain.v2.query.experimentsandsyvain.v2.query.revisions, which take up to 500 ids, must be the only event in their request.
Errors
Errors come in two layers; read both the HTTP status and the body.
A request-level failure (authentication, malformed JSON, a body that fails validation) is an HTTP error status with this body:
{
"data": null,
"error": {
"message": "Authentication is required.",
"details": "code=unauthorized; request_id=..."
}
}An event that fails inside an accepted request is a reply in place of its result, with HTTP 200:
{
"messageId": "...",
"correlationId": "7f0c1b9e-2f4e-4d37-9a51-6f1f6f0b8e11",
"event": "syvain.v2.error",
"data": {
"code": "folder_already_exists",
"message": "...",
"status": 409,
"retryable": false,
"details": { "folderId": "019e..." }
}
}status is the HTTP status the event would have had. retryable is true for
429 and 5xx. A request-level failure can also happen after earlier events of the
same request were applied.
Retries
messageId correlates replies; it is not a general idempotency key.
- A metric is deduplicated by its
messageIdwithin the experiment. Retry a metric with the samemessageIdand the same payload, and it is stored once. - Every other write has side effects when repeated. A repeated
experiment.openreplaces description and meta and issues another token; a repeatedannotateadds a second annotation; a repeateddonemovesdoneAtMs;folder.createandtokenRefreshalso change state. - Retry connection failures, HTTP 429 and 5xx, and event errors with
retryable: true, with backoff. After a timeout on a write that is not a metric, read the state before sending it again. - In a partly failed metric batch, committed metrics have
metricRecordedreplies; resend only the failed ones, with their original ids.
Recording a run
The sequence: open the experiment with the organization key, keep experimentId
and experimentToken, send lifecycle, metrics and annotations to the experiment
endpoint with the token, end with done or error, and revoke the token.
HOST=https://metrics.041.io
KEY=$SYVAIN_METRICS_API_KEY
uuid() { uuidgen | tr '[:upper:]' '[:lower:]'; }
opened=$(jq -n --arg id "$(uuid)" '{
messageId: $id, event: "syvain.v2.experiment.open",
data: {slug: "curl-run-001", description: "Recorded with curl", meta: {lr: 0.0003, seed: 7}}
}' | curl -sS "$HOST/api/v2/events" \
-H "authorization: Bearer $KEY" -H 'content-type: application/json' --data-binary @-)
EXP=$(jq -r .data.experimentId <<<"$opened")
TOKEN=$(jq -r .data.experimentToken <<<"$opened")
now=$(( $(date +%s) * 1000 ))
jq -n --arg exp "$EXP" --argjson now "$now" \
--arg a "$(uuid)" --arg b "$(uuid)" --arg c "$(uuid)" --arg d "$(uuid)" '[
{messageId: $a, event: "syvain.v2.experiment.start", data: {experimentId: $exp}},
{messageId: $b, event: "syvain.v2.experiment.metric",
data: {experimentId: $exp, timestampMs: $now, step: 100, metricName: "loss", metricValue: 2.31, meta: {split: "train"}}},
{messageId: $c, event: "syvain.v2.experiment.metric",
data: {experimentId: $exp, timestampMs: $now, step: 100, metricName: "loss", metricValue: 2.47, meta: {split: "valid"}}},
{messageId: $d, event: "syvain.v2.experiment.annotate",
data: {experimentId: $exp, annotation: "checkpoint saved", meta: {path: "s3://bucket/curl-run-001/step-100.pt", step: 100}}}
]' | curl -sS "$HOST/api/v2/experiments/$EXP/events" \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' --data-binary @- \
| jq -c '.[] | select(.event == "syvain.v2.error")'
jq -n --arg exp "$EXP" --arg a "$(uuid)" --arg b "$(uuid)" '[
{messageId: $a, event: "syvain.v2.experiment.done", data: {experimentId: $exp}},
{messageId: $b, event: "syvain.v2.experiment.tokenRevoke", data: {experimentId: $exp}}
]' | curl -sS "$HOST/api/v2/experiments/$EXP/events" \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' --data-binary @-The metric fields and their limits:
| field | rule |
|---|---|
timestampMs |
integer Unix milliseconds, 0 to 2^53 - 1 |
step |
integer, 0 to 2^53 - 2 |
metricName |
printable ASCII, 1 to 256 bytes |
metricValue |
finite number |
meta |
flat string -> string: at most 32 keys, 128 bytes per key, 512 per value, 4,096 bytes of canonical JSON |
error takes error, a message of 1 to 8,000 characters, and meta, a JSON
object. annotate takes annotation, 1 to 16,000 characters, and meta, at
most 64 KiB of compact JSON. Every limit is on limits and fair use.
start sets the status to running, done to done and error to error,
each at server time. None of them revokes the token, and events after done are
still accepted.
Tokens expire 24 hours after they are issued, at the reply's expiresAtMs.
Before then, send syvain.v2.experiment.tokenRefresh to /api/v2/events with
the organization key and {experimentId, experimentToken}; the reply carries a
new token, and the old one keeps working for at most 30 seconds. An expired or
revoked token cannot be refreshed; open the slug again.
Reading
Send reads to /api/v2/events with the organization key.
| event | data | reply |
|---|---|---|
syvain.v2.foldertree.read |
{} |
the whole tree: root folders, each with child folders and experiments, and root experiments |
syvain.v2.query.experiment |
experimentId or experimentSlug |
the record: status, description, meta, error, timestamps |
syvain.v2.query.catalog |
experimentId or experimentSlug |
every series name with the values of each metadata key |
syvain.v2.query.annotations |
experimentId or experimentSlug |
annotations, newest first, at most 100,000 |
syvain.v2.query.experiments |
experimentIds, up to 500 |
records, and missingExperimentIds |
syvain.v2.query.revisions |
experimentIds, up to 500 |
experiment and series revision counters, for cache checks |
Identity-taking reads accept exactly one of experimentId and experimentSlug.
An unknown slug is experiment_not_found; a catalog or series read by an id
that does not exist, or belongs to another organization, returns no series.
Streaming series
POST /api/v2/query/series takes exactly one envelope, never an array, and
answers application/x-ndjson: one reply envelope per line.
jq -n --arg id "$(uuid)" --arg exp "$EXP" '{
messageId: $id, event: "syvain.v2.query.series",
data: {experimentId: $exp, seriesName: "loss", filter: [{metadataKey: "split", metadataValue: "valid"}], xAxis: "step"}
}' | curl -sSN "$HOST/api/v2/query/series" \
-H "authorization: Bearer $KEY" -H 'content-type: application/json' --data-binary @-{"messageId":"...","correlationId":"...","event":"syvain.v2.query.series.data","data":{"experimentId":"019e...","seriesName":"loss","metadata":{"split":"valid"},"data":{"step":[100],"timestampMs":[1789776000000],"value":[2.47]}}}
{"messageId":"...","correlationId":"...","event":"syvain.v2.query.series.done","data":{"batches":1,"points":1}}syvain.v2.query.series reads one series name. filter: [] reads every
metadata partition. A predicate with only metadataKey requires the key; with
metadataValue it requires that exact value. All predicates must match, at
most 32. xAxis is step or timestamp and sorts points ascending; there is
no slicing, descending order or aggregation.
syvain.v2.query.experimentseries reads many series of one experiment in one
stream: seriesNames lists up to 1,000 names, or is omitted for every series,
and filter and xAxis apply to all of them. Data records carry their
seriesName and records of different series can interleave; after a series'
last record comes syvain.v2.query.experimentseries.series with its batches
and points, and the stream ends with syvain.v2.query.experimentseries.done
with series, batches and points totals. A requested name without data
reports zero counts.
Parse the stream by these rules:
- Split on newlines across network chunks; a record can span chunks.
- Each
series.datarecord is one metadata partition with alignedstep,timestampMsandvaluearrays; the values at one index form one point.stepcan benullfor older data. - A long partition arrives as several consecutive records with the same
seriesNameandmetadata; concatenate them. - A complete stream ends with
done. No matching data still ends withdoneand zero counts. - A
syvain.v2.errorrecord ends a failed stream, possibly after partial data. End of input withoutdoneis incomplete, even with HTTP 200. - Count batches and points against
donebefore trusting the result, and never append a full retry to partial data without deduplicating.
v1
The older REST API under /api/v1 remains available for existing integrations.
New code uses v2.
Related: API events, organizations and keys, limits and fair use, the collector.