Docs · Trio-Lumen API

The API documentation is available in English.

Timed labels for first-person video.

Trio-Lumen labels first-person (egocentric) video with timed activity and per-hand action segments. You submit a batch of videos, review the quote, and fetch one verified JSON result per video.

Getting started

  1. Sign up at platform.machinefi.com.
  2. Add credits to your balance in the console (Billing). Lumen and Trio-Spark share one prepaid balance; there is no subscription. See Billing.
  3. Create an API key with Lumen enabled. In the console (API Keys), check Trio-Lumen when you create the key (it is checked by default). The secret is shown once; store it on your server.
  4. Submit a batch with POST /batches (see Workflow), using public https URLs as inputs (presigned URLs recommended) and the default hosted output, then poll GET /batches/{id} and fetch results with GET /items/{id}/result.

A complete, runnable example is in the Quickstart below. Steps 1–4 are fully self-serve. Two options need us for now; contact us to set them up:

Neither is required to get started: any public https URL works as an input, and results are hosted for you.

Quickstart

Each sample below runs the whole flow end to end: create a batch with an Idempotency-Key, poll it until it finishes or waits for confirmation, confirm the quote if needed, list the items, and download each result's JSON from its short-lived link. Set your API key first:

export TRIO_API_KEY=tf_...
export VIDEO_URL="https://your-host.example/path/clip.mp4"
export MAX_AMOUNT_USD=...   # optional, Python and Node: your budget for this batch

VIDEO_URL is a public https URL to an MP4 or MOV file (see Supported inputs). Save the curl sample as a script and run it with bash.

Send a User-Agent header. If you call the API with a raw HTTP client, set a User-Agent header that names your app. Some default library user agents are blocked at the edge before the request reaches the API. The samples below set one.

curl

#!/usr/bin/env bash
# Requires bash, curl 7.76+ and jq. Run after exporting TRIO_API_KEY and VIDEO_URL.
set -euo pipefail
: "${TRIO_API_KEY:?export TRIO_API_KEY first}"
: "${VIDEO_URL:?export VIDEO_URL first (a public https URL to an MP4 or MOV file)}"
BASE=https://platform.machinefi.com/api/lumen/v1
api() { curl -sS --fail-with-body -H "Authorization: Bearer $TRIO_API_KEY" "$@"; }

# 1. Create a batch. Reuse the same Idempotency-Key if you retry this request.
BODY=$(jq -n --arg url "$VIDEO_URL" '{task: "egocentric-actions", inputs: [{url: $url}], output: {type: "hosted"}}')
BATCH=$(api -X POST "$BASE/batches" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" -d "$BODY" | jq -r .id)
echo "batch $BATCH"

# 2. Poll until the batch finishes; 3. confirm the quote only after you approve it.
while true; do
  B=$(api "$BASE/batches/$BATCH")
  STATUS=$(jq -r .status <<<"$B")
  echo "status: $STATUS"
  case $STATUS in
    completed|completed_with_errors|failed|cancelled) break ;;
    awaiting_confirmation)
      jq -r '.quote | "quote: " + .amount_usd + " USD (" + .price_per_hour_usd + " USD per hour)"' <<<"$B"
      read -r -p "Confirm this quote? [y/N] " OK || OK=n
      if [ "$OK" = y ]; then
        api -X POST "$BASE/batches/$BATCH/confirm" >/dev/null || echo "confirm refused (for example quote_expired); polling again"
      else
        api -X POST "$BASE/batches/$BATCH/cancel" >/dev/null
        echo "Not confirmed; the batch was cancelled."; exit 1
      fi ;;
    blocked)
      REASON=$(jq -r .blocked_reason <<<"$B")
      case $REASON in
        insufficient_funds|over_limit) echo "blocked ($REASON): add credits in the console (Billing); the batch resumes on its own." ;;
        credentials_invalid) echo "blocked: your bucket credentials no longer work. Send us new credentials; the batch resumes."; exit 1 ;;
        *) echo "blocked ($REASON): contact support."; exit 1 ;;
      esac ;;
  esac
  sleep 15
done
jq -r '"failure_reason: " + (.failure_reason // "none")' <<<"$B"

