# Primate Vision API — full documentation corpus > Generated from https://primateintelligence.ai/docs. Spec: https://api.primateintelligence.ai/v1/openapi.json --- # Quickstart # Quickstart Primate Vision answers questions about video. Upload a video (or point us at a URL), ask a question in plain English, and get a deterministic answer with confidence and clip timestamps — no hallucinations. This page gets you from zero to a verified result in about a minute, **without creating an account**. ## 1. Get a key One command. No email, no card: ```bash doc-test id=quickstart-sandbox curl -s -X POST https://api.primateintelligence.ai/v1/sandbox ``` The response contains your `pv_test_` key plus a ready-to-analyze fixture video: ```json { "object": "sandbox_key", "api_key": "pv_test_…", "mode": "test", "expires_at": "2026-07-16T00:00:00Z", "fixture_video_id": "video_…", "fixture_prompt": "Is there a person in this video?", "expected_answer": "yes" } ``` Export it: ```bash export PRIMATE_API_KEY="pv_test_…" # from the response above ``` Test keys return **deterministic canned results** for the fixture corpus — perfect for CI. They can't spend credits or touch the GPU, and expire in 7 days. Graduating to a `pv_live_` key requires [signup](https://primateintelligence.ai/signup) (a billing gate, not an integration gate — your code doesn't change). ## 2. Create an analysis Your sandbox account comes pre-seeded with a fixture video. Ask a question about it — `Prefer: wait` holds the connection until the result is ready (up to 120s), collapsing the poll loop: ```bash doc-test id=quickstart-analyze env=VIDEO_ID curl -s -X POST https://api.primateintelligence.ai/v1/analyses \ -H "Authorization: Bearer $PRIMATE_API_KEY" \ -H "Content-Type: application/json" \ -H "Prefer: wait=60" \ -d '{"video_id": "'"$VIDEO_ID"'", "prompt": "Is there a person in this video?"}' ``` ## 3. Read the result The response is the full analysis resource: ```json { "id": "an_…", "object": "analysis", "status": "completed", "result": { "answer": "yes", "confidence": 0.93, "clips": [{ "start_s": 1, "end_s": 4, "confidence": 0.93 }], "query_type": "object" } } ``` That's the whole loop: **video → question → answer**. `result.answer` is always `yes`, `no`, or `indeterminate`; `result.clips` tells you *where* in the video. ## The same thing in TypeScript ```typescript doc-test id=quickstart-ts sdk=ts import Primate from '@primate-intelligence/sdk'; const client = new Primate(); // reads PRIMATE_API_KEY const videos = await client.videos.list(); // fixture video is pre-seeded const analysis = await client.analyses.createAndWait({ video_id: videos.data[0].id, prompt: 'Is there a person in this video?', }); console.log(analysis.result?.answer, analysis.result?.confidence); ``` ## The same thing in Python ```python doc-test id=quickstart-py sdk=py from primate_intelligence import Primate client = Primate() # reads PRIMATE_API_KEY videos = client.videos.list() # fixture video is pre-seeded analysis = client.analyses.create_and_wait( video_id=videos["data"][0]["id"], prompt="Is there a person in this video?", ) print(analysis["result"]["answer"], analysis["result"]["confidence"]) ``` ## Next steps - **[Upload your own video](/docs/guides/uploading)** — presigned upload, URL ingest, or the SDK helper - **[Write better prompts](/docs/guides/prompts)** — what's answerable, structured queries, `unassessable_components` - **[Stop polling](/docs/guides/waiting)** — `Prefer: wait` vs webhooks - **[Building with an AI agent?](/docs/agents)** — the condensed machine-readable path - **[API reference](/docs/reference)** — every endpoint, generated from the OpenAPI spec --- # Quickstart for AI agents # Quickstart for AI agents This page is the condensed integration contract for coding agents. Everything here is machine-verifiable; nothing requires a human until you need live (paid) processing. ## Discovery | Artifact | URL | |---|---| | OpenAPI 3.1 spec | `https://api.primateintelligence.ai/v1/openapi.json` | | llms.txt | `https://primateintelligence.ai/llms.txt` (alias: `/.well-known/llms.txt`) | | llms-full.txt | `https://primateintelligence.ai/llms-full.txt` | | This page as markdown | `https://primateintelligence.ai/docs/agents.md` | | Error registry (machine-readable) | `GET https://api.primateintelligence.ai/v1/errors` | | MCP server | `npx @primate-intelligence/mcp` (env: `PRIMATE_API_KEY`) | Every docs page is also served as clean markdown — append `.md` to the path. ## Provision (zero-touch) ```bash curl -s -X POST https://api.primateintelligence.ai/v1/sandbox ``` Returns a `pv_test_` key instantly: no email, no card, no human. Limits: IP-rate-limited, fixture-corpus only, deterministic canned results, 7-day expiry, can never spend credits. A pre-seeded fixture video is already in the account. Live (`pv_live_`) keys require human signup + card — that is a **billing gate, not an integration gate**. Code written against a test key works unchanged with a live key. ## Credentials contract - Env var: `PRIMATE_API_KEY`. Both SDKs and the MCP server read it. - Header: `Authorization: Bearer $PRIMATE_API_KEY`. Keys never go in URLs. - Keys are shown once at creation. Store securely. - MCP tools never accept keys as arguments. ## The 3-call core loop ``` POST /v1/videos {"url": "https://…/video.mp4"} → {id: "video_…", status: "processing"} POST /v1/analyses {"video_id": "video_…", "prompt": "…"} → {id: "an_…", status: "queued"} GET /v1/analyses/{id} → {status: "completed", result: {…}} ``` Collapse the poll with `Prefer: wait=60` on the create (caps at 120s), or register a [webhook](/docs/guides/webhooks). For file uploads instead of URLs: `POST /v1/videos {filename, content_type, size_bytes}` → PUT bytes to `upload.url` → `POST /v1/videos/{id}/complete`. Details: [uploading guide](/docs/guides/uploading). ## Result contract `result.answer` ∈ `yes | no | indeterminate`. `result.confidence` ∈ [0,1]. `result.clips[]` carry `start_s`/`end_s`/`confidence`. Prompts that can't be assessed appear in `query.unassessable_components` — the API answers what it can and tells you what it couldn't. ## Error retry table Every error response is `{"error": {code, message, status, param, docs_url, request_id}}`. The full registry with retry flags is at `GET /v1/errors`. Key rows: | code | status | retry? | |---|---|---| | `rate_limit_exceeded` | 429 | yes — honor `Retry-After` | | `concurrency_limit_exceeded` | 429 | yes — wait for an active analysis | | `capacity_exhausted` | 503 | yes — honor `Retry-After` | | `inference_unavailable` | 503 | yes — backoff | | `internal_error` | 500 | yes — backoff | | `insufficient_credits` | 402 | **no** — check `GET /v1/usage`, top up or enable auto-refill | | `validation_failed` | 400 | no — fix `error.errors[]` | | `invalid_api_key` | 401 | no — check the key + env var | | `parse_failed` | 422 | yes — rephrase the prompt or send structured `query` | Idempotency: send `Idempotency-Key: ` on POST creates; replays return the stored response with `Idempotency-Replayed: true`. The SDKs do this automatically. ## Self-verification ```bash curl -s https://api.primateintelligence.ai/v1/test-fixture ``` Returns a stable video URL + prompt + expected answer (`yes`, confidence ≥ 0.8). Assert against it in CI. On test keys, fixture analyses complete in seconds with deterministic results. ```bash # assertion snippet ANSWER=$(curl -s -X POST https://api.primateintelligence.ai/v1/analyses \ -H "Authorization: Bearer $PRIMATE_API_KEY" -H "Content-Type: application/json" \ -H "Prefer: wait=60" \ -d "{\"video_id\": \"$FIXTURE_VIDEO_ID\", \"prompt\": \"Is there a person in this video?\"}" \ | python3 -c "import json,sys; print(json.load(sys.stdin)['result']['answer'])") [ "$ANSWER" = "yes" ] || { echo "integration broken"; exit 1; } ``` ## SDKs - TypeScript: `npm install @primate-intelligence/sdk` — `client.analyses.createAndWait(…)` - Python: `pip install primate-intelligence` — `client.analyses.create_and_wait(…)` Both auto-retry per the registry, auto-generate idempotency keys, and paginate with iterators. ## Rate limits Per key, sliding window: reads 600/min, writes 300/min, `analyses.create` 60/min. Headers on every response: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`; 429s add `Retry-After`. --- # Uploading video # Uploading video There are three paths to get video into Primate Vision. All of them end with a `video` resource in status `ready`, at which point you can create analyses against it. **Formats:** `video/mp4` (H.264) and `video/quicktime`. **Max size:** 2 GiB. **Retention:** source videos are deleted 30 days after upload (see [security model](/docs/guides/security-model)). ## Choosing a path | Path | Use when | |---|---| | **URL ingest** | The video is already at a public https URL (CDN, S3, YouTube-dl output) | | **Presigned upload** | You have the bytes (server file, browser file input) — bytes go straight to S3, never through our API servers | | **SDK `upload()` helper** | You use an SDK and want the presigned dance done for you | ## URL ingest One call. The API fetches the video asynchronously: ```bash doc-test id=uploading-url skip-live curl -s -X POST https://api.primateintelligence.ai/v1/videos \ -H "Authorization: Bearer $PRIMATE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/clip.mp4"}' ``` The video starts `processing` and transitions to `ready` (or `failed` with `error.code` = `url_fetch_failed` / `url_forbidden` / `video_unreadable`). Poll `GET /v1/videos/{id}` or subscribe to the `video.ready` webhook. URL rules (SSRF policy): `https` only, port 443, public hosts only, max 3 redirects, 2 GiB cap. Private/internal addresses are rejected with `url_forbidden`. ## Presigned upload Three steps — create, PUT, complete: ```bash # 1. Create — declares filename/type/size, returns a presigned S3 PUT URL curl -s -X POST https://api.primateintelligence.ai/v1/videos \ -H "Authorization: Bearer $PRIMATE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"filename": "clip.mp4", "content_type": "video/mp4", "size_bytes": 12345678}' # 2. PUT the bytes directly to S3 (the upload.url + upload.headers from step 1) curl -s -X PUT "$UPLOAD_URL" -H "Content-Type: video/mp4" --data-binary @clip.mp4 # 3. Tell the API the PUT finished curl -s -X POST https://api.primateintelligence.ai/v1/videos/$VIDEO_ID/complete \ -H "Authorization: Bearer $PRIMATE_API_KEY" ``` The presigned URL expires (see `upload.expires_at`, typically 1h). If the declared `size_bytes` doesn't match what landed in S3, `complete` fails with `upload_incomplete` — re-create the video. ## SDK helper ```typescript import Primate from '@primate-intelligence/sdk'; import { readFile } from 'fs/promises'; const client = new Primate(); const video = await client.videos.upload(await readFile('clip.mp4'), { filename: 'clip.mp4', content_type: 'video/mp4', }); ``` ```python from primate_intelligence import Primate client = Primate() with open("clip.mp4", "rb") as f: video = client.videos.upload(f.read(), filename="clip.mp4") ``` Both run create → PUT → complete and return the video resource. ## Browser uploads Never put a secret key in a browser. Mint an ephemeral [client token](/docs/guides/security-model#client-tokens) server-side with the `videos:write` scope, then run the presigned flow from the browser with the `pvct_` token. Working example: the [browser SPA sample](https://github.com/Primate-Intelligence/primate-intelligence-api/tree/dev/examples). ## Lifecycle ``` awaiting_upload → uploading → processing → ready ↘ failed ``` `DELETE /v1/videos/{id}` removes the video (409 `resource_conflict` while analyses are still running against it). Deletion propagates from S3 + CDN within 72h; signed URLs expire within 1h regardless. --- # Prompts & queries # Prompts & queries Every analysis is a **question about a video**. You can ask it two ways: free text (`prompt`) or a pre-compiled structured query (`query`). Provide exactly one. ## Free-text prompts ```json { "video_id": "video_…", "prompt": "Is there a person near the door?" } ``` The server compiles your prompt into a structured query (the `query` field on the analysis shows exactly what it understood, `parse_mode` shows how: `llm` or `heuristic`). Max 2000 characters. **What works well:** - **Presence** — "Is there a person in this video?", "Are there any vehicles?" - **Actions** — "Does anyone fall down?", "Is someone running?" - **Compound** — "Is there a person AND a dog?", "A truck or a van?" - **Counting-flavored** — "Are there more than two people?" - **Attributes** — "Is anyone wearing a red jacket?" **What to avoid:** - Questions about audio (we analyze frames, not sound) - Identity questions ("Is this John?") — we detect *what*, not *who* - Subjective judgments ("Does the room look nice?") - Questions about text in the video (OCR is not a v1 capability) ## The answer contract `result.answer` is always one of `yes`, `no`, `indeterminate` — with `result.confidence` (0–1) and `result.clips[]` showing *where*. `result.query_type` tells you how the question was classified: `object`, `action`, `compound`, `attribute`, or `open_ended`. `indeterminate` means the model could not commit either way — treat it as "don't ship a decision on this". ## Unassessable components If part of your question can't be assessed, the API **answers what it can and tells you what it couldn't** — it never silently drops half your question: ```json { "prompt": "Is there a person talking loudly?", "query": { "search_terms": ["person"], "unassessable_components": ["talking loudly (audio)"] }, "result": { "answer": "yes", "confidence": 0.91 } } ``` Check `query.unassessable_components` when your prompts mix visual and non-visual language. ## Structured queries Skip the compiler and send the compiled form directly (`parse_mode: "client"`). Useful when you generate queries programmatically or need exact control: ```json { "video_id": "video_…", "query": { "search_terms": ["person", "dog"], "conditions": [ { "type": "presence", "target": "person", "negated": false }, { "type": "presence", "target": "dog", "negated": false } ], "connective": "and", "query_type": "compound", "response_framing": "yes_no" } } ``` If the body fails the CompiledQuery schema you get `400 query_invalid` with `param` naming the field. If the compiler can't parse a free-text prompt you get `422 parse_failed` — rephrase, or fall back to a structured query. ## Prompt errors | Code | Meaning | |---|---| | `prompt_empty` | Provide `prompt` or `query` | | `prompt_too_long` | > 2000 chars | | `query_invalid` | Structured query fails the schema (`param` names the field) | | `parse_failed` | Compiler couldn't produce a query — retryable after rephrasing | ## Tips 1. **Ask one thing.** Two analyses with one question each beat one analysis with a paragraph. 2. **Prefer nouns + verbs over descriptions.** "Is someone climbing the fence?" > "Is there suspicious activity?" 3. **Inspect `query`** on your first few analyses — it shows exactly how your prompt compiled, which makes drift debuggable. 4. **Use the fixture** (`GET /v1/test-fixture`) to sanity-check your integration before touching real footage. --- # Waiting for results # Waiting for results Analyses run asynchronously. There are three ways to learn when one finishes; pick by context. | Strategy | Use when | |---|---| | **`Prefer: wait`** | Interactive flows, scripts, first success experience — simplest | | **Polling** | You want progress bars / queue position, or waits longer than 120s | | **Webhooks** | Production pipelines, high volume, serverless — no held connections | ## `Prefer: wait` (sync sugar) Add one header to the create and the API holds the connection until the analysis reaches a terminal state (or the wait cap): ```bash doc-test id=waiting-prefer env=VIDEO_ID curl -s -X POST https://api.primateintelligence.ai/v1/analyses \ -H "Authorization: Bearer $PRIMATE_API_KEY" \ -H "Content-Type: application/json" \ -H "Prefer: wait=60" \ -d '{"video_id": "'"$VIDEO_ID"'", "prompt": "Is there a person in this video?"}' ``` - Cap: **120 seconds**. If the analysis isn't terminal by then you get the current (running) resource back — fall through to polling. - Test-mode analyses complete in seconds, so `Prefer: wait` virtually always returns the finished result. - Under capacity brownouts `Prefer: wait` may be temporarily disabled (you get an immediate 202-style response with the running resource) — code must handle a non-terminal response either way. The SDK helpers `createAndWait()` (TS) / `create_and_wait()` (Python) use this header, then fall back to polling automatically. ## Polling ```bash curl -s https://api.primateintelligence.ai/v1/analyses/$ANALYSIS_ID \ -H "Authorization: Bearer $PRIMATE_API_KEY" ``` While running, the resource carries honest progress signals: ```json { "status": "queued", "queue_position": 3, "progress": null } ``` `queue_position` and `estimated_start_s` are accurate to within ±50% — we publish transparency rather than an analysis-latency SLO. **Backoff:** poll at 1–2s intervals for interactive flows; add jitter for fleets. Terminal states are `completed`, `failed`, `canceled` — anything else keeps polling. ## Webhooks Register once, get an HTTPS POST on every terminal transition: ```bash curl -s -X POST https://api.primateintelligence.ai/v1/webhook_endpoints \ -H "Authorization: Bearer $PRIMATE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://yourapp.com/webhooks/primate", "events": ["analysis.completed", "analysis.failed"]}' ``` See the [webhooks guide](/docs/guides/webhooks) for verification, retries, and the #1 integration rule (the webhook is a notification, not the source of truth). ## Cancellation ```bash curl -s -X POST https://api.primateintelligence.ai/v1/analyses/$ANALYSIS_ID/cancel \ -H "Authorization: Bearer $PRIMATE_API_KEY" ``` Best-effort once the analysis is `analyzing`; a 409 `analysis_not_cancelable` means it already reached a terminal state. You are not billed for canceled work that never ran. --- # Webhooks # Webhooks Webhook endpoints receive an HTTPS POST whenever a subscribed event fires. Event vocabulary (closed, v1): `video.ready`, `video.failed`, `analysis.completed`, `analysis.failed`, `analysis.canceled`, `stream.ended`. ## Rule #1: the webhook is a notification, not the source of truth Payloads over 256 KiB are truncated (`data.truncated: true`). Delivery is **at-least-once with no ordering guarantee**. The reliable pattern is always: 1. Receive the event → verify the signature → dedupe on the `webhook-id` header 2. `GET` the resource by id for the authoritative state 3. Act on that Never assume `analysis.completed` arrives before (or after) your poll sees `completed`. ## Register an endpoint ```bash curl -s -X POST https://api.primateintelligence.ai/v1/webhook_endpoints \ -H "Authorization: Bearer $PRIMATE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://yourapp.com/webhooks/primate", "events": ["analysis.completed", "analysis.failed"]}' ``` The response contains the signing secret (`whsec_…`) **exactly once**. Store it. Max 10 endpoints per account. ## Delivery contract - Body: `{"id": "evt_…", "type": "analysis.completed", "created_at": "…", "data": {…full resource DTO…}}` - Timeout **10s**; only 2xx counts as delivered (3xx = failure) - Retry schedule: `1m, 5m, 30m, 2h, 8h, 24h` with jitter; then the event dead-letters (visible in the deliveries API for 30 days) - 72h of 100% failure → the endpoint auto-disables + you get an email. Re-enable with `POST /v1/webhook_endpoints/{id}/enable` ## Verify signatures Deliveries are signed per [Standard Webhooks](https://www.standardwebhooks.com). Three headers: `webhook-id`, `webhook-timestamp`, `webhook-signature` (`v1,`; multiple space-separated during rotation). Signed content is `` `${id}.${timestamp}.${rawBody}` ``; the HMAC key is the base64-decoded part of your `whsec_` secret. Reject skew > 5 minutes; compare constant-time. Both SDKs ship verifiers: ```typescript import { verifyWebhookRequest } from '@primate-intelligence/sdk'; app.post('/webhooks/primate', (req, reply) => { verifyWebhookRequest({ secret: process.env.PRIMATE_WEBHOOK_SECRET!, headers: req.headers, rawBody: req.rawBody, // MUST be the raw bytes, not re-serialized JSON }); // throws on failure const event = JSON.parse(req.rawBody); if (alreadyProcessed(event.id)) return reply.code(204).send(); // dedupe on evt_ id // … fetch the resource, act … reply.code(204).send(); }); ``` ```python from primate_intelligence import verify_webhook_request @app.post("/webhooks/primate") async def receive(request: Request): raw = await request.body() verify_webhook_request( secret=os.environ["PRIMATE_WEBHOOK_SECRET"], headers=dict(request.headers), raw_body=raw, # raw bytes, not re-serialized JSON ) # raises ValueError on failure event = json.loads(raw) ... return Response(status_code=204) ``` > Per-request `webhook` overrides on `POST /v1/analyses` / `POST /v1/streams` have no secret exchange and are delivered **unsigned** — register an endpoint when you need verification. ## Rotate secrets ```bash curl -s -X POST https://api.primateintelligence.ai/v1/webhook_endpoints/$ID/rotate_secret \ -H "Authorization: Bearer $PRIMATE_API_KEY" ``` Both old and new secrets sign deliveries for **24h** (the signature header carries two entries); verification passes when any signature matches *your* secret. Full key-compromise procedure: [security model](/docs/guides/security-model). ## Debug deliveries ```bash # last 100 deliveries with response codes curl -s https://api.primateintelligence.ai/v1/webhook_endpoints/$ID/deliveries \ -H "Authorization: Bearer $PRIMATE_API_KEY" # redeliver a failed/dead one now curl -s -X POST https://api.primateintelligence.ai/v1/webhook_endpoints/$ID/deliveries/$DELIVERY_ID/redeliver \ -H "Authorization: Bearer $PRIMATE_API_KEY" ``` ## Local development Use a tunnel (ngrok, cloudflared) so the API can reach your dev machine, or skip webhooks locally and use `Prefer: wait` — the two strategies are drop-in replacements for the completion signal. --- # Errors & retries # Errors & retries Every non-2xx response uses one envelope: ```json { "error": { "code": "insufficient_credits", "message": "Insufficient credits. Add credits or enable auto-refill.", "status": 402, "param": null, "docs_url": "https://primateintelligence.ai/docs/errors#insufficient_credits", "request_id": "req_01H…" } } ``` - **`code`** — stable, machine-readable, from a closed registry. Codes are append-only; meanings never change. - **`docs_url`** — deep link to the [error registry page](/docs/errors) anchor for that code. - **`request_id`** — include it in support requests; it indexes our logs. - **`error.errors[]`** — on `validation_failed`, every violation is listed (param + message), not just the first. - **`details`** — structured extras (e.g. `details.meter` on `quota_exceeded`). ## The registry is machine-readable ```bash doc-test id=errors-registry curl -s https://api.primateintelligence.ai/v1/errors ``` Each entry carries two retry flags: - **`retryable`** — safe to retry the same request unchanged (with backoff) - **`idem`** — idempotent to retry with the same `Idempotency-Key` The SDKs generate their retry policy from this table — if you build your own client, do the same and your retry logic can never drift from the API. ## What to retry | Situation | Codes | Strategy | |---|---|---| | Rate limited | `rate_limit_exceeded`, `concurrency_limit_exceeded` | Wait `Retry-After` seconds, retry | | Platform transient | `inference_unavailable`, `db_unavailable`, `internal_error`, `capacity_exhausted`, `idempotency_unavailable` | Exponential backoff + jitter, same `Idempotency-Key` | | URL fetch flake | `url_fetch_failed` | Retry the create; check the source URL is reachable | | Prompt didn't compile | `parse_failed` (422) | Rephrase the prompt or send a structured `query` | | Everything else | `validation_failed`, `invalid_api_key`, `insufficient_credits`, … | **Do not retry unchanged** — fix the cause | ## Idempotency (never double-create) Send `Idempotency-Key` (any string, 1–255 chars; UUIDv4 recommended) on `POST /v1/videos`, `/v1/analyses`, `/v1/webhook_endpoints`: - Same key + same body within 24h → the stored response replays with `Idempotency-Replayed: true` - Same key + **different** body → `409 idempotency_key_reused` - Body comparison is canonical (JCS) — key order / whitespace / number formatting never false-positive The SDKs auto-generate keys on create calls, which makes network-level retries safe by default. ## Resource-level errors Two codes never appear as HTTP errors — only inside a failed resource's `error` field: - **`inference_error`** — the model failed on this input; safe to create a new analysis - **`stuck_timeout`** — the platform lost the job; not billed; resubmit Check `analysis.error` / `video.error` when `status` is `failed`. ## A worked retry loop (Python, no SDK) ```python import time, requests def create_analysis(body, key, attempts=4): idem = str(uuid.uuid4()) for attempt in range(attempts): r = requests.post( "https://api.primateintelligence.ai/v1/analyses", json=body, headers={"Authorization": f"Bearer {key}", "Idempotency-Key": idem}, ) if r.ok: return r.json() err = r.json()["error"] registry = requests.get("https://api.primateintelligence.ai/v1/errors").json() flags = next(e for e in registry["data"] if e["code"] == err["code"]) if not flags["retryable"] or attempt == attempts - 1: raise RuntimeError(f"{err['code']}: {err['message']} ({err['request_id']})") time.sleep(int(r.headers.get("Retry-After", 2 ** attempt))) ``` (Or just use the SDK — this is exactly what it does, with the registry vendored at build time.) ## See also - [Error registry](/docs/errors) — every code, one anchor each (the `docs_url` targets) - [Rate limits & quotas](/docs/guides/rate-limits) - [Billing & credits](/docs/guides/billing) — handling `insufficient_credits` gracefully --- # Rate limits & quotas # Rate limits & quotas ## Rate limits Applied per API key, sliding window: | Class | Limit | Covers | |---|---|---| | `reads` | 600/min | All GETs | | `writes` | 300/min | POST/DELETE except analysis creation | | `analyses.create` | 60/min | `POST /v1/analyses` | Plan multipliers can raise these. Every response — including 429s — carries: ``` X-RateLimit-Limit: 600 X-RateLimit-Remaining: 597 X-RateLimit-Reset: 1751980860 ``` A 429 adds `Retry-After` (integer seconds, ≥ 1). **Honor it** — the SDKs do automatically. ```json { "error": { "code": "rate_limit_exceeded", "status": 429, "message": "Rate limit exceeded. Back off per Retry-After." } } ``` ## Concurrency limits Separate from request rates: your plan caps **simultaneous running analyses**. Exceeding it returns `429 concurrency_limit_exceeded` — wait for an active analysis to finish rather than backing off blindly. ## Quotas (metered limits) Quotas are about *how much* you process, not how fast you call. Exceeding a metered limit returns `429 quota_exceeded` with `details.meter` naming the meter. Check consumption any time: ```bash doc-test id=rate-limits-usage curl -s https://api.primateintelligence.ai/v1/usage \ -H "Authorization: Bearer $PRIMATE_API_KEY" ``` ```json { "meters": [ { "meter": "credit_seconds", "unit": "seconds", "balance": 5990 }, { "meter": "seconds_processed.period", "unit": "seconds", "used": 10, "resets_at": "2026-08-01T00:00:00Z" } ] } ``` Running out of credits is `402 insufficient_credits` (not 429) — see [billing & credits](/docs/guides/billing). ## Capacity Under platform-wide pressure new creates may get `503 capacity_exhausted` with `Retry-After` while in-flight work drains. Test-mode traffic is never shed (it doesn't touch the GPU). See [service expectations](/docs/guides/versioning#service-expectations). ## Client checklist 1. Honor `Retry-After` on every 429/503 — never hammer 2. Watch `X-RateLimit-Remaining` and pre-throttle when it gets low 3. Treat `concurrency_limit_exceeded` as "wait for completion", not "back off" 4. Treat `quota_exceeded`/`insufficient_credits` as account states — alert a human, don't retry 5. Single region (us-west-2) in v1 — no cross-region limit ambiguity --- # Test mode # Test mode Every account has two key modes. They hit the same API surface with the same code — only the backend differs. | | `pv_test_` | `pv_live_` | |---|---|---| | Getting one | `POST /v1/sandbox` — instant, anonymous | Signup + card | | Results | Deterministic, canned, fixture corpus | Real GPU inference | | Credits | Can never hold or spend credits | Metered | | Speed | Analyses complete in seconds | Workload-shaped | | Expiry | 7 days (sandbox) | Until revoked | ## Instant sandbox ```bash doc-test id=test-mode-sandbox curl -s -X POST https://api.primateintelligence.ai/v1/sandbox ``` No email, no card, no human. The response includes the key, a pre-seeded fixture video id, the fixture prompt, and the expected answer. IP-limited (default 3 provisions per 24h → `429 sandbox_limit_exceeded`). This is the zero-touch path for agents and CI: build and verify the *entire* integration before any human signs up. Graduating to live is a **billing gate, not an integration gate** — your code doesn't change. ## Deterministic fixtures On test keys, analyses against the fixture corpus return canned results — same input, same output, every time. That makes them assertable in CI: ```bash doc-test id=test-mode-fixture curl -s https://api.primateintelligence.ai/v1/test-fixture ``` ```json { "test_video_url": "https://assets.primateintelligence.ai/test/sample-security-cam.mp4", "test_prompt": "Is there a person in this video?", "expected_answer": "Yes", "expected_confidence_min": 0.8 } ``` ## CI verification pattern ```python # conftest-friendly integration check (runs in seconds, costs nothing) from primate_intelligence import Primate def test_primate_integration(): client = Primate() # PRIMATE_API_KEY = a pv_test_ key in CI secrets videos = client.videos.list() analysis = client.analyses.create_and_wait( video_id=videos["data"][0]["id"], prompt="Is there a person in this video?", timeout_s=60, ) assert analysis["result"]["answer"] == "yes" assert analysis["result"]["confidence"] >= 0.8 ``` Every code sample in these docs runs in CI against this exact mechanism — docs that don't run don't ship. ## Limits of test mode - Fixture corpus only — you can't upload arbitrary videos for real analysis on a test key (uploads work mechanically, but analysis results are canned) - No live streaming GPU sessions - `403 test_mode_only` marks operations unavailable in your key's mode When you need real inference on your own footage: [sign up](https://primateintelligence.ai/signup), add a card, create a `pv_live_` key, change the env var. Done. --- # Versioning & deprecation # Versioning & deprecation ## Compatibility contract The v1 API is **additive-only**. Within v1 we may: - Add new endpoints, optional request fields, response fields, enum values on *new* fields, and error codes (the registry is append-only) We will never (without a major version): - Remove or rename fields/endpoints, change field types or meanings, change error-code semantics, or tighten validation on existing fields Write clients that **ignore unknown fields** — that's the only client-side requirement for staying compatible. The gate is mechanical: every spec change runs `oasdiff breaking` in CI; a breaking diff fails the build unless it's an explicitly approved major-version migration. ## Deprecation process When something must go: 1. It's marked `deprecated` in the OpenAPI spec + the [changelog](/docs/changelog) (typed entry: `breaking | additive | fix | deprecation`) 2. A **migration guide** ships with before/after code in curl/TS/Python, the deadline, and an automated check command 3. Sunset headers appear on responses where applicable 4. Breaking removals only ever land in a new major version ## Legacy `pk_` key sunset API keys created before v1 used the `pk_` prefix. They remain valid during the launch migration so existing integrations do not break. New keys and rotations now create `pv_live_` / `pv_test_` keys only. The `pk_` compatibility window is **12 months from GA announcement**. The exact sunset date starts when Matt approves the production GA cutover and will be published here, in the changelog, and in dashboard key-management copy. Before that date, rotate each legacy key from the dashboard or `POST /v1/api-keys/{id}/rotate`; the replacement key is drop-in for `Authorization: Bearer`. After the sunset, `pk_` keys return `401 invalid_api_key`. No paid usage is charged by the act of rotating a key. ## Model versioning Models are versioned resources (`GET /v1/models`) with lifecycle: `preview → stable → deprecated`, and a `sunset_at` date once deprecated. - Omitting `model` on create uses the current default — fine for exploration - **Production integrations should pin a model id** (e.g. `"model": "darwin-1.3"`) so behavior changes only when you choose - `400 model_deprecated` past sunset tells you to re-pin; the changelog carries the migration entry ## SDK versioning SDKs follow semver. An SDK major bump happens only with an API major. Minor/patch releases are additive (new endpoints, fixes) and safe to auto-update within `^`. ## Service expectations Published targets (not a contractual SLA in v1): - **Availability:** 99.9%/mo control plane; ≥ 99% completion success for admitted analyses - **Latency:** p95 reads < 300ms; creates < 1.5s (excluding upload bytes); webhook dispatch p95 < 60s after terminal transition; test-mode analyses < 10s - **Analysis latency is workload-shaped** — we publish honest transparency instead: `queue_position` + `estimated_start_s` accurate to ±50% - Single region: us-west-2 (US). All processing and storage. EU is roadmap, not promise. ## Changelog & RSS Every change lands in the [changelog](/docs/changelog) with a date and type tag. Subscribe: [RSS feed](/docs/changelog.xml). Agents: the changelog is also in `llms-full.txt`. --- # Security model # Security model ## API keys - Two prefixes: `pv_live_` (real processing, metered) and `pv_test_` ([test mode](/docs/guides/test-mode)). 256-bit CSPRNG entropy. - Shown **exactly once** at creation/rotation. We store only a SHA-256 digest. - Send via `Authorization: Bearer …` header only. Keys never belong in URLs, logs, or browser code. - Env-var contract: `PRIMATE_API_KEY` — both SDKs and the MCP server read it. The MCP server never accepts keys as tool arguments (keys must not land in agent transcripts). - Revocation propagates in ≤ 5 seconds. ## Client tokens (browser auth) Secret keys must never reach a browser. For client-side flows, your server mints an **ephemeral client token**: ```bash curl -s -X POST https://api.primateintelligence.ai/v1/client_tokens \ -H "Authorization: Bearer $PRIMATE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"scopes": ["videos:write", "analyses:read"], "ttl_s": 300}' ``` - Prefix `pvct_`, TTL 60–900s (default 900), **never refreshable** — mint a new one when it expires (`401 token_expired`) - Scopes (v1): `videos:write`, `analyses:read`, `analyses:write`, `streams:create`, `streams:signal` — a token's scopes must be a subset of the minting key's - Optionally bind a token to one `video_id` or `stream_id` — the token then works only for that resource - The streaming signaling WebSocket accepts **only** client tokens (or first-party session JWTs) — never secret keys Pattern: a ≤ 20-line "token mint" route on your server, browser does the rest. Working example: the token-mint server sample in the [examples](https://github.com/Primate-Intelligence/primate-intelligence-api/tree/dev/examples). ## Data handling **Prompts and result metadata are retained to operate and improve the service; customer video content is never used to train models.** The full contract: | Data | Retention | |---|---| | Source videos | Deleted 30 days after upload; `DELETE /v1/videos/{id}` propagates from S3 + CDN within 72h (signed URLs expire ≤ 1h) | | Result artifacts (annotated videos) | 30 days, same deletion path | | Result JSON + analysis metadata | 13 months (billing/audit), then aggregated | | Prompts | Retained per the sentence above; org-level opt-out available on request | | Account deletion | Cascades videos/analyses/keys within 30 days; webhook endpoints immediately | - Encryption: TLS ≥ 1.2 in transit; AES-256 at rest - No customer video bytes in logs or analytics — enforced by a redaction test in CI - Region: all processing/storage in us-west-2 (US) - Audit log: key lifecycle + webhook changes retained 13 months ## URL ingest (SSRF policy) `POST /v1/videos {url}` fetches are locked down: https only, port 443, public addresses only (RFC-1918/link-local/metadata ranges rejected), resolved IPs pinned (no TOCTOU), max 3 redirects each re-validated, 2 GiB streamed cap, content validated by magic bytes. Policy violations return `url_forbidden`; network failures `url_fetch_failed`. ## If a key is compromised 1. **Rotate immediately** — create a new key, revoke the old one in the [dashboard](https://primateintelligence.ai/dashboard/api-keys) (propagation ≤ 5s) 2. **Audit** — `GET /v1/analyses?created_after=…` for activity you don't recognize 3. **Contact support** with request ids for usage-forgiveness on abusive spend We monitor per-key spend anomalies (> 5× 7-day baseline), page on them, and may auto-suspend a key pending confirmation — with an email + dashboard banner, never silently. ## Webhook signing Deliveries are signed (Standard Webhooks, HMAC-SHA256, 5-minute replay window, 24h dual-secret rotation). Details: [webhooks guide](/docs/guides/webhooks). --- # Billing & credits # Billing & credits Primate Vision bills by **video-seconds processed**, prepaid as credits. 1 credit-second = 1 second of video analyzed. Current pricing is always at `GET /v1/credit-pricing` (public, no auth) and on the [pricing page](/pricing). ## How credits flow - **Signup grant** — new accounts get free credit-seconds to evaluate with (currently 6,000s ≈ 100 minutes; expiring) - **Card grant** — adding a card grants another tranche - **Purchases** — buy credit blocks in the [dashboard](https://primateintelligence.ai/dashboard/billing); custom amounts supported - **Auto-refill** — opt in to top up automatically when the balance crosses the threshold (currently 600s) Test-mode keys never hold or spend credits — build and CI on `pv_test_` for free, forever. ## Check your balance ```bash doc-test id=billing-usage curl -s https://api.primateintelligence.ai/v1/usage \ -H "Authorization: Bearer $PRIMATE_API_KEY" ``` ```json { "meters": [ { "meter": "credit_seconds", "unit": "seconds", "balance": 5990 }, { "meter": "seconds_processed.period", "unit": "seconds", "used": 10, "resets_at": "2026-08-01T00:00:00Z" } ] } ``` ## What you're charged for - **Analyses** — the duration of video actually analyzed, debited when the analysis completes. Failed platform-side work (`inference_error`, `stuck_timeout`) is **not billed**; resubmit freely. - **Streams** — live sessions reserve credits up front and reconcile to actual connected seconds at end (`GET /v1/streams/{id}` shows the final usage). Mid-stream you get `warning` messages as the reservation runs low, then the session ends with `end_reason: "insufficient_credits"`. ## Handling `insufficient_credits` in code The one billing error every integration must handle (`402`, **not retryable**): ```typescript doc-test id=billing-handling sdk=ts offline import Primate, { PrimateError } from '@primate-intelligence/sdk'; const client = new Primate(); try { await client.analyses.create({ video_id: videoId, prompt: 'Is the loading dock clear?' }); } catch (err) { if (err instanceof PrimateError && err.code === 'insufficient_credits') { const { meters } = await client.usage.retrieve(); const balance = meters.find((m) => m.meter === 'credit_seconds')?.balance ?? 0; notifyOwner( `Primate Vision balance is ${balance}s — top up or enable auto-refill: ` + `https://primateintelligence.ai/dashboard/billing`, ); // Do NOT retry unchanged — queue the job for after refill instead. } else { throw err; } } ``` The robust pattern: 1. Catch `insufficient_credits` → **stop submitting** (it will keep failing) 2. Read `GET /v1/usage` for the real balance 3. Surface a human-actionable message with the billing URL 4. If the account has auto-refill, retry after a short delay **once** — refill is triggered by the threshold crossing 5. Alert *before* you hit zero: poll `credit_seconds` and warn below your own threshold ## Free-credit expiry Granted (free) credits expire; purchased credits don't. Expiry dates show in the dashboard. Expired grants simply vanish from `balance` — no negative surprises. ## Invoices & history Purchase history and receipts live in the [dashboard billing page](https://primateintelligence.ai/dashboard/billing). Usage aggregates by day/analysis are on the [usage page](https://primateintelligence.ai/dashboard/usage). --- # Streaming # Streaming Streams analyze live video in real time: point a camera (or any MediaStream) at the API and get per-frame detection results back over a WebRTC data channel, with the same prompt semantics as file analyses. ## Lifecycle ``` POST /v1/streams → queued|ready → (WS join → offer/answer/ICE) → live → ended ``` 1. **Create** the stream server-side with your secret key: ```bash curl -s -X POST https://api.primateintelligence.ai/v1/streams \ -H "Authorization: Bearer $PRIMATE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"prompt": "Is there a person in the frame?"}' ``` The response carries everything the client needs: `signaling.url` (a WebSocket), `ice_servers` (STUN/TURN with credentials), `limits` (`max_session_s`, `warn_at_remaining_s`), and `queue_position`/`estimated_start_s` when capacity is busy. 2. **Mint a client token** for the browser/device (the signaling WS **never** accepts secret keys): ```bash curl -s -X POST https://api.primateintelligence.ai/v1/client_tokens \ -H "Authorization: Bearer $PRIMATE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"scopes": ["streams:signal"], "stream_id": "'"$STREAM_ID"'", "ttl_s": 300}' ``` 3. **Connect** from the client. With the TS SDK this is one call: ```typescript import { connectStream } from '@primate-intelligence/sdk/browser'; const session = await connectStream({ stream, // the Stream resource (relay it from your server) clientToken, // pvct_… minted in step 2 mediaStream: await navigator.mediaDevices.getUserMedia({ video: true }), onResult: (r) => console.log(r.frame_num, r.detections), onWarning: (remainingS) => showCountdown(remainingS), onEnd: (reason) => cleanup(reason), }); // change the question mid-stream: session.updatePrompt('Is there a forklift moving?'); // done: session.end(); ``` Under the hood: WS `join` → `ready` (or `queued {position}`) → WebRTC `offer`/`answer` + ICE trickle → media flows → `result` messages per analyzed frame. ## Signaling protocol (build your own client) Messages you send: `join`, `offer {sdp}`, `ice {candidate}`, `update_prompt {prompt}`, `end`, `ping`. Messages you receive: `queued {position, estimated_start_s}`, `ready`, `answer {sdp}`, `ice {candidate}`, `live`, `result {frame_num, detections}`, `metering {…}`, `warning {remaining_s}`, `end {reason}`, `error {code, message}`, `pong`. A Python/aiortc backend example (robotics-style, no browser) ships in the [examples](https://github.com/Primate-Intelligence/primate-intelligence-api/tree/dev/examples). ## Credits & limits - One active (queued/ready/live) stream per account by default — a second create returns `409 stream_already_active` - Sessions reserve credits up front; metering ticks every 5s; you get `warning {remaining_s}` before funds run out, then `end {reason: "insufficient_credits"}` - `limits.max_session_s` is the hard per-session cap from your plan - After `ended`, `GET /v1/streams/{id}` shows `duration_s`, `usage` (reconciled to the second), and `results_summary` ## End a stream From the client: send `end` (or just `session.end()`). Server-side / cleanup: ```bash curl -s -X POST https://api.primateintelligence.ai/v1/streams/$STREAM_ID/end \ -H "Authorization: Bearer $PRIMATE_API_KEY" ``` Idempotent — safe to call on an already-ended stream. `stream.ended` fires as a [webhook](/docs/guides/webhooks) if subscribed. --- # Tutorial: build from scratch # Tutorial: build from scratch We'll build `askvideo` — a Node CLI that takes a video URL and a question, and prints the answer. Zero to working in about 15 minutes, entirely on a free test key. ## Step 0: get a key (30 seconds) ```bash doc-test id=tutorial-sandbox curl -s -X POST https://api.primateintelligence.ai/v1/sandbox ``` ```bash export PRIMATE_API_KEY="pv_test_…" # from the response ``` ## Step 1: project setup ```bash mkdir askvideo && cd askvideo npm init -y ``` ## Step 2: the whole program `askvideo.mjs`: ```javascript const [url, ...promptParts] = process.argv.slice(2); const prompt = promptParts.join(' '); if (!url || !prompt) { console.error('usage: node askvideo.mjs '); process.exit(2); } const apiKey = process.env.PRIMATE_API_KEY; const baseUrl = process.env.PRIMATE_BASE_URL ?? 'https://api.primateintelligence.ai'; if (!apiKey) { console.error('PRIMATE_API_KEY is required'); process.exit(2); } async function api(path, options = {}) { const res = await fetch(`${baseUrl}${path}`, { ...options, headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', ...(options.headers ?? {}), }, }); const body = await res.json(); if (!res.ok) { const err = body.error ?? { code: 'request_failed', message: res.statusText }; console.error(`${err.code}: ${err.message}`); if (err.docs_url) console.error(`docs: ${err.docs_url}`); process.exit(1); } return body; } async function createAndWait(videoId, question) { let analysis = await api('/v1/analyses', { method: 'POST', headers: { Prefer: 'wait=60' }, body: JSON.stringify({ video_id: videoId, prompt: question }), }); for (let i = 0; analysis.status !== 'completed' && i < 30; i += 1) { if (analysis.status === 'failed' || analysis.status === 'canceled') { throw new Error(`analysis_${analysis.status}: ${JSON.stringify(analysis.error)}`); } await new Promise(resolve => setTimeout(resolve, 1000)); analysis = await api(`/v1/analyses/${analysis.id}`); } return analysis; } const video = await api('/v1/videos', { method: 'POST', body: JSON.stringify({ url }), }); console.error(`video ${video.id} (${video.status})`); const analysis = await createAndWait(video.id, prompt); console.log(JSON.stringify(analysis.result, null, 2)); ``` ## Step 3: run it Test keys analyze the fixture corpus deterministically, so use the official fixture: ```bash FIXTURE=$(curl -s https://api.primateintelligence.ai/v1/test-fixture) node askvideo.mjs \ "$(echo $FIXTURE | python3 -c 'import json,sys; print(json.load(sys.stdin)["test_video_url"])')" \ "Is there a person in this video?" ``` ```json { "answer": "yes", "confidence": 0.93, "clips": [{ "start_s": 1, "end_s": 4, "confidence": 0.93 }], "query_type": "object", "video_duration_s": 10 } ``` That's a working integration. Everything below is production hardening. ## Step 4: make it CI-verifiable `test.mjs`: ```javascript const baseUrl = process.env.PRIMATE_BASE_URL ?? 'https://api.primateintelligence.ai'; const headers = { Authorization: `Bearer ${process.env.PRIMATE_API_KEY}`, 'Content-Type': 'application/json', }; const fixture = await fetch(`${baseUrl}/v1/test-fixture`).then(r => r.json()); const video = await fetch(`${baseUrl}/v1/videos`, { method: 'POST', headers, body: JSON.stringify({ url: fixture.test_video_url }), }).then(r => r.json()); const analysis = await fetch(`${baseUrl}/v1/analyses`, { method: 'POST', headers: { ...headers, Prefer: 'wait=60' }, body: JSON.stringify({ video_id: video.id, prompt: fixture.test_prompt }), }).then(r => r.json()); const ok = analysis.result.answer.toLowerCase() === fixture.expected_answer.toLowerCase() && analysis.result.confidence >= fixture.expected_confidence_min; console.log(ok ? 'INTEGRATION OK' : 'INTEGRATION BROKEN'); process.exit(ok ? 0 : 1); ``` Run that in CI with a `pv_test_` key in your secrets — it completes in seconds and costs nothing. ## Step 5: production hardening checklist - **Error handling** — retry only codes marked retryable in the [error registry](/docs/guides/errors-retries); never retry [`insufficient_credits`](/docs/guides/billing) - **Stop polling** — swap `createAndWait` for [webhooks](/docs/guides/webhooks) at volume - **Pin a model** — pass `model: 'darwin-1.3'` so behavior changes only when you choose ([versioning](/docs/guides/versioning)) - **Your own videos** — swap URL ingest for [presigned upload](/docs/guides/uploading) when the bytes live with you - **Go live** — [sign up](https://primateintelligence.ai/signup), create a `pv_live_` key, change the env var. No code changes. ## Where to next - [Sample matrix](https://github.com/Primate-Intelligence/primate-intelligence-api/tree/dev/examples) — Python, webhook receivers, browser SPA, streaming, token-mint server - [Agent quickstart](/docs/agents) — the condensed machine-readable version of everything you just did - [API reference](/docs/reference) --- # Error registry Every error code the API can return, with retry semantics. This registry is closed and append-only — codes are never removed or repurposed within v1. Machine-readable at `GET https://api.primateintelligence.ai/v1/errors`. **retryable** = safe to retry the same request unchanged (with backoff). **idem** = idempotent to retry with the same `Idempotency-Key`. ## HTTP errors ### `invalid_api_key` **HTTP 401** · retryable: **no** · idem: **no** API key is invalid. Check the key, env var, and whitespace. ### `key_revoked` **HTTP 401** · retryable: **no** · idem: **no** This API key has been revoked. Rotate or create a new key. ### `key_expired` **HTTP 401** · retryable: **no** · idem: **no** This API key has expired. Create a new key. ### `token_expired` **HTTP 401** · retryable: **no** · idem: **no** Client token has expired. Mint a new token from your server (client tokens are never refreshable). ### `insufficient_scope` **HTTP 403** · retryable: **no** · idem: **no** The key does not carry the scope required for this operation. ### `account_restricted` **HTTP 403** · retryable: **no** · idem: **no** Account is restricted. Complete signup/verification. ### `test_mode_only` **HTTP 403** · retryable: **no** · idem: **no** Operation not available in this key mode. ### `validation_failed` **HTTP 400** · retryable: **no** · idem: **no** Request validation failed. All violations are listed in error.errors[]. ### `unsupported_media_type` **HTTP 415** · retryable: **no** · idem: **no** Send video/mp4 or video/quicktime with a correct Content-Type. ### `payload_too_large` **HTTP 413** · retryable: **no** · idem: **no** Payload exceeds the 100MB multipart cap. Use the presigned upload path. ### `resource_not_found` **HTTP 404** · retryable: **no** · idem: **no** No such resource. IDs are account-scoped; check the ID and prefix. ### `resource_conflict` **HTTP 409** · retryable: **no** · idem: **no** Invalid state transition. GET the resource for its current state. ### `idempotency_key_reused` **HTTP 409** · retryable: **no** · idem: **no** Idempotency-Key was reused with a different request body. ### `idempotency_unavailable` **HTTP 409** · retryable: **yes** · idem: **yes** Idempotency store unavailable. Retry with the same key after Retry-After. ### `stream_already_active` **HTTP 409** · retryable: **no** · idem: **no** An active stream already exists on this account. End it (POST /v1/streams/{id}/end) or wait for it to finish. ### `upload_incomplete` **HTTP 400** · retryable: **no** · idem: **no** Upload incomplete or size mismatch. Re-create the video and complete within 24h. ### `video_unreadable` **HTTP 400** · retryable: **no** · idem: **no** Container/codec unsupported. Re-encode to H.264 MP4. ### `video_too_large` **HTTP 400** · retryable: **no** · idem: **no** Video exceeds the 2 GiB limit. ### `video_too_long` **HTTP 400** · retryable: **no** · idem: **no** Video exceeds the plan duration limit. Upgrade or trim. ### `url_fetch_failed` **HTTP 400** · retryable: **yes** · idem: **no** Source URL unreachable or timed out. ### `url_forbidden` **HTTP 400** · retryable: **no** · idem: **no** URL scheme or host blocked by the ingest policy. ### `prompt_empty` **HTTP 400** · retryable: **no** · idem: **no** Provide prompt or query. ### `prompt_too_long` **HTTP 400** · retryable: **no** · idem: **no** Prompt exceeds 2000 characters. ### `query_invalid` **HTTP 400** · retryable: **no** · idem: **no** Body fails the CompiledQuery schema; param names the field. ### `parse_failed` **HTTP 422** · retryable: **yes** · idem: **no** The compiler could not produce a query. Rephrase or send a structured query. ### `model_not_found` **HTTP 400** · retryable: **no** · idem: **no** Unknown model. See GET /v1/models. ### `model_deprecated` **HTTP 400** · retryable: **no** · idem: **no** Model is deprecated. Pin a supported version; see the changelog. ### `analysis_not_cancelable` **HTTP 409** · retryable: **no** · idem: **no** Analysis is already in a terminal state. ### `rate_limit_exceeded` **HTTP 429** · retryable: **yes** · idem: **yes** Rate limit exceeded. Back off per Retry-After. ### `concurrency_limit_exceeded` **HTTP 429** · retryable: **yes** · idem: **yes** Concurrent analysis limit reached. Wait for an active analysis to finish. ### `quota_exceeded` **HTTP 429** · retryable: **no** · idem: **no** Quota exceeded for a metered limit. See details.meter. ### `insufficient_credits` **HTTP 402** · retryable: **no** · idem: **no** Insufficient credits. Add credits or enable auto-refill. ### `capacity_exhausted` **HTTP 503** · retryable: **yes** · idem: **yes** Platform queue is full. Honor Retry-After. ### `sandbox_limit_exceeded` **HTTP 429** · retryable: **yes** · idem: **yes** Sandbox provisioning limit reached for this IP. Retry after the window resets, or sign up for a live key. ### `inference_unavailable` **HTTP 503** · retryable: **yes** · idem: **yes** Transient inference backend outage. Retry with backoff. ### `db_unavailable` **HTTP 503** · retryable: **yes** · idem: **yes** Transient database outage. Retry with backoff. ### `internal_error` **HTTP 500** · retryable: **yes** · idem: **yes** Internal error. Includes request_id; contact support if persistent. ### `webhook_endpoint_limit` **HTTP 400** · retryable: **no** · idem: **no** At most 10 webhook endpoints per account. ## Resource-level errors These never appear as HTTP responses — only in `analysis.error.code` / `video.error.code` on failed resources. ### `inference_error` Model failed on this input. Safe to create a new analysis; persistent failures on one video → support with request_id. ### `stuck_timeout` The platform lost the job past the sweep window. Not billed; resubmit. --- # Changelog # Changelog Entry types: `additive` (safe), `fix`, `deprecation` (migration guide included), `breaking` (major versions only — none in v1). Subscribe: [RSS](/docs/changelog.xml) ## 2026-07-09 — `additive` — SDKs, MCP server, agent artifacts - Official SDKs: TypeScript (`@primate-intelligence/sdk`) and Python (`primate-intelligence`) — typed resources, registry-driven retries, auto idempotency keys, `createAndWait()`, webhook verification, pagination iterators, browser `connectStream()`. - MCP server (`@primate-intelligence/mcp`) with seven tools mirroring the spec. - `llms.txt` / `llms-full.txt` + `/.well-known/llms.txt`; all docs pages served as raw markdown at `/docs/*.md`. - New documentation site: quickstart, agent quickstart, ten guides, Scalar API reference, error-registry anchors. ## 2026-07-09 — `deprecation` — Legacy `pk_` API key prefix sunset prepared - Existing `pk_` keys remain valid through the launch migration. - New keys and rotations create `pv_live_` / `pv_test_` keys only. - The 12-month `pk_` sunset clock starts at production GA announcement after Matt approves the cutover; the exact date will be published in the versioning guide and dashboard before enforcement. ## 2026-07-08 — `additive` — Billing: credit purchases + auto-refill - `GET /v1/credit-pricing` (public): price per second, grant sizes, purchase options. - Dashboard credit purchases, saved payment methods, and threshold-triggered auto-refill. - `GET /v1/billing/usage-summary` for dashboard usage aggregation. ## 2026-07-07 — `additive` — Webhooks + usage meters - `webhook_endpoints` resource: CRUD, Standard-Webhooks signing, delivery worker with `1m…24h` retry schedule, deliveries API, redelivery, secret rotation with 24h dual-secret overlap, auto-disable after 72h of failures. - Event vocabulary: `video.ready`, `video.failed`, `analysis.completed`, `analysis.failed`, `analysis.canceled`, `stream.ended`. - Per-request `webhook` overrides on `POST /v1/analyses` / `POST /v1/streams` (unsigned). - `GET /v1/usage` generic meters shape (`credit_seconds` balance + period consumption). ## 2026-07-06 — `additive` — Streams public API - `streams` resource (§4.8): create → signal (WS) → WebRTC → live per-frame results → end. Queueing with honest `queue_position`/`estimated_start_s`, per-plan session caps, 5s metering ticks, credit reservation reconciled to the second. - `stream_already_active` (409) error code registered. ## 2026-07-05 — `additive` — Client tokens - `POST /v1/client_tokens`: ephemeral browser-safe `pvct_` tokens (60–900s TTL, scope subsets, optional video/stream binding). Signaling WS accepts client tokens only — never secret keys. - `token_expired` (401) error code registered. ## 2026-07-04 — `additive` — Instant sandbox + auth cutoff - Anonymous `POST /v1/sandbox` issues instant `pv_test_` keys (IP-limited, 7-day expiry, fixture corpus, deterministic results) with a pre-seeded fixture video. - Metered credit enforcement on the live plane; open signup. - `sandbox_limit_exceeded` (429) error code registered. ## 2026-07-03 — `additive` — Public core: videos + analyses - `videos` resource: presigned upload (create → PUT → complete), SSRF-guarded URL ingest, cursor-paginated lists, delete with 409 protection. - `analyses` resource: free-text prompts or structured queries, `Prefer: wait` sync sugar (cap 120s), live progress + queue position, cancel, deterministic `result` contract (`answer`/`confidence`/`clips`). - `GET /v1/models`, `GET /v1/errors` (machine-readable registry), idempotency keys (JCS canonical body comparison, 24h replay window). ## 2026-07-02 — `additive` — OpenAPI spec - `GET /v1/openapi.json` — OpenAPI 3.1, generated from the DTO registry; CI drift + `oasdiff breaking` gates. The spec is the source of truth for docs, SDKs, and this changelog's compatibility promises.