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.
- Base URL:
https://platform.machinefi.com/api/lumen/v1 - All requests and responses are JSON over HTTPS.
- All times are ISO 8601 UTC. All money amounts are decimal strings in USD.
Getting started
- Sign up at platform.machinefi.com.
- Add credits to your balance in the console (Billing). Lumen and Trio-Spark share one prepaid balance; there is no subscription. See Billing.
- 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.
- Submit a batch with
POST /batches(see Workflow), using presignedhttpsURLs as inputs and the default hosted output, then pollGET /batches/{id}and fetch results withGET /items/{id}/result.
curl https://platform.machinefi.com/api/lumen/v1/batches \
-H "Authorization: Bearer $TRIO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"task": "egocentric-actions",
"inputs": [{ "url": "https://storage.example.com/raw/clip-001.mp4?X-Amz-Signature=..." }],
"output": { "type": "hosted" }
}'Steps 1–4 are fully self-serve. Two options need us for now; contact us to set them up:
- Your own bucket (read inputs from a prefix, or write results back): create credentials with the bucket permissions below and send the bucket details through a secure channel. We register it and give you its
src_...id. - Webhooks: we issue your signing secret through a secure channel. Until then, poll for status.
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
- If the quote is within
max_amount_usd, the batch is confirmed automatically and starts running. - A zero quote is confirmed automatically, even without
max_amount_usd. - If every input is skipped, there is nothing to quote: the batch fails with
no_processable_itemsand nothing is charged. - Otherwise it waits in
awaiting_confirmationuntil you callPOST /batches/{id}/confirm. - 144 hours after creation, a batch still in
awaiting_confirmation, orblockedbefore any funds were reserved for it, is cancelled automatically. Nothing is charged. - Quotes are valid for 24 hours and are re-quoted when they expire.
- The quote is an upper bound: you are never charged more than the confirmed 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 of one minute per item.
4. Results
- Fetch each result with
GET /items/{id}/result, or have them written to your bucket (output.type = "bucket"). - Hosted results are kept for 30 days.
- Subscribe to webhooks instead of polling.
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.
Endpoints
| Path | Method | Purpose |
|---|---|---|
/batches | POST | Create a batch (requires Idempotency-Key) |
/batches | GET | List your batches, newest first |
/batches/{id} | GET | Get a batch |
/batches/{id}/confirm | POST | Confirm a quote, or resume a batch blocked for funds |
/batches/{id}/cancel | POST | Cancel a batch |
/batches/{id}/items | GET | List a batch's items |
/items/{id}/result | GET | Get a short-lived download link for an item's result |
/schema | GET | Result JSON Schema and available task versions |
Create a batch
POST https://platform.machinefi.com/api/lumen/v1/batches
| Header | Description |
|---|---|
Authorization | Bearer followed by your API key. Required. |
Idempotency-Key | Required. 8–255 characters from A–Z a–z 0–9 . _ : -. Use a new key for every new batch. |
Content-Type | application/json |
{
"task": "egocentric-actions",
"inputs": [
{
"url": "https://storage.example.com/raw/clip-001.mp4?X-Amz-Signature=..."
},
{
"source": "src_3f2a...",
"prefix": "raw/2026-11/"
}
],
"max_amount_usd": "250",
"output": {
"type": "hosted"
},
"webhook_url": "https://hooks.example.com/lumen",
"metadata": {
"project": "warehouse-q4"
}
}| Field | Description |
|---|---|
task | Required. "egocentric-actions" |
version | Optional. A task version from GET /schema. Defaults to "latest". The version used is pinned on the batch as processing_version. |
inputs | Required. 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[].url | A 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[].prefix | A 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_usd | Optional. Auto-confirm quotes up to this amount. Omit it to confirm every non-zero quote yourself. |
output | Optional. { "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_url | Optional. A public https URL for webhooks. Contact us for a signing secret first. |
metadata | Optional. 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:
- Each succeeded item is written to
<prefix><batch_id>/<item_id>.json. - When the batch ends as
completed,completed_with_errorsorfailed,<prefix><batch_id>/_COMPLETE.jsonis written. It carriesbatch_idand is written only after every succeeded item's file is in place, so its presence means the set is complete. - It is not written for a
cancelledbatch, or for a batch that fails duringpreflight(for exampletoo_many_items), since no item file was written. - It is rewritten if an approval changes the outcome. Its
generationfield increases with every new outcome; the file with the highestgenerationis current. Writes use conditional requests (If-None-Match/If-Match) so an older outcome never replaces a newer one. On storage that does not support conditional writes, a late older write is detected and corrected within about 10 minutes. _COMPLETE.jsonis a convenience marker. The API (GET /batches/{id}) and webhooks are the source of truth for a batch's status and items.
Idempotency.
- Repeating a request with the same
Idempotency-Keyreturns the original batch with the headerIdempotent-Replayed: true. No second batch is created. - The request comparison ignores
metadata. A retry that differs only inmetadatareplays the original batch, and the new metadata is not applied. - Reusing a key with any other field changed returns
409 idempotency_conflict.
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": 1,
"quoted_at": "2026-11-01T18:04:39.950Z",
"expires_at": "2026-11-02T18:04:39.950Z"
},
"max_amount_usd": "250",
"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"
}
}processing_versionabove is a placeholder. The actual value is the task version pinned on your batch; see the available versions inGET /schema.quoteisnulluntil preflight finishes.amounts.reserved_usdis the part of the quote still held on your balance.amounts.settled_usdis what has been charged so far.
List batches
GET /batches?limit=20&cursor=...
limitis 1–100 (default 20).- Returns
{ "object": "list", "data": [batch...], "next_cursor": "..." | null }. - Pass
next_cursorback ascursorto get the next page.
Confirm or resume
POST /batches/{id}/confirm, no body. Returns the batch object.
- In
awaiting_confirmation: reserves the quoted amount and starts processing. Returns409 quote_expiredif the quote expired; a new quote follows automatically. - In
blockedforinsufficient_fundsorover_limit: retries the reservation after you add funds. - During
preflight: returns409 not_ready.
Cancel
POST /batches/{id}/cancel, no body. Returns the batch object.
- Queued items are cancelled and not charged.
- Items already running finish and are charged normally.
- The batch becomes
cancelledonce every item has stopped.
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 field | Description |
|---|---|
duration_s | Duration of the video stream. This is what we label and quote. |
container_duration_s | Duration of the whole file, which can be longer when audio runs past the video. |
covered_s | Video time labeled in the result. |
billable_minutes | Covered time, capped at the video duration and rounded up to whole minutes. |
billing | charged, 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"
}urlis a download link valid for 15 minutes. Call this endpoint again for a fresh link.- Check the downloaded bytes against
sha256. 409 result_unavailable: the item has no result.409 result_expired: the item is past its 30-day retention.
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 presigned URL inputs and hosted results.
The credentials you give us need:
| Scope | Permissions |
|---|---|
| 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, optional | s3: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
| Status | Meaning |
|---|---|
preflight | Fetching and inspecting inputs, computing the quote |
awaiting_confirmation | Quote ready; waiting for confirm |
running | Items are being processed |
blocked | Paused; see blocked_reason |
completed | Every item succeeded |
completed_with_errors | Some items succeeded and some did not |
failed | No item succeeded; see failure_reason |
cancelled | Cancelled 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
| Reason | What to do |
|---|---|
insufficient_funds | Add funds, then call confirm. Blocked batches also resume on their own once the balance covers them. |
over_limit | The batch exceeds your credit limit. Add funds, or contact us. |
credentials_invalid | The registered bucket's credentials no longer work. Send us new credentials; the batch resumes. |
payment_reversed | Contact 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.
Item reason
None of these is charged.
| Reason | Meaning |
|---|---|
too_large | File over 10 GB |
too_long | Over 70 minutes, measured as the longer of the video stream and the file |
unsupported_container · unsupported_codec · no_video_stream | The file is not a supported video |
bad_timestamps | Broken or implausible timestamps (for example, the video starts far into the file). Re-mux and resubmit. |
unreadable · fetch_failed · input_expired | We could not read the input, or its URL expired before processing |
input_mismatch | The input changed while we were processing it |
decode_error · pipeline_error · timeout · processing_error | Processing did not complete |
invalid_result | The result failed our verification and was discarded |
qa_failed | The result did not pass quality checks; it is in needs_review |
exceeds_quote | The result would have cost more than the confirmed quote; it was not charged |
verification_unavailable · preflight_unavailable | Temporary; processing was not completed in time |
batch_rejected · cancelled | The 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
| Type | When |
|---|---|
batch.awaiting_confirmation | The quote is ready and the batch is waiting for your confirm. It is not sent when the batch is confirmed automatically. |
batch.blocked | The batch was paused (reason gives the blocked_reason) |
batch.completed | The batch finished with status completed or completed_with_errors. It is sent again, with previous_status, if an approval changes the outcome. |
batch.failed | The batch finished with no succeeded item |
batch.cancelled | The batch finished after a cancel |
item.completed | An 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"
}sequencenumbers the events of one batch 1, 2, 3, … with no gaps. Events of one batch are delivered in that order: event n+1 is not sent until event n was accepted or given up on.- An event that keeps failing holds back later events of its batch for at most 5 attempts (about 10 minutes). After that, later events are delivered while it keeps retrying, so order is no longer guaranteed. Always order by
sequenceon your side. Events of different batches are independent. - Delivery is at least once. Deduplicate on
event_id, which is also in theX-Lumen-Event-Idheader. - Respond with any
2xxwithin 10 seconds. 408,429,5xx, connection errors and timeouts are retried with exponential backoff for up to 24 hours. Any other4xxis final: the event is not retried.- A
webhook_urlthat resolves to a private or reserved address is never delivered to. The event is given up on after 3 such attempts in a row, about 5 minutes apart. - Webhooks are notifications. Read the current state from the API before acting on money or results.
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 "", 204Result 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:
| Field | Description |
|---|---|
input | sha256 of your file, duration_s (the video stream), video_start_s. |
covered_seconds | Video time labeled. |
activity_segments | A gap-free timeline of activity segments, each with start_s, end_s, action, object, hands, description. |
hands.left · hands.right | Gap-free per-hand timelines, each segment with start_s, end_s, action, grasp, object. |
qa | The quality checks the result passed, and metrics.labeled_share. |
- Times are seconds on the file's player timeline.
- All timelines start at 0 and end together.
- Spans that could not be labeled have
action: "unlabeled". - If the video stream starts after the file does (for example, audio starts first), every timeline begins with one
unlabeledsegment[0, video_start_s)described as"no video". That segment is not counted incovered_seconds.
Limits
| Limit | Value |
|---|---|
| Videos per batch | 2000 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 batch | 200 |
| File size per video | 10 GB (10,000,000,000 bytes) |
| Video length per video | 70 minutes |
| Request body | 4 MB |
| metadata | 32 keys, 8 KB |
| Open batches per account | 20 (more returns 429 over_limit) |
| Request rate | 300 requests per minute per account |
| Quote validity | 24 hours |
| Hosted result retention | 30 days |
| Result download link | 15 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.
| Code | Status | Meaning |
|---|---|---|
unauthenticated | 401 | Missing or invalid API key |
missing_scope | 403 | The key does not have Lumen enabled; create a new key with Lumen enabled |
not_found | 404 | No such batch, item or source for this account |
idempotency_conflict | 409 | The Idempotency-Key was used for a different request |
not_ready | 409 | Preflight is still running |
quote_expired | 409 | The quote expired; a new one follows |
quote_changed | 409 | The quote changed while you confirmed; read the batch and confirm again |
blocked | 409 | The batch is blocked for a reason only we can clear |
invalid_state | 409 | The batch or item is not in a state that allows this |
result_unavailable | 409 | The item has no result |
result_expired | 409 | The hosted result is past retention |
timeout | 408 | The request body was not received in time; retry |
too_large | 413 | The request body is too large |
invalid_request | 422 | The request is malformed; message says why |
rate_limited | 429 | More than 300 requests in a minute |
over_limit | 429 | Too many open batches |
unavailable | 503 | Temporary; 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. Before any processing, every batch gets a quote, and the quoted amount is held on your balance when the batch is confirmed. You are never charged more than the confirmed quote. Only succeeded items are charged, on the video time covered, rounded up to whole minutes with a minimum of one minute per item. 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