# 4. List items; 5. download each succeeded item's result and check its sha256.
CURSOR=""
FOUND=0
while true; do
  PAGE=$(api "$BASE/batches/$BATCH/items?limit=100${CURSOR:+&cursor=$CURSOR}")
  jq -r '.data[] | [.id, .status, (.reason // "")] | @tsv' <<<"$PAGE"
  for ITEM in $(jq -r '.data[] | select(.status == "succeeded") | .id' <<<"$PAGE"); do
    RES=$(api "$BASE/items/$ITEM/result")
    curl -sS --fail-with-body -o "$ITEM.json" "$(jq -r .url <<<"$RES")"   # presigned link: no API key
    WANT=$(jq -r .sha256 <<<"$RES")
    GOT=$( (sha256sum "$ITEM.json" 2>/dev/null || shasum -a 256 "$ITEM.json") | cut -d' ' -f1)
    if [ "$GOT" != "$WANT" ]; then rm -f "$ITEM.json"; echo "checksum mismatch for $ITEM; fetch a fresh link and retry"; exit 1; fi
    echo "  saved $ITEM.json: $(jq '.activity_segments | length' "$ITEM.json") activity segments"
    FOUND=$((FOUND + 1))
  done
  CURSOR=$(jq -r '.next_cursor // empty' <<<"$PAGE")
  [ -n "$CURSOR" ] || break
done
[ "$FOUND" -gt 0 ] || { echo "No succeeded items; see the reasons above."; exit 1; }

Python (requests)

# pip install requests
import hashlib, json, os, sys, time, uuid
from decimal import Decimal
import requests

BASE = "https://platform.machinefi.com/api/lumen/v1"
VIDEO_URL = os.environ["VIDEO_URL"]               # a public https URL to an MP4 or MOV file
BUDGET = os.environ.get("MAX_AMOUNT_USD")         # optional: quotes within it are confirmed automatically
TERMINAL = {"completed", "completed_with_errors", "failed", "cancelled"}

s = requests.Session()
s.headers.update({
    "Authorization": "Bearer " + os.environ["TRIO_API_KEY"],
    "User-Agent": "my-app/1.0",
})

def check(r):
    if r.status_code >= 400:
        sys.exit(f"HTTP {r.status_code}: {r.text}")
    return r.json()

# 1. Create a batch. Reuse the same Idempotency-Key if you retry this request.
body = {"task": "egocentric-actions", "inputs": [{"url": VIDEO_URL}], "output": {"type": "hosted"}}
if BUDGET:
    body["max_amount_usd"] = BUDGET
batch = check(s.post(f"{BASE}/batches", json=body, timeout=30,
                     headers={"Idempotency-Key": str(uuid.uuid4())}))
batch_id = batch["id"]
print("batch", batch_id)

# 2. Poll until the batch finishes; 3. confirm the quote only after you approve it.
while True:
    batch = check(s.get(f"{BASE}/batches/{batch_id}", timeout=30))
    status = batch["status"]
    print("status:", status)
    if status in TERMINAL:
        break
    if status == "awaiting_confirmation":
        # With MAX_AMOUNT_USD set, a batch only waits here when its quote is over that budget.
        q = batch["quote"]
        over = BUDGET and Decimal(q["amount_usd"]) > Decimal(BUDGET)
        print(f"quote: {q['amount_usd']} USD ({q['price_per_hour_usd']} USD per hour)"
              + (f", over your budget of {BUDGET} USD" if over else ""))
        try:
            answer = input("Confirm this quote? [y/N] ")
        except EOFError:
            answer = ""
        if answer.strip().lower() != "y":
            check(s.post(f"{BASE}/batches/{batch_id}/cancel", timeout=30))
            sys.exit("Not confirmed; the batch was cancelled.")
        r = s.post(f"{BASE}/batches/{batch_id}/confirm", timeout=30)
        if r.status_code == 409:
            print("confirm:", r.json()["error"]["code"])  # e.g. quote_expired: a new quote follows
        else:
            check(r)
    elif status == "blocked":
        reason = batch["blocked_reason"]
        if reason in ("insufficient_funds", "over_limit"):
            print(f"blocked ({reason}): add credits in the console (Billing); the batch resumes on its own.")
        elif reason == "credentials_invalid":
            sys.exit("blocked: your bucket credentials no longer work. Send us new credentials; the batch resumes.")
        else:
            sys.exit(f"blocked ({reason}): contact support.")
    time.sleep(15)
print("failure_reason:", batch["failure_reason"])

