API
Analysing a broadcast from your own code
Upload the file to the bucket, call one endpoint with the path, and KAIROS runs the same analysis the app runs. Your workspace, your plan, your allowance — an API key does nothing a signed-in person could not do.
The whole thing, end to end
Five calls. Uploading and analysing are separate on purpose: you can push a batch of files overnight and decide later which of them to analyse.
# 1. Ask for somewhere to put the file.
curl -sX POST https://kairosapp.tech/api/uploads \
-H "Authorization: Bearer $KAIROS_KEY" \
-H 'content-type: application/json' \
-d '{"filename":"match.mp4","sizeBytes":734003200}'
# -> {"path":"tenants/…/inbox/Ab3…/match.mp4","sessionUri":"https://storage.googleapis.com/…"}
# 2. Send the bytes. One PUT if it is small; see "Sending the bytes" for chunks.
curl -sX PUT "$SESSION_URI" --upload-file match.mp4
# 3. Start the analysis, naming the path from step 1.
curl -sX POST https://kairosapp.tech/api/analyses \
-H "Authorization: Bearer $KAIROS_KEY" \
-H 'content-type: application/json' \
-d '{"path":"tenants/…/inbox/Ab3…/match.mp4","title":"France v Spain","domain":"sport"}'
# -> {"assetId":"e5f1…","status":"uploaded"}
# 4. Wait for it.
curl -s https://kairosapp.tech/api/assets/e5f1…/progress -H "Authorization: Bearer $KAIROS_KEY"
# -> {"phase":"analysing","heartbeatAgeSeconds":4,…} then eventually {"phase":"done",…}
# 5. Read it.
curl -s https://kairosapp.tech/api/assets/e5f1…/timeline -H "Authorization: Bearer $KAIROS_KEY"Authentication
Every call carries Authorization: Bearer kai_sk_…. Create a key under Settings; it is shown once and cannot be shown again, so put it straight into wherever you keep secrets. Revoking is on the same screen, and takes effect on the next call.
| Permission | What it allows |
|---|---|
kairos:read | Read broadcasts, their progress and their finished analysis. |
kairos:write | Upload, start an analysis, cancel one, delete a broadcast. Spends the workspace’s allowance. |
kairos:search | Search by meaning. Not used by the endpoints below; it belongs to the connector. |
A key belongs to the deployment it was made on. A key created on a preview URL is refused on the live site, and the other way round. If a key that worked yesterday answers 401, check which host you made it on before you check anything else.
Sending the bytes
POST /api/uploads gives you a sessionUri. The bytes go to Google Cloud Storage directly — not through KAIROS — so a broadcast-sized file is not passing through anybody’s API. Small files can go in one PUT. Anything large should be chunked, so a dropped connection costs one chunk rather than the whole upload.
PUT <sessionUri>
Content-Range: bytes 0-16777215/734003200 # 16 MiB, a multiple of 256 KiB
308 Resume Incomplete
Range: bytes=0-16777215 # what GCS ACTUALLY kept — resume from here
PUT <sessionUri>
Content-Range: bytes 16777216-33554431/734003200
...
200 OK # the last chunk answers 200 or 201On a 308, trust the Range header rather than your own byte count. It says how much was actually kept, which is not always how much you sent. Resuming from your own offset leaves a hole, and the upload then succeeds with a corrupt file — which is a far worse day than a failed upload.
Chunk sizes must be a multiple of 256 KiB, except the last one. 16 MiB is a good default.
Lost track after a crash? Ask, do not guess:
PUT <sessionUri>
Content-Range: bytes */734003200 # no body: "how much do you have?"
308 Resume Incomplete
Range: bytes=0-50331647 # continue from 50331648A session lasts a week. After that, ask for a new one — the old path is dead and nothing was created, so there is nothing to clean up.
The endpoints
Ask for somewhere to put a broadcast. Creates nothing and analyses nothing — it hands back a path and a resumable upload session.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
filename | string | yes | Reduced to a safe basename. Only the name is used, never a path. |
sizeBytes | number | yes | The exact size. Checked against your plan now — including anything you have already uploaded and not yet analysed — and against the object itself when you start the analysis. |
Answers
201 { path, sessionUri, expiresAt }. Keep `path` — it is what you name to start the analysis.
| Status | error | When |
|---|---|---|
| 400 | — | filename is missing, or sizeBytes is not a positive number. |
| 413 | file-too-large | The file is larger than a single file may be on your plan. |
| 409 | period-bytes-exhausted | It would not fit in what your plan allows this month. Uploads you have not analysed count toward that, so clean them up or analyse them. |
| 503 | — | Uploads are unavailable because the deployment has no cloud credentials. |
Send the bytes, straight to Google Cloud Storage. This one does not go to KAIROS at all, and takes no KAIROS key — the session URI is the credential.
Answers
200 or 201 when the last chunk lands. 308 after every earlier chunk, carrying a `Range` header.
| Status | error | When |
|---|---|---|
| 308 | — | Normal. More bytes wanted — read the `Range` header for how much was actually kept. |
| 404 | — | The session has expired or was cancelled. Ask for a new one and start again. |
Analyse an object you have uploaded. This is what creates the broadcast and queues the work.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
path | string | yes | The `path` from POST /api/uploads, exactly as it was given. |
title | string | yes | What you will see it called. Trimmed at 300 characters. |
domain | 'sport' | 'news' | 'auto' | yes | `auto` lets the analyser classify it. **This cannot be changed afterwards** — the pipeline it selects is the one that runs. |
Answers
201 { assetId, status, sizeBytes, container }. Keep `assetId`: progress and the timeline are both under it.
| Status | error | When |
|---|---|---|
| 400 | — | The path is not one this workspace can analyse, or the title is missing. |
| 400 | domain-required | No domain was given. It is required, and there is no default. |
| 400 | domain-not-in-plan | Your plan does not carry the sport pipeline. `auto` needs it too, because the answer may be sport. |
| 400 | mime-not-allowed | The container is not a video type. In practice the 415 below fires first. |
| 409 | — | There is no object at that path, or that object has already been analysed. |
| 413 | file-too-large | The object is larger than a single file may be on your plan. |
| 409 | period-bytes-exhausted | Your plan’s bytes for this month are spent. |
| 409 | lifetime-minutes-exhausted | Your plan’s lifetime minutes are spent. |
| 409 | monthly-minutes-exhausted | Your plan’s minutes for this month are spent. |
| 415 | — | The first bytes are not a video container. The object is deleted, and nothing is created or charged. |
Where one analysis has got to, and whether the machine running it is still alive. Poll this.
Answers
200 { phase, generation, heartbeatAgeSeconds, cancelRequested, startedAt, uploadedBytes }. See the phases below.
| Status | error | When |
|---|---|---|
| 404 | — | No such broadcast in your workspace. Not yours and not existing are one answer. |
The same answer for every analysis still in flight, in one call. Use it instead of polling per broadcast when you have several running.
Answers
200 { "<assetId>": { phase, heartbeatAgeSeconds, cancelRequested } , … }, up to 50.
The finished analysis: segments with their start, end, kind and description, their key moments, and the highlights found.
Query
| Parameter | Type | Notes |
|---|---|---|
offset | number | First segment to return. Default 0. |
limit | number | How many. Default 50, capped at 200. |
Answers
200 { id, title, domain, durationSeconds, total, returned, offset, segments, keyMoments }.
| Status | error | When |
|---|---|---|
| 404 | — | No such broadcast in your workspace. |
| 409 | — | It has not been analysed yet. Poll progress until the phase is `done`. |
Ask for a running analysis to stop. Intent, not an instant stop — the machine notices on its next check-in, up to 30 seconds later.
Answers
200 { ok, cancelRequested }.
| Status | error | When |
|---|---|---|
| 404 | — | No such broadcast, or it has no analysis to cancel. |
| 409 | — | That analysis has already finished. |
Delete a broadcast and its bytes. A broadcast that was analysed keeps counting toward your lifetime minutes — deleting is not a refund.
Answers
200 { ok }.
| Status | error | When |
|---|---|---|
| 404 | — | No such broadcast in your workspace. |
Is KAIROS up and can it still reach its database. No credential needed, and it says nothing about your workspace.
Answers
200 { ok: true, database: "up", checkedAt }, or 503 with database: "down".
| Status | error | When |
|---|---|---|
| 503 | — | KAIROS is up but cannot reach its database. Nothing will progress until this clears. |
Branch on the code in error, not on the sentence beside it. The codes above are the contract and will not change under you. The wording is written for people and does change.
Following a run
progress returns a phase. Poll it about every 10 seconds — that is what the app’s own screen does — and stop when the phase is one of the terminal ones. heartbeatAgeSeconds is how long since the machine doing the work last checked in; it is only set while something is actually running, and a number climbing past a minute or two is the signal that a run is in trouble rather than merely slow.
| phase | Stop? | Meaning |
|---|---|---|
uploading | no | The bytes are still arriving. Nothing is queued yet, and that is correct. |
not-dispatched | yes | Nothing was ever queued for this broadcast. An upload that never finished — start again. |
queued | no | Waiting for a machine. The queue is swept once a minute, so this can sit for that long. |
starting | no | A machine is being brought up. It has not picked the job up yet. |
analysing | no | Running. The only phase with a heartbeat: `heartbeatAgeSeconds` is how long since the machine last checked in. |
retrying | no | The machine stopped answering or was refused, and another is being tried. `generation` counts the attempts. |
loading | no | The run finished and its results are being read in. Nearly done. |
done | yes | Finished. The timeline is readable. |
failed | yes | It will not finish. Nothing to wait for. |
cancelled | yes | Stopped because it was asked to stop. |
There is no percentage and no estimate, because there is no honest one to give: the analyser is a single opaque run that reports nothing from inside itself, and the measured read rate varies by nearly three times across broadcasts. The phases are what is actually known.
Being called back
Rather than polling to the end, register a callback under Settings and KAIROS will POST to it when an analysis settles. One callback per workspace, https only. You are given a signing secret once, at that moment.
POST https://your-service.example.com/kairos
X-Kairos-Timestamp: 1786291200
X-Kairos-Signature: sha256=3f1c…
{
"event": "analysis.settled",
"assetId": "e5f1…",
"status": "complete",
"domain": "sport",
"durationSeconds": 5412.32,
"timelineUrl": "https://kairosapp.tech/api/assets/e5f1…/timeline",
"occurredAt": "2026-08-20T14:31:07.221Z"
}It carries a link to the timeline rather than the timeline itself — a two-hour broadcast is hundreds of segments, and you read them with your own key, so nothing decides on your behalf what you may see.
Verifying it
The signature is an HMAC-SHA256 over <timestamp>.<raw body>. Check the timestamp before you trust the digest: that is what makes a captured delivery useless to replay later.
import { createHmac, timingSafeEqual } from 'node:crypto';
// The RAW body. Parsing it to JSON and re-serialising changes bytes, and the signature is over bytes.
export function verify(rawBody, headers, secret) {
const timestamp = headers['x-kairos-timestamp'];
const signature = (headers['x-kairos-signature'] || '').replace(/^sha256=/, '');
if (!timestamp || !signature) return false;
// Refuse an old delivery even when its signature is perfect — that is what stops a replay.
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(age) || age > 300) return false;
const expected = Buffer.from(createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex'));
const offered = Buffer.from(signature);
// Constant time, and the length check first: timingSafeEqual throws on a mismatch rather than failing.
return expected.length === offered.length && timingSafeEqual(expected, offered);
}Treat the callback as a nudge, not as the record. It is attempted once. If your service is down, or answers slowly, or the connection drops, that delivery is gone — there is no retry queue. So a correct integration still reads progress, whether on a timer or when the callback arrives.
It can also arrive twice for one analysis. Treat it as “this broadcast has settled” rather than as an event in a sequence, and a duplicate costs you nothing.
Limits
- Your plan’s caps apply exactly as they do in the app: file size, bytes per month, minutes, and whether the sport pipeline is available at all. A refusal names the reason code, and the codes are in the tables above.
domaincannot be changed after the analysis starts. It selects the pipeline, and the run is paid for either way.- One analysis per uploaded object. Naming the same path twice answers
409rather than analysing it again — upload the file again if you genuinely want a second run. - Deleting a broadcast does not give its minutes back. Uploading, analysing and deleting in a loop is not a way around an allowance.
- An object you upload and never analyse stays in the bucket, and counts toward your bytes for the month until you analyse it or it is removed. Nothing removes it for you, so a script that uploads more than it analyses is eventually refused.
- A callback whose address stops resolving publicly is refused at delivery time rather than silently dropped, and the attempt is recorded either way. Repointing the hostname at an internal address after registering it does not work.