API docs
One resource, the session: one video of one person, up to an hour. You create it, we measure it, and you read its outputs. Paths below start at the API’s own address, given when it opens.
Quickstart
1 · Get a key. Keys come with Pro: create one in your Pro account. It is shown once, with
its webhook signing secret. Send it as Authorization: Bearer pk_live_….
2 · Create a session.
POST /v1/sessions
Authorization: Bearer pk_live_…
Content-Type: application/json
{
"source": { "url": "https://example.com/videos/talk.mp4" },
"outputs": { "signals": true, "reports": ["99"] },
"release": "first@1",
"consent": { "token": "consent-record-8812" },
"webhook": { "url": "https://example.com/hooks/99traits" }
} 3 · Wait for the webhook. Measuring takes from a few minutes to
about an hour. We post session.ready to your webhook, or you poll, following
Retry-After.
4 · Read the outputs.
GET /v1/sessions/{id}
Authorization: Bearer pk_live_… Videos we accept
We check every video before we measure it. One that breaks a rule comes back rejected, and is not billed. Its reject.codes name the
rules it broke, and reject.detail says what to send instead.
- Ten minutes to an hour long.
- More minutes measure better, up to the hour. Code:
video_length - One person.
- Alone in picture and sound from start to end: no interviewer, co-host or split screen. Code:
one_person - Facing the camera.
- Their face is in view nearly all the time, and they talk to the camera. Code:
facing_camera - One take.
- From one camera angle, with few cuts and long stretches between them. Code:
one_take - Real video.
- Moving video at its full frame rate: not a photo, a slideshow or a still face over sound. Code:
real_video - Speech, with no music.
- They talk most of the time, with clear sound and no music under it. Code:
speech - In English.
- The API opens for English only. Code:
language - A file we can open.
- A video file with its sound track, at 4K or less. Code:
file
Endpoints
- POST /v1/sessions
- Create a session: the video by link or by chunks, the outputs, the consent reference. Returns 202 and the session. To retry safely, send an Idempotency-Key header: the same key and body within a day returns that session.
- GET /v1/sessions/{id}
- The session’s status, and its outputs once ready. A Retry-After header comes with every status that is not final.
- DELETE /v1/sessions/{id}
- Remove the outputs now, and the video if we still hold it. Returns 204.
- PUT /v1/sessions/{id}/chunks/{seq}
- One chunk of the file (for a session created with source.upload), numbered from 0, with X-Chunk-SHA256. Up to 16 MiB.
- POST /v1/sessions/{id}/finalize
- Close the upload with the number of chunks sent. A 409 names any chunk still missing.
The request body of POST /v1/sessions
- source
- Required. { "url": "…" }, a direct https link to the video file, which we fetch; or { "upload": true }, to send it in chunks. Write the link in plain ASCII, percent-encoding the rest. The file can be up to 4 GiB. A fetch can take up to an hour, and the file must keep coming at 256 KiB a second or more. A link we cannot reach, a fetch slower than that, or a file that is not a video comes back rejected.
- consent
- Required. { "token": "…" }, a reference to your own record of this person’s consent.
- outputs.signals
- Places and bands. On unless set to false.
- outputs.reports
- The reports to return as block trees, by ID: 99 (the person’s profile with its chapters closed) and chapters (with every chapter open). None unless you ask.
- release
- The release to pin. There is one for now, first@1, and it is the default. Any other name is refused.
- webhook.url
- An https address where we send the session’s events.
Sending the file in chunks
# 1. create the session with "source": { "upload": true }
# (its upload block gives chunk_url and chunk_bytes)
# 2. send the file in order, a chunk at a time, with its SHA-256
PUT /v1/sessions/{id}/chunks/0
Authorization: Bearer pk_live_…
X-Chunk-SHA256: <the chunk's SHA-256, in hex>
Content-Type: application/octet-stream
<the chunk's bytes>
# 3. finalize with the number of chunks sent
POST /v1/sessions/{id}/finalize
Authorization: Bearer pk_live_…
Content-Type: application/json
{ "chunks": 12 } A chunk that arrives intact is answered with its sequence number, size and hash; send it again on any error. A session is billed as one Pro session once its video passes the checks. If it then fails on our side, it is not.
Statuses
- awaiting_media
- Created with source.upload; waiting for the chunks and finalize.
- gating
- Checking that the video can be measured.
- rejected
- It cannot: reject.codes and reject.detail say why and what to send instead. Not billed.
- processing
- Being measured.
- ready
- The outputs are in the response, until expires_at.
- failed
- Measuring failed on our side, or the account’s Pro plan ended before the video arrived. Not billed.
- expired
- The outputs are no longer served.
Outputs
When the status is ready, the session carries outputs and its consent block
(mode, your token as given, and when it was represented).
- signals
- An object keyed by each trait’s public key (made from its plain name), each { "place", "band" }. A trait in the middle, or one the video does not make sure, is not in it.
- reports
- An object keyed by report ID, each a block tree: its language and blocks in order, as our site renders them. Every trait in a tree carries its own two ends, low and high.
- manifest
- The release that served the outputs and one version of everything that made them.
Places and bands
A place runs from 0 to 99 among people: 91 is further toward the high end than about nine in ten people. A band names the outer tenths:
- hi1
- In the top tenth: further toward the high end than 9 in 10 people.
- hi2
- In the second tenth from the top: further than 8 in 10 people.
- lo1
- In the bottom tenth: further toward the low end than 9 in 10 people.
- lo2
- In the second tenth from the bottom.
Show a place as a position, not as a score: neither end of a trait is better. Outputs never carry raw values, timestamps or excerpts of the video.
Versions
The manifest names the release that made the outputs, by its public name, and one version of everything that made them. Outputs are made once: they do not change while they are served.
How long outputs stay
The video is deleted once the outputs are ready, and when a session is rejected or fails. The outputs are
served until expires_at, 30 days later, or until you delete the session. After that
the session reads expired.
Signals
Each signal runs between two named ends: a place near 0 sits toward its low end, near 99 toward its high end. Read a key by its ends, not by its name alone.
The lists below hold the signals of our example, a made-up person, with their two ends. The full list of keys comes with the API.
Timing and rhythm (1)
answer- speed - Low: takes time to answer
- High: answers fast
Words (1)
answer- first - Low: gives the reason before the answer
- High: gives the answer before the reason
Body and energy (1)
hand- talk - Low: keeps the hands still
- High: talks with the hands
Webhooks
We post to the session’s webhook URL on session.ready, session.rejected and session.failed. The body is { "id", "event", "at" }: fetch the session for its outputs. Answer with any 2xx;
we retry with backoff for 24 hours. Your webhook URL must be https, and we do not follow its redirects.
Each post is signed with your key’s signing secret: X-99traits-Signature: t=<unix seconds>,v1=<hex>, where v1 is the
HMAC-SHA256 of the timestamp, a dot and the raw body.
import hmac, hashlib, time
def verify(body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
signed = f"{parts['t']}.".encode() + body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
fresh = abs(time.time() - int(parts["t"])) < 300
return fresh and hmac.compare_digest(expected, parts["v1"]) Errors
Errors are application/problem+json with a stable code and a plain detail. 401: the key is missing or revoked. 402: the account has no
active Pro plan. 404: no such session. 409: the upload is incomplete or closed, an Idempotency-Key came with
another body, or the account has not accepted the current terms, which you can do on your Pro page. 413: the
request body is too large. 422: the request is not valid. 429: too many sessions
today. 503: we have reached today’s limit. 507: we cannot take new files right now.