# 4. List items; 5. download each succeeded item's result and check its sha256.
cursor, found = None, 0
while True:
    params = {"limit": 100, **({"cursor": cursor} if cursor else {})}
    page = check(s.get(f"{BASE}/batches/{batch_id}/items", params=params, timeout=30))
    for item in page["data"]:
        print(item["id"], item["status"], item["reason"] or "")
        if item["status"] != "succeeded":
            continue
        res = check(s.get(f"{BASE}/items/{item['id']}/result", timeout=30))
        dl = requests.get(res["url"], timeout=120)  # presigned link: no API key
        dl.raise_for_status()
        if hashlib.sha256(dl.content).hexdigest() != res["sha256"]:
            sys.exit("checksum mismatch; fetch a fresh link and retry")
        with open(f"{item['id']}.json", "wb") as f:
            f.write(dl.content)
        doc = json.loads(dl.content)
        print("  saved", f"{item['id']}.json:", len(doc["activity_segments"]), "activity segments")
        found += 1
    cursor = page["next_cursor"]
    if not cursor:
        break
if not found:
    sys.exit("No succeeded items; see the reasons above.")

Node.js 18+ (built-in fetch)

// Node 18+ (built-in fetch). Save as lumen-quickstart.mjs and run: node lumen-quickstart.mjs
import { createHash, randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { createInterface } from "node:readline/promises";

const BASE = "https://platform.machinefi.com/api/lumen/v1";
const VIDEO_URL = process.env.VIDEO_URL; // a public https URL to an MP4 or MOV file
const BUDGET = process.env.MAX_AMOUNT_USD; // optional: quotes within it are confirmed automatically
const TERMINAL = new Set(["completed", "completed_with_errors", "failed", "cancelled"]);
const HEADERS = { Authorization: "Bearer " + process.env.TRIO_API_KEY, "User-Agent": "my-app/1.0" };
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const fail = (message) => { console.error(message); process.exit(1); };
if (!process.env.TRIO_API_KEY || !VIDEO_URL) fail("export TRIO_API_KEY and VIDEO_URL first");

async function api(method, path, { body, headers = {}, allow = [] } = {}) {
  const res = await fetch(BASE + path, {
    method,
    headers: { ...HEADERS, ...(body ? { "Content-Type": "application/json" } : {}), ...headers },
    body: body ? JSON.stringify(body) : undefined,
  });
  const text = await res.text();
  if (!res.ok && !allow.includes(res.status)) fail("HTTP " + res.status + ": " + text);
  return { status: res.status, data: JSON.parse(text) };
}

// Resolves to the typed answer, or to "" (declined) if stdin closes first.
function ask(question) {
  const rl = createInterface({ input: process.stdin, output: process.stdout });
  return new Promise((resolve) => {
    let answered = false;
    rl.on("close", () => { if (!answered) resolve(""); });
    rl.question(question).then(
      (answer) => { answered = true; resolve(answer); rl.close(); },
      () => resolve(""),
    );
  });
}

// 1. Create a batch. Reuse the same Idempotency-Key if you retry this request.
const body = { task: "egocentric-actions", inputs: [{ url: VIDEO_URL }], output: { type: "hosted" } };
if (BUDGET) body.max_amount_usd = BUDGET;
const created = await api("POST", "/batches", { headers: { "Idempotency-Key": randomUUID() }, body });
const batchId = created.data.id;
console.log("batch", batchId);

// 2. Poll until the batch finishes; 3. confirm the quote only after you approve it.
let batch;
while (true) {
  batch = (await api("GET", "/batches/" + batchId)).data;
  console.log("status:", batch.status);
  if (TERMINAL.has(batch.status)) break;
  if (batch.status === "awaiting_confirmation") {
    // With MAX_AMOUNT_USD set, a batch only waits here when its quote is over that budget.
    const q = batch.quote;
    const over = BUDGET && Number(q.amount_usd) > Number(BUDGET);
    console.log("quote: " + q.amount_usd + " USD (" + q.price_per_hour_usd + " USD per hour)"
      + (over ? ", over your budget of " + BUDGET + " USD" : ""));
    const answer = await ask("Confirm this quote? [y/N] ");
    if (answer.trim().toLowerCase() !== "y") {
      await api("POST", "/batches/" + batchId + "/cancel");
      fail("Not confirmed; the batch was cancelled.");
    }
    const r = await api("POST", "/batches/" + batchId + "/confirm", { allow: [409] });
    if (r.status === 409) console.log("confirm:", r.data.error.code); // e.g. quote_expired: a new quote follows
  } else if (batch.status === "blocked") {
    const reason = batch.blocked_reason;
    if (reason === "insufficient_funds" || reason === "over_limit") {
      console.log("blocked (" + reason + "): add credits in the console (Billing); the batch resumes on its own.");
    } else if (reason === "credentials_invalid") {
      fail("blocked: your bucket credentials no longer work. Send us new credentials; the batch resumes.");
    } else {
      fail("blocked (" + reason + "): contact support.");
    }
  }
  await sleep(15000);
}
console.log("failure_reason:", batch.failure_reason);

// 4. List items; 5. download each succeeded item's result and check its sha256.
let cursor = null;
let found = 0;
do {
  const query = "?limit=100" + (cursor ? "&cursor=" + encodeURIComponent(cursor) : "");
  const page = (await api("GET", "/batches/" + batchId + "/items" + query)).data;
  for (const item of page.data) {
    console.log(item.id, item.status, item.reason ?? "");
    if (item.status !== "succeeded") continue;
    const res = (await api("GET", "/items/" + item.id + "/result")).data;
    const download = await fetch(res.url); // presigned link: no API key
    if (!download.ok) fail("download failed: HTTP " + download.status);
    const bytes = Buffer.from(await download.arrayBuffer());
    if (createHash("sha256").update(bytes).digest("hex") !== res.sha256) fail("checksum mismatch");
    await writeFile(item.id + ".json", bytes);
    const doc = JSON.parse(bytes.toString("utf8"));
    console.log("  saved", item.id + ".json:", doc.activity_segments.length, "activity segments");
    found++;
  }
  cursor = page.next_cursor;
} while (cursor);
if (!found) fail("No succeeded items; see the reasons above.");

The samples never confirm a quote on their own. When a batch waits in awaiting_confirmation, they show the quote and ask you to approve it; anything but y cancels the batch, and nothing is charged. With MAX_AMOUNT_USD set, the Python and Node samples send it as max_amount_usd: a quote within that budget is confirmed by us automatically, and a quote over it still waits for your approval (a manual confirm does not check max_amount_usd). With webhooks you can drop the polling loop.

Supported inputs

Every input is inspected during preflight: we read its container, streams and timestamps, without decoding the whole video. A file that fails these checks becomes a skipped item with the reason shown. A problem found only later, while processing (for example frames that cannot be decoded), ends the item as failed, with a reason such as decode_error. Neither skipped nor failed items are charged.

RequirementAcceptedOtherwise
ContainerMP4 or MOV. 3GP files and audio-only files (M4A, M4B) are not accepted.unsupported_container
Video codecH.264 (AVC) or H.265 (HEVC)unsupported_codec
Video streamAt least one video streamno_video_stream
File sizeUp to 10 GB (10,000,000,000 bytes)too_large
LengthUp to 70 minutes, measured as the longer of the video stream and the filetoo_long
Video startThe first video frame is at most 10 seconds into the file, and within the first 25% of the video's durationbad_timestamps
Readable fileA complete file with a readable duration and frame sizeunreadable

URL inputs. A bucket is not required: any public https URL that serves the video file works.

Bucket inputs. Under a registered source's prefix, every non-empty object whose key ends in .mp4 or .mov (any case) becomes an item. Other objects are ignored.

Processing time

Authentication

Send your API key as a bearer token:

Authorization: Bearer tf_...

Create the key in the platform console with Trio-Lumen checked. A key can be enabled for Trio-Spark, Trio-Lumen, or both; this is fixed when the key is created. A key that is not enabled for Lumen gets 403 missing_scope: create a new key with Lumen checked (you can then revoke the old one). Keys created through the console API without a products list are enabled for Trio-Spark only. Keep keys server-side and never embed them in clients.

Workflow

1. Create a batch

Send your video inputs. The batch starts in preflight: we fetch and inspect every video and compute a quote.

2. Quote

3. Processing

Each video becomes an item and is processed independently. Items that finish with a verified result are succeeded and are charged on the video time covered, rounded up to whole minutes, with a minimum per item set by the quote (currently 2 minutes). See Billing.

4. Results

Only succeeded items are charged. Skipped, failed, cancelled and needs_review items are not charged. An item in needs_review is checked by us. If we approve it, it becomes succeeded and is charged then, unless it qualifies for reuse (see Items in needs_review).

Endpoints

PathMethodPurpose
/batchesPOSTCreate a batch (requires Idempotency-Key)
/batchesGETList your batches, newest first
/batches/{id}GETGet a batch
/batches/{id}/confirmPOSTConfirm a quote, or resume a batch blocked for funds
/batches/{id}/cancelPOSTCancel a batch
/batches/{id}/itemsGETList a batch's items
/items/{id}/resultGETGet a short-lived download link for an item's result
/schemaGETResult JSON Schema and available task versions

Create a batch

POST https://platform.machinefi.com/api/lumen/v1/batches

HeaderDescription
AuthorizationBearer followed by your API key. Required.
Idempotency-KeyRequired. 8–255 characters from A–Z a–z 0–9 . _ : -. Use a new key for every new batch.
Content-Typeapplication/json
{
  "task": "egocentric-actions",
  "inputs": [
    {
      "url": "https://storage.example.com/raw/clip-001.mp4?X-Amz-Signature=..."
    },
    {
      "source": "src_3f2a...",
      "prefix": "raw/2026-11/"
    }
  ],
  "output": {
    "type": "hosted"
  },
  "webhook_url": "https://hooks.example.com/lumen",
  "metadata": {
    "project": "warehouse-q4"
  }
}
FieldDescription
taskRequired. "egocentric-actions"
versionOptional. A task version from GET /schema. Defaults to "latest". The version used is pinned on the batch as processing_version.
inputsRequired. 1–2000 entries. Each is either { "url" } or { "source", "prefix" }. A batch holds at most 2000 videos in total: a prefix that lists more fails the batch with too_many_items.
inputs[].urlA public https URL we can GET, typically a presigned URL valid for at least 24 hours. We copy the video during preflight and do not keep the URL after that.
inputs[].source, inputs[].prefixA registered bucket (we give you its src_... id) and a folder inside its read scope. Every video under the prefix becomes an item. The listing is taken once during preflight.
max_amount_usdOptional. Auto-confirm quotes up to this amount. Omit it to confirm every non-zero quote yourself.
outputOptional. { "type": "hosted" } (default), or { "type": "bucket", "source": "src_...", "prefix": "lumen-out/" } to write results into your bucket. The prefix must be a folder inside the source's write scope.
webhook_urlOptional. A public https URL for webhooks. Contact us for a signing secret first.
metadataOptional. Your own key/values, up to 32 keys and 8 KB. Returned unchanged.

Response: 202 Accepted with the batch object.

Bucket output. Every batch writes into its own folder, <prefix><batch_id>/, so batches that share an output prefix never touch each other's files:

Idempotency.

Batch object

{
  "object": "batch",
  "id": "8d0c7c1e-4b0f-4f1a-9a57-0f3f6a0f2b11",
  "status": "running",
  "blocked_reason": null,
  "failure_reason": null,
  "cancel_requested": false,
  "task": "egocentric-actions",
  "processing_version": "egocentric-actions@<version>",
  "created_at": "2026-11-01T18:02:11.204Z",
  "confirmed_at": "2026-11-01T18:04:40.017Z",
  "finished_at": null,
  "preflight": {
    "pending_inputs": 0
  },
  "quote": {
    "amount_usd": "...",
    "price_per_hour_usd": "...",
    "min_billable_minutes": 2,
    "quoted_at": "2026-11-01T18:04:39.950Z",
    "expires_at": "2026-11-02T18:04:39.950Z"
  },
  "max_amount_usd": "...",
  "amounts": {
    "reserved_usd": "...",
    "settled_usd": "..."
  },
  "counts": {
    "queued": 40,
    "running": 4,
    "publishing": 0,
    "succeeded": 12,
    "needs_review": 0,
    "failed": 0,
    "skipped": 1,
    "cancelled": 0
  },
  "output": {
    "type": "hosted"
  },
  "webhook_url": "https://hooks.example.com/lumen",
  "metadata": {
    "project": "warehouse-q4"
  }
}

List batches

GET /batches?limit=20&cursor=...

Confirm or resume

POST /batches/{id}/confirm, no body. Returns the batch object.

Cancel

POST /batches/{id}/cancel, no body. Returns the batch object.

If your Lumen access ends, you can no longer create or confirm batches, but you can still list, read and cancel your existing batches and download their results. Batches still open 24 hours after access ended are cancelled as above: queued items are not charged, and items already running finish and are charged normally.

List items

GET /batches/{id}/items?status=succeeded&limit=100&cursor=... · limit is 1–500 (default 100); status filters by item status.

{
  "object": "list",
  "data": [
    {
      "object": "item",
      "id": "5a1e9a42-0b9e-4c55-8f0e-2a7b0f9c1d33",
      "batch_id": "8d0c7c1e-4b0f-4f1a-9a57-0f3f6a0f2b11",
      "position": 0,
      "input": {
        "type": "url",
        "index": 0,
        "host": "storage.example.com"
      },
      "status": "succeeded",
      "reason": null,
      "duration_s": 600,
      "container_duration_s": 600.5,
      "covered_s": 600,
      "billing": "charged",
      "billable_minutes": 10,
      "charged_usd": "...",
      "result_available": true,
      "result_expires_at": "2026-12-01T18:40:02.000Z",
      "created_at": "2026-11-01T18:02:11.204Z",
      "finished_at": "2026-11-01T18:40:02.000Z"
    }
  ],
  "next_cursor": null
}
Item fieldDescription
duration_sDuration of the video stream. This is what we label and quote.
container_duration_sDuration of the whole file, which can be longer when audio runs past the video.
covered_sVideo time labeled in the result.
billable_minutesFor a charged item: covered time, capped at the video stream's duration (duration_s), rounded up to whole minutes, and at least the batch's quote.min_billable_minutes. 0 for a reused item, which is not charged.
billingcharged, reused (the same content was already charged within the reuse window), not_billed, or pending while the item is not finished.
input{ "type": "url", "index", "host" } for URL inputs (the URL itself is never returned), or { "type": "source", "source", "key" } for bucket inputs.

Get a result

GET /items/{id}/result

{
  "object": "result",
  "item_id": "5a1e9a42-0b9e-4c55-8f0e-2a7b0f9c1d33",
  "schema": "lumen.egocentric-actions.v1",
  "sha256": "9b1f...",
  "bytes": 48213,
  "url": "https://...",
  "url_expires_at": "2026-11-01T18:55:02.000Z",
  "retained_until": "2026-12-01T18:40:02.000Z"
}

Schema

GET /schema returns id (lumen.egocentric-actions.v1), tasks (the available versions, with latest marked) and json_schema (the full result JSON Schema).

Bucket permissions

Your own storage (optional, set up with us). To read inputs from, or write results back to, your own storage, you provide an S3-compatible bucket you control (AWS S3, Cloudflare R2, or another S3-compatible store) and credentials with read access to the input prefix and read and write access to the output prefix. Credentials without the permissions below are refused when the source is registered. Without a bucket, use public https URL inputs (presigned URLs recommended) and hosted results.

The credentials you give us need:

ScopePermissions
Read prefix (inputs)s3:ListBucket, s3:GetObject
Write prefix (outputs)s3:PutObject, s3:GetObject, s3:ListBucket (we read _COMPLETE.json back before replacing it; without s3:ListBucket a missing object answers 403 instead of 404)
Write prefix, optionals3:DeleteObject (removes the registration check object)

When the bucket is registered, and whenever its credentials are replaced, we check them with a small test object at <write prefix>.lumen-selftest/<random>.json: read of a missing key, write, read back, and two conditional writes that must be refused. Credentials missing a required permission are not accepted. The test object is deleted afterwards; without s3:DeleteObject it stays (a few bytes) and can be removed at any time.

AWS IAM example (inputs under raw/, outputs under lumen-out/):

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::<bucket>",
      "Condition": {
        "StringLike": {
          "s3:prefix": [
            "raw/*",
            "lumen-out/*"
          ]
        }
      }
    },
    {
      "Effect": "Allow",
      "Action": "s3:GetObject",
      "Resource": [
        "arn:aws:s3:::<bucket>/raw/*",
        "arn:aws:s3:::<bucket>/lumen-out/*"
      ]
    },
    {
      "Effect": "Allow",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::<bucket>/lumen-out/*"
    },
    {
      "Effect": "Allow",
      "Action": "s3:DeleteObject",
      "Resource": "arn:aws:s3:::<bucket>/lumen-out/.lumen-selftest/*"
    }
  ]
}

Cloudflare R2: an R2 API token with Object Read & Write on the bucket covers all of the above (R2 tokens are scoped per bucket, not per prefix).

Statuses and reasons

Batch status

StatusMeaning
preflightFetching and inspecting inputs, computing the quote
awaiting_confirmationQuote ready; waiting for confirm
runningItems are being processed
blockedPaused; see blocked_reason
completedEvery item succeeded
completed_with_errorsSome items succeeded and some did not
failedNo item succeeded; see failure_reason
cancelledCancelled at your request

A finished batch can change from completed_with_errors or failed to completed (or completed_with_errors) when we approve an item that was in needs_review. A batch.completed webhook announces the change.

blocked_reason

ReasonWhat to do
insufficient_fundsAdd funds, then call confirm. Blocked batches also resume on their own once the balance covers them.
over_limitThe batch exceeds your credit limit. Add funds, or contact us.
credentials_invalidThe registered bucket's credentials no longer work. Send us new credentials; the batch resumes.
payment_reversedContact us.

failure_reason

no_processable_items, all_items_failed, too_many_items, batch_too_large, listing_too_large, source_unavailable, credentials_invalid, preflight_timeout (inputs could not be inspected within 24 hours), processing_error.

Item status

queued, running, publishing (result being verified), succeeded, needs_review, failed, skipped, cancelled.

Items in needs_review

needs_review means the item finished processing but its result was held back instead of delivered. Its reason says why:

While an item is in needs_review:

We review held items. If we approve one, it becomes succeeded and its result becomes available. It is charged then (billing charged), never more than its share of the confirmed quote, with one exception: if the same content was already charged within the reuse window, or the item was quoted at no charge, it is approved with billing reused and nothing is charged. The batch can then move to completed or completed_with_errors (a failure_reason is cleared), announced by item.completed and batch.completed webhooks. An item that is not approved stays in needs_review and is never charged.

Item reason

None of these is charged.

ReasonMeaning
too_largeFile over 10 GB
too_longOver 70 minutes, measured as the longer of the video stream and the file
unsupported_container · unsupported_codec · no_video_streamThe file is not a supported video
bad_timestampsBroken or implausible timestamps (for example, the video starts far into the file). Re-mux and resubmit.
unreadable · fetch_failed · input_expiredWe could not read the input, or its URL expired before processing
input_mismatchThe input changed while we were processing it
decode_error · pipeline_error · timeout · processing_errorProcessing did not complete
invalid_resultThe result failed our verification and was discarded
qa_failedThe result did not pass quality checks; it is in needs_review
exceeds_quoteThe result would have cost more than the confirmed quote; it was not charged
verification_unavailable · preflight_unavailableTemporary; processing was not completed in time
batch_rejected · cancelledThe batch was rejected or cancelled

Webhooks

Set webhook_url on a batch to receive signed POST requests. We issue your signing secret through a secure channel. To rotate the secret, ask us for a new one; the old secret keeps working for 24 hours.

Events

TypeWhen
batch.awaiting_confirmationThe quote is ready and the batch is waiting for your confirm. It is not sent when the batch is confirmed automatically.
batch.blockedThe batch was paused (reason gives the blocked_reason)
batch.completedThe batch finished with status completed or completed_with_errors. It is sent again, with previous_status, if an approval changes the outcome.
batch.failedThe batch finished with no succeeded item
batch.cancelledThe batch finished after a cancel
item.completedAn item reached succeeded, needs_review or failed, or was approved

Payload

{
  "event_id": "item.completed:5a1e9a42-0b9e-4c55-8f0e-2a7b0f9c1d33",
  "type": "item.completed",
  "batch_id": "8d0c7c1e-4b0f-4f1a-9a57-0f3f6a0f2b11",
  "item_id": "5a1e9a42-0b9e-4c55-8f0e-2a7b0f9c1d33",
  "status": "succeeded",
  "sequence": 7,
  "occurred_at": "2026-11-01T18:40:02.311Z"
}

Headers and signature

Content-Type: application/json
X-Lumen-Event-Id: item.completed:5a1e...
X-Lumen-Event-Type: item.completed
X-Lumen-Timestamp: 1793556002
X-Lumen-Signature: v1=5f0c...[,v1=...]

The signature is HMAC-SHA256(secret, "<X-Lumen-Timestamp>.<raw request body>"), hex-encoded with the prefix v1=. During a secret rotation the header carries several comma-separated v1= values; accept the request if any one matches. When verifying, reject timestamps more than 5 minutes from your clock, and verify against the raw body bytes, before any JSON parsing.

Verification: Node.js

import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.LUMEN_WEBHOOK_SECRET;

// Returns true only for a fresh timestamp and a matching v1= signature.
// Malformed headers return false; they never throw.
function verifyLumenSignature(secret, ts, header, rawBody, nowSeconds = Date.now() / 1000) {
  if (typeof ts !== "string" || !/^[0-9]{1,12}$/.test(ts)) return false;
  if (Math.abs(nowSeconds - Number(ts)) > 300) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(Buffer.concat([Buffer.from(`${ts}.`), rawBody]))
    .digest(); // 32 bytes
  let ok = false;
  for (const token of String(header ?? "").split(",")) {
    const m = /^v1=([0-9a-fA-F]{64})$/.exec(token.trim());
    if (!m) continue; // skip malformed tokens
    ok = crypto.timingSafeEqual(Buffer.from(m[1], "hex"), expected) || ok;
  }
  return ok;
}

const app = express();
app.post("/lumen", express.raw({ type: "application/json" }), (req, res) => {
  const ok = verifyLumenSignature(SECRET, req.get("X-Lumen-Timestamp"), req.get("X-Lumen-Signature"), req.body);
  if (!ok) return res.sendStatus(400);
  const event = JSON.parse(req.body.toString("utf8"));
  // Deduplicate on event.event_id; order per batch_id by event.sequence.
  res.sendStatus(204);
});

Verification: Python

import hashlib, hmac, json, os, re, time
from flask import Flask, request, abort

SECRET = os.environ["LUMEN_WEBHOOK_SECRET"].encode()
app = Flask(__name__)

def verify_lumen_signature(secret, ts, header, raw_body, now=None):
    """True only for a fresh timestamp and a matching v1= signature.
    Malformed headers return False; they never raise."""
    if not isinstance(ts, str) or not re.fullmatch(r"[0-9]{1,12}", ts):
        return False
    if abs((time.time() if now is None else now) - int(ts)) > 300:
        return False
    expected = hmac.new(secret, ts.encode("ascii") + b"." + raw_body, hashlib.sha256).digest()
    ok = False
    for token in (header or "").split(","):
        m = re.fullmatch(r"v1=([0-9a-fA-F]{64})", token.strip())
        if not m:
            continue  # skip malformed tokens
        ok = hmac.compare_digest(bytes.fromhex(m.group(1)), expected) or ok
    return ok

@app.post("/lumen")
def lumen_webhook():
    body = request.get_data()  # raw bytes
    if not verify_lumen_signature(SECRET, request.headers.get("X-Lumen-Timestamp"),
                                  request.headers.get("X-Lumen-Signature"), body):
        abort(400)
    event = json.loads(body)
    # Deduplicate on event["event_id"]; order per batch_id by event["sequence"].
    return "", 204

Result format

Each succeeded item has one JSON document. It follows the JSON Schema lumen.egocentric-actions.v1, returned in full by GET /schema. In outline:

FieldDescription
schemaAlways "lumen.egocentric-actions.v1".
processing_versionThe task version that produced the result, the same value as the batch's processing_version (for example "egocentric-actions@...").
taxonomy_versionThe version of the label set (activity actions, hand actions and grasp types) the result uses.
inputsha256 of your file, duration_s (the video stream), video_start_s.
covered_secondsSeconds of video covered by the timeline, unlabeled segments included. A trailing fragment shorter than 0.25 s that carries no label is not counted, nor is the leading no-video span.
activity_segmentsA gap-free timeline of activity segments, each with start_s, end_s, action, object, hands, description.
hands.left · hands.rightGap-free per-hand timelines, each segment with start_s, end_s, action, grasp, object.
qaThe quality checks the result passed, and metrics.labeled_share.

Limits

LimitValue
Videos per batch2000 in total, URLs and prefix listings combined (more fails the batch with too_many_items; a listing too large to read fails with listing_too_large)
Video hours per batch200
File size per video10 GB (10,000,000,000 bytes)
Video length per video70 minutes
Request body4 MB
metadata32 keys, 8 KB
Open batches per account20 (more returns 429 over_limit)
Request rate300 requests per minute per account
Quote validity24 hours
Hosted result retention30 days
Result download link15 minutes

Errors

Errors use HTTP status codes and a JSON body with a stable code, a human-readable message and a retryable flag.

{
  "error": {
    "code": "invalid_request",
    "message": "inputs must hold 1–2000 entries.",
    "retryable": false
  }
}

Retry only when retryable is true (or on 429 and 503), with backoff. Reuse the same Idempotency-Key when retrying POST /batches.

CodeStatusMeaning
unauthenticated401Missing or invalid API key
missing_scope403The key does not have Lumen enabled; create a new key with Lumen enabled
not_found404No such batch, item or source for this account
idempotency_conflict409The Idempotency-Key was used for a different request
not_ready409Preflight is still running
quote_expired409The quote expired; a new one follows
quote_changed409The quote changed while you confirmed; read the batch and confirm again
blocked409The batch is blocked for a reason only we can clear
invalid_state409The batch or item is not in a state that allows this
result_unavailable409The item has no result
result_expired409The hosted result is past retention
timeout408The request body was not received in time; retry
too_large413The request body is too large
invalid_request422The request is malformed; message says why
rate_limited429More than 300 requests in a minute
over_limit429Too many open batches
unavailable503Temporary; retry with backoff

Billing

Lumen is billed per minute of processed video, from the same prepaid balance as Trio-Spark. Add credits in the console (Billing); there is no subscription.

Worked example (minimum of 2 minutes per item):

ItemCovered videoBillable minutes
Short clip10 seconds2 (below the minimum, so the minimum applies)
Clip1 minute 30 seconds2
Clip2 minutes 10 seconds3 (rounded up)
Long video45 minutes exactly45
Unreadable fileskipped0 (not charged)
Total52 billable minutes, charged at the quote's price_per_hour_usd

For volume or a higher credit limit, talk to us.

Ready to send your first batch?

Create an account, add credits in the console (Billing), and create an API key with Trio-Lumen checked.

Go to the platform