Emotions API
Facial REST endpoint. Up to 478 MediaPipe landmarks, 5 VEXKIO states.
- Endpoint
- POST /emotions
- Auth
- Bearer api_key
- Price
- $0.05 per session
Request
POST a JSON object. The fields below are the ones the handler reads; anything else in the body is ignored. Every rule in the table was transcribed from supabase/functions/emotions/index.ts.
| Field | Type | Presence | Behaviour in the handler |
|---|---|---|---|
| session_id | string | optional | Any opaque string you control. Omit it and the endpoint generates a UUID and returns it to you in the response. |
| image_b64 | string | one of these | Base64 of one frame, no data: prefix. This is the ONLY input that can reach the IA-01 model, and only on a deployment where INFERENCE_URL is configured. Capped at 15,000,000 characters (413 above it). |
| landmarks | number[][] | one of these | MediaPipe FaceMesh output as [x,y,...] tuples of finite numbers. Hard cap 478 points (400 above it). The geometric classifier needs at least 468: fewer is accepted by validation but answers insufficient_signal. |
Smallest body that clears validation. The shape is real; the values in angle brackets are placeholders you replace.
curl https://krrrxshxncvcsumugxbi.functions.supabase.co/emotions \
-X POST \
-H "Authorization: Bearer $VEXKIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"session_id": "demo-0001",
"image_b64": "<base64 of one JPEG or PNG frame, no data: prefix>"
}'Headers
| Header | Required | Description |
|---|---|---|
| Authorization | yes | Bearer <api_key>. The key must carry the emotions scope; a key without it is rejected as 401, not 403. |
| Content-Type | yes | application/json |
No other request header changes behaviour. This page used to document an X-Idempotency-Key; no endpoint reads it, so it has been removed rather than left as a no-op promise.
Response
200 returns the object below. There is no envelope and no wrapper: the fields are top-level.
| Field | Type | Presence | Behaviour in the handler |
|---|---|---|---|
| state | string | required | One of neutral | apertura | friccion | tension | desconexion, or the literal insufficient_signal when nothing could be scored. |
| confidence | number | required | Probability the classifier assigned to `state` IN THIS REQUEST, rounded to 3 decimals. It is not model accuracy, not a benchmark, and not a quality guarantee. Read `inference` before you trust it. Forced to exactly 0 on the two no-result paths. |
| probabilities | object | required | One entry per VEXKIO state. All five are 0 on the no-result paths — deliberately not a normalised distribution, because there is nothing to distribute. |
| inference | string | required | Provenance of the score. real = the IA-01 origin answered. heuristic = the geometric classifier over your landmarks, no model ran. unavailable = you sent pixels, the origin did not answer, and there was no landmark fallback. insufficient_signal = no usable input for any backend. |
| session_id | string | required | Echoed back, or the generated UUID. |
| latency_ms | number | required | Wall time measured inside the function. Excludes network. |
| model_version | string | required | heuristic-v1 on the geometric path; unavailable / insufficient-signal on the no-result paths; the origin's own tag when a model actually ran. |
| timestamp | string | required | ISO-8601, generated at response time. |
| reason | string | optional | Present ONLY when inference is unavailable or insufficient_signal. Explains in prose why nothing was measured. |
| llm_enrichment | object | null | required | null unless the operator set VEXKIO_LLM_ENRICH=true on the deployment. Default across the fleet is off, so expect null. |
Example values, not a measurement
The field names, types and nesting below are transcribed from the deployed handler. The numbers and strings are placeholders chosen to illustrate the shape. They are not a recorded call, not a benchmark, and not a claim about how any VEXKIO model performs.
In particular confidence is the probability the classifier assigned within a single request. It is not accuracy. VEXKIO publishes no accuracy figure on this site.
{
"state": "apertura",
"confidence": 0.41,
"probabilities": {
"neutral": 0.19,
"apertura": 0.41,
"friccion": 0.15,
"tension": 0.14,
"desconexion": 0.11
},
"inference": "heuristic",
"session_id": "demo-0001",
"latency_ms": 38,
"model_version": "heuristic-v1",
"timestamp": "2026-01-01T00:00:00.000Z",
"llm_enrichment": null
}Before you trust a number
- A 200 does not mean a model ran. Branch on `inference`: only "real" means IA-01 produced the score.
- When nothing could be scored you still get 200 — with state "insufficient_signal", confidence exactly 0, an all-zero probability map and a `reason` string. Treat that as no reading, never as a neutral reading.
- Sending fewer than 468 landmarks is the most common cause of a zero-confidence answer. Sending more than 478 is a 400.
Errors
Almost every error body is a JSON object with a single human-readable error string. There is no stable machine-readable code field on these endpoints - branch on the HTTP status, not on the string.
{ "error": "Provide image_b64 (string) or landmarks (array)" }The second tab is the one body on these endpoints that does NOT carry an error key. A client that reads only body.error will log undefined for a cost-cap 429, so read the status first. Note the field is retry_after_seconds - seconds, not milliseconds - it appears on this body only, and its value is however many seconds remain until 00:00 UTC, so the number above is just an illustration.
| HTTP | When |
|---|---|
| 400 | Body is not a JSON object, or a field failed validation. The error string names the field. |
| 401 | Missing Bearer header, unknown key, revoked key, or a key that lacks this endpoint's scope. All four collapse into the same 401 - a missing scope is NOT a 403 here. |
| 403 | Organization is suspended. Some non-core endpoints also use 403 for missing consent or attestation flags. |
| 413 | Body over 12,000,000 bytes, or a base64 field over its own cap (15,000,000 chars for image_b64, 40,000,000 for audio_b64). |
| 429 | Per-IP throttle, per-org daily limit, monthly session quota exhausted, or a cost cap. See Rate limits below. |
Codes this page used to list - invalid_payload, scope_missing, session_not_found, model_unavailable - are not emitted by any handler and have been removed.
Rate limits
Three independent limits can produce a 429. All of them are read from supabase/functions/_shared/rate_limit.ts and the handler itself.
| Limit | Value | Scope |
|---|---|---|
| Pre-auth throttle | 60 / minute | Per client IP, in-memory, checked before the key is even validated. |
| Daily request limit | 10,000 / day (default) | Per organization. Resolved from your daily_quota, else your sessions_limit, else the deployment default. |
| Session quota | per plan | Cumulative sessions used against your plan ceiling. Exhausting it returns 429 until the plan is upgraded. |
Two corrections to what this page used to claim. There is no Starter / Pro / Business / Enterprise rps table in force - the tiered limiter exists in the repo but is not imported by any deployed function. And no endpoint emits X-RateLimit-Limit, X-RateLimit-Remaining or X-RateLimit-Reset headers, so do not build a budget on reading them. Retry with your own bounded exponential backoff.