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:

  1. POST /api/ingest/episode — open a new run, get back an episode id and (when R2 is configured) a batch of signed PUT URLs.
  2. PUT <signed-url> — for each artifact, upload the file directly to Cloudflare R2 using the signed URL.
  3. POST /api/ingest/episode/{id}/finalize — flip the run to ready (or failed) 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/json

Request 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"]
}
FieldTypeDefaultNotes
namestringnull≤ 200 chars. Falls back to episode_<short_id> in the UI.
sourceenum"real"One of "real", "sim", "replay".
robotstringnull≤ 120 chars.
policy_versionstringnull≤ 120 chars. Strongly recommended — eval engine needs it.
env_versionstringnull≤ 120 chars. Strongly recommended.
git_shastringnull≤ 64 chars.
seedintegernullBigint range.
fpsnumbernullPositive.
metadataobject{}Free-form JSON. Stored as jsonb.
request_uploadsstring[]["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
    }
  ]
}
FieldNotes
episode_idUUID. Use this in step 3.
statusAlways "recording" at this point.
storage"r2" when artifacts can be uploaded; "unconfigured" when the deployment hasn't wired R2.
upload_urlsEmpty when storage="unconfigured" or request_uploads=[]. One entry per requested artifact.
public_urlnull 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_urls is []. 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.

KindRequired Content-Type
videovideo/mp4
sensorsapplication/octet-stream
actionsapplication/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/json

Request 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" }
}
FieldTypeDefaultNotes
statusenum"ready"One of "ready", "failed". Cannot transition out of "archived".
duration_snumberunchangedWall-clock duration. Non-negative.
fpsnumberunchangedOverrides the value from step 1 if both are set.
bytes_totalintegerunchangedSum of all artifact sizes. Non-negative.
metadataobjectunchangedMerged 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 ready episode returns the same payload but doesn't roll back to recording.
  • Re-finalizable. A failed run can be re-finalized as ready (e.g. when a CI retry succeeds). The metadata merges across calls.
  • Cross-tenant guard. The endpoint checks the episode's client_id matches the calling client. Mismatch returns 404, not 403, 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`." }
StatusCommon causeWhat to do
400Body isn't JSON, or fails Zod validationFix the payload
401Authorization header missing, or key is unknown / revokedRe-mint the key
404(finalize) Episode id doesn't exist, or belongs to a different clientCheck the id; don't retry
409(finalize) Episode is archivedRestore from admin UI
500DB / R2 hiccupRetry 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 ready means 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.