Ingest API
The two HTTP endpoints behind the Python SDK. You only need to read this if:
- You're writing a non-Python client (TypeScript, Rust, Go, Bash).
- You're debugging an upload and want to see the wire shape.
- You're security-reviewing how RoboTrace handles ingest.
The flow is always the same three steps:
POST /api/ingest/episode- open a new run, get back an episode id and a batch of short-lived signedPUTURLs (one per artifact slot you requested).PUT <signed-url>- for each artifact, upload the file directly to Cloudflare R2 using the signed URL.POST /api/ingest/episode/{id}/finalize- flip the run toready(orfailed) and roll up duration / fps / bytes.
Authentication
Every call to the RoboTrace endpoints (steps 1 and 3 - not the R2 PUTs) needs a per-client API key in the headers:
Authorization: Bearer rt_8a4f01c2b3_kPcD…Or the equivalent custom header:
X-RoboTrace-Key: rt_8a4f01c2b3_kPcD…See API keys for the format, mint/rotation, and security properties. Keys are scoped per client - the server won't let you finalize an episode that belongs to a different client, and the response on auth failure is identical for both "missing key" and "wrong key" to avoid leaking info.
1. Open a run
POST /api/ingest/episode
Authorization: Bearer rt_<id>_<secret>
Content-Type: application/jsonRequest body
Every field is optional. The server accepts an empty {} body and
opens a metadata-only run with sensible defaults.
{
"name": "pick_and_place v3 morning warmup",
"source": "real",
"robot": "halcyon-bimanual-01",
"policy_version": "pap-v3.2.1",
"env_version": "halcyon-cell-rev4",
"git_sha": "abc1234",
"seed": 8124,
"fps": 30,
"metadata": { "task": "pick_and_place", "scene": "tabletop" },
"request_uploads": ["video", "sensors", "actions"]
}| Field | Type | Default | Notes |
|---|---|---|---|
name | string | null | ≤ 200 chars. Falls back to episode_<short_id> in the UI. |
source | enum | "real" | One of "real", "sim", "replay". |
robot | string | null | ≤ 120 chars. |
policy_version | string | null | ≤ 120 chars. Strongly recommended - eval engine needs it. |
env_version | string | null | ≤ 120 chars. Strongly recommended. |
git_sha | string | null | ≤ 64 chars. |
seed | integer | null | Bigint range. |
fps | number | null | Positive. |
metadata | object | {} | Free-form JSON. Stored as jsonb. |
request_uploads | string[] | ["video", "sensors", "actions"] | Subset of those three. Pass [] for a metadata-only run. |
Response (201 Created)
{
"episode_id": "e8a4f01c-2b39-4f89-b8ab-12c4ab7d40e6",
"status": "recording",
"storage": "r2",
"upload_urls": [
{
"kind": "video",
"url": "https://<account>.r2.cloudflarestorage.com/<bucket>/episodes/<client>/<episode>/video.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Signature=…",
"expires_at": "2026-05-02T15:30:00.000Z",
"public_url": null
},
{
"kind": "sensors",
"url": "…",
"expires_at": "2026-05-02T15:30:00.000Z",
"public_url": null
}
]
}| Field | Notes |
|---|---|
episode_id | UUID. Use this in step 3. |
status | Always "recording" at this point. |
storage | Almost always "r2". The defensive "unconfigured" value exists for self-hosted deployments without object storage wired - production RoboTrace is always "r2". |
upload_urls | One entry per requested artifact. Empty when request_uploads=[] (metadata-only run). |
public_url | Almost always null. Production buckets are private; the portal player fetches each artifact through an auth-gated /api/episodes/[id]/artifact/[kind] route that mints a fresh signed GET URL. The legacy public_url field stays in the response for backward compatibility. |
The Python SDK exposes the storage value on the Episode.storage
field - useful for defensive code that wants to bail loudly if it
hits a deployment without object storage configured.
2. Upload artifacts to R2
For each entry in upload_urls, PUT the file body directly to the
signed URL. Do not include the Authorization header here -
the signed URL carries its own credentials in the query string.
curl -X PUT \
-H "Content-Type: video/mp4" \
--upload-file /tmp/run.mp4 \
"<upload_urls[0].url>"Content-Type matters
Signed PUT URLs are minted with a specific Content-Type header
baked into the signature. Mismatched content type → R2 returns 403.
| Kind | Required Content-Type |
|---|---|
video | video/mp4 |
sensors | application/octet-stream |
actions | application/octet-stream |
Expiry
Signed URLs are valid for 30 minutes. If your upload exceeds
that (multi-GB video on a slow uplink), call step 1 again with the
same metadata and the server mints a fresh batch of signed URLs.
The episode row is not recreated - the create endpoint always
inserts a new row, so re-calling step 1 gives you a new
episode_id. Today there's no "regenerate URLs for an existing
episode" endpoint; that's a known gap, planned for 0.2.
What the SDK does
The Python SDK streams the file from disk via httpx so memory stays
flat regardless of file size. It does not retry on transport
errors during upload - your client should decide what to do (retry
the PUT, request fresh URLs, mark the episode failed).
3. Finalize the run
POST /api/ingest/episode/<episode_id>/finalize
Authorization: Bearer rt_<id>_<secret>
Content-Type: application/jsonRequest body
Every field is optional. An empty {} body finalizes the run as
status="ready" with no roll-up.
{
"status": "ready",
"duration_s": 47.2,
"fps": 30,
"bytes_total": 1840000000,
"metadata": { "outcome": "ok" }
}| Field | Type | Default | Notes |
|---|---|---|---|
status | enum | "ready" | One of "ready", "failed". Cannot transition out of "archived". |
duration_s | number | unchanged | Wall-clock duration. Non-negative. |
fps | number | unchanged | Overrides the value from step 1 if both are set. |
bytes_total | integer | unchanged | Sum of all artifact sizes. Non-negative. |
metadata | object | unchanged | Merged with the metadata from step 1, not overwritten. Per-key. |
Response (200 OK)
{
"episode_id": "e8a4f01c-2b39-4f89-b8ab-12c4ab7d40e6",
"status": "ready",
"updated_at": "2026-05-02T15:00:42.123Z"
}Behavior
- Idempotent. Re-finalizing a
readyepisode returns the same payload but doesn't roll back torecording. - Re-finalizable. A
failedrun can be re-finalized asready(e.g. when a CI retry succeeds). The metadata merges across calls. - Cross-tenant guard. The endpoint checks the episode's
client_idmatches the calling client. Mismatch returns404, not403, to avoid a UUID-enumeration oracle. - Archived runs are protected. Finalize on an archived episode
returns
409. Restore from/portal/episodes/<id>first.
Errors
All errors return JSON with an error field:
{ "error": "Missing or invalid API key. Pass it as `Authorization: Bearer rt_…` or `X-RoboTrace-Key`." }| Status | Common cause | What to do |
|---|---|---|
400 | Body isn't JSON, or fails Zod validation | Fix the payload |
401 | Authorization header missing, or key is unknown / revoked | Re-mint the key |
404 | (finalize) Episode id doesn't exist, or belongs to a different client | Check the id; don't retry |
409 | (finalize) Episode is archived | Restore from /portal/episodes/<id> |
500 | DB / R2 hiccup | Retry with exponential backoff |
401 responses also include a WWW-Authenticate: Bearer realm="robotrace"
header per RFC 6750.
Worked example with curl
End-to-end metadata-only flow, no artifacts:
# 1. Open a run.
EPISODE=$(curl -s -X POST https://app.robotrace.dev/api/ingest/episode \
-H "Authorization: Bearer $ROBOTRACE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "curl demo",
"source": "sim",
"policy_version": "demo-v0",
"metadata": { "via": "curl" },
"request_uploads": []
}' | jq -r .episode_id)
echo "opened $EPISODE"
# 2. (skipped - no uploads requested)
# 3. Finalize it.
curl -s -X POST "https://app.robotrace.dev/api/ingest/episode/$EPISODE/finalize" \
-H "Authorization: Bearer $ROBOTRACE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"status": "ready",
"duration_s": 1.5,
"metadata": { "outcome": "ok" }
}'Don'ts
- Don't put episode bytes in the JSON bodies. Sensor blobs go to the signed PUT URL, not inline.
- Don't log the request body. It can carry trade secrets
(
policy_version, internal robot names, scene labels). The server doesn't, and you shouldn't either. - Don't rely on the same signed URL for retries - they expire every 30 minutes. Re-call step 1.
- Don't assume
readymeans the artifacts uploaded - finalize doesn't verify R2 contents in Phase 1. A future endpoint will verify object existence before flipping the status; until then, CI is the source of truth that the bytes landed.