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 (when R2 is configured) a batch of signed PUT URLs.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/robotrace-episodes/episodes/<client>/<episode>/video.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Signature=…",
"expires_at": "2026-05-02T15:30:00.000Z",
"public_url": "https://artifacts.robotrace.dev/episodes/<client>/<episode>/video.mp4"
},
{
"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 | "r2" when artifacts can be uploaded; "unconfigured" when the deployment hasn't wired R2. |
upload_urls | Empty when storage="unconfigured" or request_uploads=[]. One entry per requested artifact. |
public_url | null until R2_PUBLIC_URL is configured on the server. |
Storage modes
storage: "r2"— bucket is configured, signed URLs are real, PUT uploads will work.storage: "unconfigured"— the deployment hasn't wired R2.upload_urlsis[]. Step 2 is skipped; the run is metadata-only.
The Python SDK exposes this on the Episode.storage field. See
Object storage for what configuring R2
involves.
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/admin/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 admin UI |
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.