# API reference

> Public API v1 for Primate Vision — video understanding for the agentic web. P1 (PRI-430) publishes resource schemas; endpoints ship from P3 onward.

Generated from the OpenAPI 3.1 contract (version `1.0.0-alpha`). The machine-readable spec is the source of truth: [https://api.primateintelligence.ai/v1/openapi.json](https://api.primateintelligence.ai/v1/openapi.json).

- Base URL: `https://api.primateintelligence.ai`
- Auth: `Authorization: Bearer $PRIMATE_API_KEY` (see [Security model](/docs/guides/security-model))
- Errors: every non-2xx response carries the standard envelope — see the [Error registry](/docs/errors)

## Provisioning & keys

### `POST /v1/keys/request`

<a id="post-v1-keys-request"></a>**Start a device-code claim flow for a full billed key**

Stage 2 of the key lifecycle. Optional auth (test or live key accepted, or none). Returns a `device_code` and `claim_url`. Direct the user to `claim_url` to sign in; poll `GET /v1/keys/request/{code}` until status changes from `pending`. Key is delivered once in the claim response; subsequent polls return 410.

**Responses**

- `202` — Device code issued. → [DeviceCodeRequest](#schema-devicecoderequest)
- `503` — db_unavailable → [ErrorEnvelope](#schema-errorenvelope)

### `GET /v1/keys/request/{code}`

<a id="get-v1-keys-request-code"></a>**Poll a device-code claim request**

Returns `{status: "pending"}` until the user approves in the browser. After approval, returns `status: "consumed"` (key was delivered in the claim response). Expired or already-consumed codes return 410.

**Parameters**

- `code` (path, string) *(required)* — The `device_code` from POST /v1/keys/request.

**Responses**

- `200` — Current status of the device code. → [DeviceCodeStatus](#schema-devicecodestatus)
- `404` — resource_not_found — code not found. → [ErrorEnvelope](#schema-errorenvelope)
- `410` — device_code_expired — code consumed or expired. Start a new request. → [ErrorEnvelope](#schema-errorenvelope)

### `POST /v1/keys/upgrade`

<a id="post-v1-keys-upgrade"></a>**Upgrade a test key to a live free-grant key (6,000s credit)**

Stage 1 of the key lifecycle. Call with a `pv_test_` key in Authorization; receive a `pv_live_` key with `tier: free_grant` and a 6,000-second credit grant. No email, card, or human-in-the-loop required. IP-limited (3 upgrades / IP / 24h).

**Responses**

- `201` — Live key issued. The raw `api_key` appears only in this response. → [UpgradeKeyResponse](#schema-upgradekeyresponse)
- `403` — test_mode_only — must call with a pv_test_ key. → [ErrorEnvelope](#schema-errorenvelope)
- `429` — upgrade_limit_exceeded — per-IP or global daily cap reached. → [ErrorEnvelope](#schema-errorenvelope)

### `POST /v1/sandbox`

<a id="post-v1-sandbox"></a>**Provision an instant sandbox (pv_test_) key — no auth, no card**

Anonymous zero-touch provisioning (A1). Issues a fixture-only test-mode key: analyses return deterministic canned results, never touch the GPU, and burn no credits. IP-limited; 7-day expiry. Live keys require signup (billing gate, not an integration gate).

**Responses**

- `201` — Sandbox key issued. The raw key appears only in this response. → [SandboxKey](#schema-sandboxkey)
- `429` — sandbox_limit_exceeded — IP daily cap reached. Retry-After set. → [ErrorEnvelope](#schema-errorenvelope)
- `503` — db_unavailable — transient; retry with backoff. → [ErrorEnvelope](#schema-errorenvelope)

## Videos

### `GET /v1/videos`

<a id="get-v1-videos"></a>**List videos (cursor pagination, created_at desc)**

**Parameters**

- `limit` (query, integer)
- `starting_after` (query, string) — Cursor — mutually exclusive with ending_before.
- `ending_before` (query, string)
- `status` (query, string)
- `created_after` (query, string (date-time))
- `created_before` (query, string (date-time))

**Responses**

- `200` — List envelope (§13.4). → [VideoList](#schema-videolist)
- `400` — validation_failed / url_forbidden / prompt errors (§15) → [ErrorEnvelope](#schema-errorenvelope)
- `401` — invalid_api_key / key_revoked / key_expired → [ErrorEnvelope](#schema-errorenvelope)
- `404` — resource_not_found (account-scoped; no existence oracle) → [ErrorEnvelope](#schema-errorenvelope)
- `409` — resource_conflict / idempotency_key_reused / idempotency_unavailable → [ErrorEnvelope](#schema-errorenvelope)
- `429` — rate_limit_exceeded / concurrency_limit_exceeded / quota_exceeded (always with Retry-After) → [ErrorEnvelope](#schema-errorenvelope)

### `POST /v1/videos`

<a id="post-v1-videos"></a>**Create a video (presigned upload or URL ingest)**

**Parameters**

- `Idempotency-Key` (header, string) — §16.1: first request wins; 24h replay window; same key + different body → 409 idempotency_key_reused.

**Request body** — [CreateVideoRequest](#schema-createvideorequest)

**Responses**

- `201` — Video created. Presigned mode → status awaiting_upload with upload instructions; URL mode → status processing (async fetch). → [Video](#schema-video)
- `400` — validation_failed / url_forbidden / prompt errors (§15) → [ErrorEnvelope](#schema-errorenvelope)
- `401` — invalid_api_key / key_revoked / key_expired → [ErrorEnvelope](#schema-errorenvelope)
- `404` — resource_not_found (account-scoped; no existence oracle) → [ErrorEnvelope](#schema-errorenvelope)
- `409` — resource_conflict / idempotency_key_reused / idempotency_unavailable → [ErrorEnvelope](#schema-errorenvelope)
- `429` — rate_limit_exceeded / concurrency_limit_exceeded / quota_exceeded (always with Retry-After) → [ErrorEnvelope](#schema-errorenvelope)

### `GET /v1/videos/{id}`

<a id="get-v1-videos-id"></a>**Fetch a video**

**Parameters**

- `id` (path, string) *(required)*

**Responses**

- `200` — The video. → [Video](#schema-video)
- `400` — validation_failed / url_forbidden / prompt errors (§15) → [ErrorEnvelope](#schema-errorenvelope)
- `401` — invalid_api_key / key_revoked / key_expired → [ErrorEnvelope](#schema-errorenvelope)
- `404` — resource_not_found (account-scoped; no existence oracle) → [ErrorEnvelope](#schema-errorenvelope)
- `409` — resource_conflict / idempotency_key_reused / idempotency_unavailable → [ErrorEnvelope](#schema-errorenvelope)
- `429` — rate_limit_exceeded / concurrency_limit_exceeded / quota_exceeded (always with Retry-After) → [ErrorEnvelope](#schema-errorenvelope)

### `DELETE /v1/videos/{id}`

<a id="delete-v1-videos-id"></a>**Delete a video (409 while analyses are non-terminal on it)**

**Parameters**

- `id` (path, string) *(required)*

**Responses**

- `200` — Deleted. → [DeletedVideo](#schema-deletedvideo)
- `400` — validation_failed / url_forbidden / prompt errors (§15) → [ErrorEnvelope](#schema-errorenvelope)
- `401` — invalid_api_key / key_revoked / key_expired → [ErrorEnvelope](#schema-errorenvelope)
- `404` — resource_not_found (account-scoped; no existence oracle) → [ErrorEnvelope](#schema-errorenvelope)
- `409` — resource_conflict / idempotency_key_reused / idempotency_unavailable → [ErrorEnvelope](#schema-errorenvelope)
- `429` — rate_limit_exceeded / concurrency_limit_exceeded / quota_exceeded (always with Retry-After) → [ErrorEnvelope](#schema-errorenvelope)

### `POST /v1/videos/{id}/complete`

<a id="post-v1-videos-id-complete"></a>**Signal that the presigned S3 PUT finished**

**Parameters**

- `id` (path, string) *(required)*

**Responses**

- `200` — Size verified (±5% of declaration); video is ready. → [Video](#schema-video)
- `400` — validation_failed / url_forbidden / prompt errors (§15) → [ErrorEnvelope](#schema-errorenvelope)
- `401` — invalid_api_key / key_revoked / key_expired → [ErrorEnvelope](#schema-errorenvelope)
- `404` — resource_not_found (account-scoped; no existence oracle) → [ErrorEnvelope](#schema-errorenvelope)
- `409` — resource_conflict / idempotency_key_reused / idempotency_unavailable → [ErrorEnvelope](#schema-errorenvelope)
- `429` — rate_limit_exceeded / concurrency_limit_exceeded / quota_exceeded (always with Retry-After) → [ErrorEnvelope](#schema-errorenvelope)

## Analyses

### `GET /v1/analyses`

<a id="get-v1-analyses"></a>**List analyses (filters: status, video_id, model, created_*)**

**Parameters**

- `limit` (query, integer)
- `starting_after` (query, string) — Cursor — mutually exclusive with ending_before.
- `ending_before` (query, string)
- `created_after` (query, string (date-time))
- `created_before` (query, string (date-time))
- `status` (query, string) — Filter by public analysis status (§13.2 vocabulary): queued | preparing | analyzing | rendering | completed | failed | canceled. E.g. status=analyzing returns currently-running analyses. Unknown values → 400 validation_failed.
- `video_id` (query, string)
- `model` (query, string)

**Responses**

- `200` — List envelope (§13.4). → [AnalysisList](#schema-analysislist)
- `400` — validation_failed / url_forbidden / prompt errors (§15) → [ErrorEnvelope](#schema-errorenvelope)
- `401` — invalid_api_key / key_revoked / key_expired → [ErrorEnvelope](#schema-errorenvelope)
- `404` — resource_not_found (account-scoped; no existence oracle) → [ErrorEnvelope](#schema-errorenvelope)
- `409` — resource_conflict / idempotency_key_reused / idempotency_unavailable → [ErrorEnvelope](#schema-errorenvelope)
- `429` — rate_limit_exceeded / concurrency_limit_exceeded / quota_exceeded (always with Retry-After) → [ErrorEnvelope](#schema-errorenvelope)

### `POST /v1/analyses`

<a id="post-v1-analyses"></a>**Create an analysis (video × prompt × model)**

Async by default: 202 + poll. `Prefer: wait=<s>` (≤120) blocks and returns the terminal resource when it finishes in time. Test-mode keys (pv_test_) return deterministic canned results with no GPU time and no credit burn. Set `validate_only: true` to dry-run a prompt — returns an AnalysisPreview with cost/assessability and creates nothing.

**Parameters**

- `Idempotency-Key` (header, string) — §16.1: first request wins; 24h replay window; same key + different body → 409 idempotency_key_reused.
- `Prefer` (header, string) — wait=<seconds> (cap 120)

**Request body** — [CreateAnalysisRequest](#schema-createanalysisrequest)

**Responses**

- `200` — Terminal resource (Prefer: wait satisfied, or test mode) — OR an AnalysisPreview when validate_only:true was sent. Discriminate on the `object` field. → Analysis | AnalysisPreview
- `202` — Queued. Response echoes the compiled query and honest queue_position. → [Analysis](#schema-analysis)
- `400` — validation_failed / url_forbidden / prompt errors (§15) → [ErrorEnvelope](#schema-errorenvelope)
- `401` — invalid_api_key / key_revoked / key_expired → [ErrorEnvelope](#schema-errorenvelope)
- `402` — insufficient_credits (details.meter = credit_seconds) → [ErrorEnvelope](#schema-errorenvelope)
- `404` — resource_not_found (account-scoped; no existence oracle) → [ErrorEnvelope](#schema-errorenvelope)
- `409` — resource_conflict / idempotency_key_reused / idempotency_unavailable → [ErrorEnvelope](#schema-errorenvelope)
- `429` — rate_limit_exceeded / concurrency_limit_exceeded / quota_exceeded (always with Retry-After) → [ErrorEnvelope](#schema-errorenvelope)
- `503` — inference_unavailable / capacity_exhausted (with Retry-After) → [ErrorEnvelope](#schema-errorenvelope)

### `POST /v1/analyses/batch`

<a id="post-v1-analyses-batch"></a>**Submit 2–10 prompts against the same video (batch discount)**

Pricing: first prompt billed at full price; each additional prompt billed at 50%. Credits are reserved per-prompt at the discounted rate so the ledger shows the discount directly. Returns an `analysis_batch` containing all analysis DTOs plus a `pricing` summary. Each analysis can be polled individually via `GET /v1/analyses/{id}`. Pass `validate_only: true` for a cost-estimate dry-run (HTTP 200, no credits reserved).

**Request body** — [CreateAnalysisBatchRequest](#schema-createanalysisbatchrequest)

**Responses**

- `200` — Dry-run preview (validate_only: true). Prompts parsed; cost estimated; no credits reserved, no jobs created. → [AnalysisBatchPreview](#schema-analysisbatchpreview)
- `202` — Batch queued. All analyses dispatched; poll each `id` individually. → [AnalysisBatch](#schema-analysisbatch)
- `400` — validation_failed / url_forbidden / prompt errors (§15) → [ErrorEnvelope](#schema-errorenvelope)
- `401` — invalid_api_key / key_revoked / key_expired → [ErrorEnvelope](#schema-errorenvelope)
- `402` — insufficient_credits / grant_exhausted → [ErrorEnvelope](#schema-errorenvelope)
- `404` — resource_not_found (account-scoped; no existence oracle) → [ErrorEnvelope](#schema-errorenvelope)
- `409` — resource_conflict / idempotency_key_reused / idempotency_unavailable → [ErrorEnvelope](#schema-errorenvelope)
- `429` — rate_limit_exceeded / concurrency_limit_exceeded / quota_exceeded (always with Retry-After) → [ErrorEnvelope](#schema-errorenvelope)
- `503` — capacity_exhausted / inference_unavailable → [ErrorEnvelope](#schema-errorenvelope)

### `GET /v1/analyses/{id}`

<a id="get-v1-analyses-id"></a>**Fetch an analysis (live progress while running)**

**Parameters**

- `id` (path, string) *(required)*

**Responses**

- `200` — The analysis. progress uses the canonical stage set queued→preparing→analyzing→rendering. → [Analysis](#schema-analysis)
- `400` — validation_failed / url_forbidden / prompt errors (§15) → [ErrorEnvelope](#schema-errorenvelope)
- `401` — invalid_api_key / key_revoked / key_expired → [ErrorEnvelope](#schema-errorenvelope)
- `404` — resource_not_found (account-scoped; no existence oracle) → [ErrorEnvelope](#schema-errorenvelope)
- `409` — resource_conflict / idempotency_key_reused / idempotency_unavailable → [ErrorEnvelope](#schema-errorenvelope)
- `429` — rate_limit_exceeded / concurrency_limit_exceeded / quota_exceeded (always with Retry-After) → [ErrorEnvelope](#schema-errorenvelope)

### `POST /v1/analyses/{id}/cancel`

<a id="post-v1-analyses-id-cancel"></a>**Cancel an analysis (best-effort once analyzing)**

**Parameters**

- `id` (path, string) *(required)*

**Responses**

- `200` — Canceled (or best-effort race documented in §14.2). → [Analysis](#schema-analysis)
- `400` — validation_failed / url_forbidden / prompt errors (§15) → [ErrorEnvelope](#schema-errorenvelope)
- `401` — invalid_api_key / key_revoked / key_expired → [ErrorEnvelope](#schema-errorenvelope)
- `404` — resource_not_found (account-scoped; no existence oracle) → [ErrorEnvelope](#schema-errorenvelope)
- `409` — resource_conflict / idempotency_key_reused / idempotency_unavailable → [ErrorEnvelope](#schema-errorenvelope)
- `429` — rate_limit_exceeded / concurrency_limit_exceeded / quota_exceeded (always with Retry-After) → [ErrorEnvelope](#schema-errorenvelope)

## Streams

### `GET /v1/streams`

<a id="get-v1-streams"></a>**List streams (cursor pagination, created_at desc)**

**Parameters**

- `limit` (query, integer)
- `starting_after` (query, string) — Cursor — mutually exclusive with ending_before.
- `ending_before` (query, string)
- `status` (query, string)
- `created_after` (query, string (date-time))
- `created_before` (query, string (date-time))
- `status` (query, `"queued"` · `"ready"` · `"live"` · `"ended"`)

**Responses**

- `200` — List envelope (§13.4). → [StreamList](#schema-streamlist)
- `400` — validation_failed / url_forbidden / prompt errors (§15) → [ErrorEnvelope](#schema-errorenvelope)
- `401` — invalid_api_key / key_revoked / key_expired → [ErrorEnvelope](#schema-errorenvelope)
- `404` — resource_not_found (account-scoped; no existence oracle) → [ErrorEnvelope](#schema-errorenvelope)
- `409` — resource_conflict / idempotency_key_reused / idempotency_unavailable → [ErrorEnvelope](#schema-errorenvelope)
- `429` — rate_limit_exceeded / concurrency_limit_exceeded / quota_exceeded (always with Retry-After) → [ErrorEnvelope](#schema-errorenvelope)

### `POST /v1/streams`

<a id="post-v1-streams"></a>**Create a real-time analysis stream**

Returns the signaling WS URL, ephemeral ICE servers, and session limits. Signaling protocol v1: join → ready|queued(position) → offer/answer/ice → live; mid-stream update_prompt, metering (5s ticks), warning, end {reason}. WS auth: ?token= accepts a client token (pvct_) or first-party session JWT — never a secret key. Billed per second of live clock time (join/negotiation free) from the same credit ledger as uploads.

**Parameters**

- `Idempotency-Key` (header, string) — §16.1: first request wins; 24h replay window; same key + different body → 409 idempotency_key_reused.

**Request body** — [CreateStreamRequest](#schema-createstreamrequest)

**Responses**

- `201` — Stream created (queued or ready). Response carries signaling.url + ice_servers + limits. → [Stream](#schema-stream)
- `400` — validation_failed / url_forbidden / prompt errors (§15) → [ErrorEnvelope](#schema-errorenvelope)
- `401` — invalid_api_key / key_revoked / key_expired → [ErrorEnvelope](#schema-errorenvelope)
- `402` — insufficient_credits — positive balance required. → [ErrorEnvelope](#schema-errorenvelope)
- `404` — resource_not_found (account-scoped; no existence oracle) → [ErrorEnvelope](#schema-errorenvelope)
- `409` — stream_already_active — one queued|ready|live stream per account (also the un-keyed-retry guard, §31-F2) / idempotency_key_reused. → [ErrorEnvelope](#schema-errorenvelope)
- `429` — rate_limit_exceeded / concurrency_limit_exceeded / quota_exceeded (always with Retry-After) → [ErrorEnvelope](#schema-errorenvelope)
- `503` — capacity_exhausted (with Retry-After) — slot queue beyond the hard cap. → [ErrorEnvelope](#schema-errorenvelope)

### `GET /v1/streams/{id}`

<a id="get-v1-streams-id"></a>**Fetch a stream (usage + results summary once ended)**

**Parameters**

- `id` (path, string) *(required)*

**Responses**

- `200` — The stream. Terminal streams carry usage {billed_seconds, credit_balance_after} + duration_s + results_summary. → [Stream](#schema-stream)
- `400` — validation_failed / url_forbidden / prompt errors (§15) → [ErrorEnvelope](#schema-errorenvelope)
- `401` — invalid_api_key / key_revoked / key_expired → [ErrorEnvelope](#schema-errorenvelope)
- `404` — resource_not_found (account-scoped; no existence oracle) → [ErrorEnvelope](#schema-errorenvelope)
- `409` — resource_conflict / idempotency_key_reused / idempotency_unavailable → [ErrorEnvelope](#schema-errorenvelope)
- `429` — rate_limit_exceeded / concurrency_limit_exceeded / quota_exceeded (always with Retry-After) → [ErrorEnvelope](#schema-errorenvelope)

### `POST /v1/streams/{id}/end`

<a id="post-v1-streams-id-end"></a>**End a stream (idempotent)**

Graceful end: settles the credit reservation for the billed live-clock seconds (releases it entirely when the stream never went live). Ending an already-ended stream returns the terminal resource unchanged.

**Parameters**

- `id` (path, string) *(required)*

**Responses**

- `200` — The ended stream with final usage. → [Stream](#schema-stream)
- `400` — validation_failed / url_forbidden / prompt errors (§15) → [ErrorEnvelope](#schema-errorenvelope)
- `401` — invalid_api_key / key_revoked / key_expired → [ErrorEnvelope](#schema-errorenvelope)
- `404` — resource_not_found (account-scoped; no existence oracle) → [ErrorEnvelope](#schema-errorenvelope)
- `409` — resource_conflict / idempotency_key_reused / idempotency_unavailable → [ErrorEnvelope](#schema-errorenvelope)
- `429` — rate_limit_exceeded / concurrency_limit_exceeded / quota_exceeded (always with Retry-After) → [ErrorEnvelope](#schema-errorenvelope)

## Webhook endpoints

### `GET /v1/webhook_endpoints`

<a id="get-v1-webhook-endpoints"></a>**List webhook endpoints (secrets never included)**

**Responses**

- `200` — List envelope. → [WebhookEndpointList](#schema-webhookendpointlist)
- `400` — validation_failed / url_forbidden / prompt errors (§15) → [ErrorEnvelope](#schema-errorenvelope)
- `401` — invalid_api_key / key_revoked / key_expired → [ErrorEnvelope](#schema-errorenvelope)
- `404` — resource_not_found (account-scoped; no existence oracle) → [ErrorEnvelope](#schema-errorenvelope)
- `409` — resource_conflict / idempotency_key_reused / idempotency_unavailable → [ErrorEnvelope](#schema-errorenvelope)
- `429` — rate_limit_exceeded / concurrency_limit_exceeded / quota_exceeded (always with Retry-After) → [ErrorEnvelope](#schema-errorenvelope)

### `POST /v1/webhook_endpoints`

<a id="post-v1-webhook-endpoints"></a>**Create a webhook endpoint (returns the one-time whsec_ secret)**

Standard Webhooks signing (webhook-id / webhook-timestamp / webhook-signature). The secret appears ONLY in this response. Delivery contract: 10s timeout, retries 1m/5m/30m/2h/8h/24h with jitter, at-least-once, no ordering — dedupe on the evt_ id. Endpoints failing 100% for 72h are auto-disabled with an email notice. At most 10 endpoints per account.

**Parameters**

- `Idempotency-Key` (header, string) — §16.1: first request wins; 24h replay window; same key + different body → 409 idempotency_key_reused.

**Request body** — [CreateWebhookEndpointRequest](#schema-createwebhookendpointrequest)

**Responses**

- `201` — Endpoint created. `secret` is returned exactly once. → [WebhookEndpoint](#schema-webhookendpoint)
- `400` — validation_failed / webhook_endpoint_limit (10 per account). → [ErrorEnvelope](#schema-errorenvelope)
- `401` — invalid_api_key / key_revoked / key_expired → [ErrorEnvelope](#schema-errorenvelope)
- `404` — resource_not_found (account-scoped; no existence oracle) → [ErrorEnvelope](#schema-errorenvelope)
- `409` — resource_conflict / idempotency_key_reused / idempotency_unavailable → [ErrorEnvelope](#schema-errorenvelope)
- `429` — rate_limit_exceeded / concurrency_limit_exceeded / quota_exceeded (always with Retry-After) → [ErrorEnvelope](#schema-errorenvelope)

### `GET /v1/webhook_endpoints/{id}`

<a id="get-v1-webhook-endpoints-id"></a>**Fetch a webhook endpoint (secret never included)**

**Parameters**

- `id` (path, string) *(required)* — Webhook endpoint id (we_…).

**Responses**

- `200` — The endpoint. → [WebhookEndpoint](#schema-webhookendpoint)
- `400` — validation_failed / url_forbidden / prompt errors (§15) → [ErrorEnvelope](#schema-errorenvelope)
- `401` — invalid_api_key / key_revoked / key_expired → [ErrorEnvelope](#schema-errorenvelope)
- `404` — resource_not_found (account-scoped; no existence oracle) → [ErrorEnvelope](#schema-errorenvelope)
- `409` — resource_conflict / idempotency_key_reused / idempotency_unavailable → [ErrorEnvelope](#schema-errorenvelope)
- `429` — rate_limit_exceeded / concurrency_limit_exceeded / quota_exceeded (always with Retry-After) → [ErrorEnvelope](#schema-errorenvelope)

### `DELETE /v1/webhook_endpoints/{id}`

<a id="delete-v1-webhook-endpoints-id"></a>**Delete a webhook endpoint (effective immediately, §17)**

**Parameters**

- `id` (path, string) *(required)* — Webhook endpoint id (we_…).

**Responses**

- `200` — {id, object, deleted: true}.
- `400` — validation_failed / url_forbidden / prompt errors (§15) → [ErrorEnvelope](#schema-errorenvelope)
- `401` — invalid_api_key / key_revoked / key_expired → [ErrorEnvelope](#schema-errorenvelope)
- `404` — resource_not_found (account-scoped; no existence oracle) → [ErrorEnvelope](#schema-errorenvelope)
- `409` — resource_conflict / idempotency_key_reused / idempotency_unavailable → [ErrorEnvelope](#schema-errorenvelope)
- `429` — rate_limit_exceeded / concurrency_limit_exceeded / quota_exceeded (always with Retry-After) → [ErrorEnvelope](#schema-errorenvelope)

### `GET /v1/webhook_endpoints/{id}/deliveries`

<a id="get-v1-webhook-endpoints-id-deliveries"></a>**List recent deliveries (last 100, with response codes)**

Dead-lettered deliveries stay visible for 30 days. Deliveries carry the evt_ id (stable across retries), attempt count, last response status/error, and next retry time.

**Parameters**

- `id` (path, string) *(required)* — Webhook endpoint id (we_…).

**Responses**

- `200` — List envelope (newest first). → [WebhookDeliveryList](#schema-webhookdeliverylist)
- `400` — validation_failed / url_forbidden / prompt errors (§15) → [ErrorEnvelope](#schema-errorenvelope)
- `401` — invalid_api_key / key_revoked / key_expired → [ErrorEnvelope](#schema-errorenvelope)
- `404` — resource_not_found (account-scoped; no existence oracle) → [ErrorEnvelope](#schema-errorenvelope)
- `409` — resource_conflict / idempotency_key_reused / idempotency_unavailable → [ErrorEnvelope](#schema-errorenvelope)
- `429` — rate_limit_exceeded / concurrency_limit_exceeded / quota_exceeded (always with Retry-After) → [ErrorEnvelope](#schema-errorenvelope)

### `POST /v1/webhook_endpoints/{id}/deliveries/{delivery_id}/redeliver`

<a id="post-v1-webhook-endpoints-id-deliveries-delivery-id-redeliver"></a>**Redeliver a delivery now (works on failed/dead rows)**

**Parameters**

- `id` (path, string) *(required)* — Webhook endpoint id (we_…).
- `delivery_id` (path, string) *(required)* — Delivery id (whd_…).

**Responses**

- `202` — Requeued for immediate delivery. → [WebhookDelivery](#schema-webhookdelivery)
- `400` — validation_failed / url_forbidden / prompt errors (§15) → [ErrorEnvelope](#schema-errorenvelope)
- `401` — invalid_api_key / key_revoked / key_expired → [ErrorEnvelope](#schema-errorenvelope)
- `404` — resource_not_found (account-scoped; no existence oracle) → [ErrorEnvelope](#schema-errorenvelope)
- `409` — resource_conflict / idempotency_key_reused / idempotency_unavailable → [ErrorEnvelope](#schema-errorenvelope)
- `429` — rate_limit_exceeded / concurrency_limit_exceeded / quota_exceeded (always with Retry-After) → [ErrorEnvelope](#schema-errorenvelope)

### `POST /v1/webhook_endpoints/{id}/enable`

<a id="post-v1-webhook-endpoints-id-enable"></a>**Re-enable a disabled endpoint (clears auto-disable state)**

**Parameters**

- `id` (path, string) *(required)* — Webhook endpoint id (we_…).

**Responses**

- `200` — The re-enabled endpoint. → [WebhookEndpoint](#schema-webhookendpoint)
- `400` — validation_failed / url_forbidden / prompt errors (§15) → [ErrorEnvelope](#schema-errorenvelope)
- `401` — invalid_api_key / key_revoked / key_expired → [ErrorEnvelope](#schema-errorenvelope)
- `404` — resource_not_found (account-scoped; no existence oracle) → [ErrorEnvelope](#schema-errorenvelope)
- `409` — resource_conflict / idempotency_key_reused / idempotency_unavailable → [ErrorEnvelope](#schema-errorenvelope)
- `429` — rate_limit_exceeded / concurrency_limit_exceeded / quota_exceeded (always with Retry-After) → [ErrorEnvelope](#schema-errorenvelope)

### `POST /v1/webhook_endpoints/{id}/rotate_secret`

<a id="post-v1-webhook-endpoints-id-rotate-secret"></a>**Rotate the signing secret (24h dual-secret overlap)**

Returns the NEW secret exactly once. The previous secret keeps signing deliveries for 24h so receivers can migrate without dropped verifications — the signature header carries one signature per active secret.

**Parameters**

- `id` (path, string) *(required)* — Webhook endpoint id (we_…).

**Responses**

- `200` — Endpoint with the new one-time `secret`. → [WebhookEndpoint](#schema-webhookendpoint)
- `400` — validation_failed / url_forbidden / prompt errors (§15) → [ErrorEnvelope](#schema-errorenvelope)
- `401` — invalid_api_key / key_revoked / key_expired → [ErrorEnvelope](#schema-errorenvelope)
- `404` — resource_not_found (account-scoped; no existence oracle) → [ErrorEnvelope](#schema-errorenvelope)
- `409` — resource_conflict / idempotency_key_reused / idempotency_unavailable → [ErrorEnvelope](#schema-errorenvelope)
- `429` — rate_limit_exceeded / concurrency_limit_exceeded / quota_exceeded (always with Retry-After) → [ErrorEnvelope](#schema-errorenvelope)

## Client tokens

### `POST /v1/client_tokens`

<a id="post-v1-client-tokens"></a>**Mint an ephemeral browser-safe client token (pvct_)**

Server-side token exchange (§4.9): authenticate with a secret key (or first-party session), receive a short-lived scoped pvct_ bearer for the browser. The browser then calls Primate directly — your secret key never leaves your server. Tokens are revoked implicitly when the minting key is revoked (≤5s). Rate class: 120/min per key. Client tokens can never mint tokens.

**Request body** — [CreateClientTokenRequest](#schema-createclienttokenrequest)

**Responses**

- `201` — Token minted. The raw token appears only in this response. → [ClientToken](#schema-clienttoken)
- `400` — validation_failed — scopes/ttl_s/binding violations, all listed at once. → [ErrorEnvelope](#schema-errorenvelope)
- `403` — insufficient_scope — requested scopes exceed the minting key’s, or the caller is itself a client token. → [ErrorEnvelope](#schema-errorenvelope)
- `404` — resource_not_found — video_id binding does not resolve to an owned video. → [ErrorEnvelope](#schema-errorenvelope)
- `429` — rate_limit_exceeded — client_tokens.create is 120/min per key. → [ErrorEnvelope](#schema-errorenvelope)

## Billing

### `GET /v1/credits`

<a id="get-v1-credits"></a>**Credit balance and transaction history**

Agent-facing billing truth (PRI-480 A-5): remaining balance in billable seconds plus a paginated ledger. Check this before submitting work to avoid a 402. Requires the billing:read scope.

**Parameters**

- `limit` (query, integer)
- `before` (query, string)

**Responses**

- `200` — Balance plus one page of ledger entries. → [CreditsResponse](#schema-creditsresponse)
- `400` — validation_failed / url_forbidden / prompt errors (§15) → [ErrorEnvelope](#schema-errorenvelope)
- `401` — invalid_api_key / key_revoked / key_expired → [ErrorEnvelope](#schema-errorenvelope)
- `404` — resource_not_found (account-scoped; no existence oracle) → [ErrorEnvelope](#schema-errorenvelope)
- `409` — resource_conflict / idempotency_key_reused / idempotency_unavailable → [ErrorEnvelope](#schema-errorenvelope)
- `429` — rate_limit_exceeded / concurrency_limit_exceeded / quota_exceeded (always with Retry-After) → [ErrorEnvelope](#schema-errorenvelope)

## Usage

### `GET /v1/usage`

<a id="get-v1-usage"></a>**Usage meters (credit balance, period consumption)**

**Responses**

- `200` — Meters + legacy fields. Same numbers as the dashboard (shared service, reconciles with the credit ledger to the second). → [UsageResponse](#schema-usageresponse)
- `400` — validation_failed / url_forbidden / prompt errors (§15) → [ErrorEnvelope](#schema-errorenvelope)
- `401` — invalid_api_key / key_revoked / key_expired → [ErrorEnvelope](#schema-errorenvelope)
- `404` — resource_not_found (account-scoped; no existence oracle) → [ErrorEnvelope](#schema-errorenvelope)
- `409` — resource_conflict / idempotency_key_reused / idempotency_unavailable → [ErrorEnvelope](#schema-errorenvelope)
- `429` — rate_limit_exceeded / concurrency_limit_exceeded / quota_exceeded (always with Retry-After) → [ErrorEnvelope](#schema-errorenvelope)

## Meta & platform

### `GET /v1/credit-pricing`

<a id="get-v1-credit-pricing"></a>**Current credit pricing (public, no auth)**

**Responses**

- `200` — Pricing config. Billing unit is source-clock video-seconds; analysis cost = usage.billed_seconds × price_per_second_cents. → [CreditPricing](#schema-creditpricing)

### `GET /v1/errors`

<a id="get-v1-errors"></a>**Machine-readable error-code registry (§15)**

**Responses**

- `200` — Closed registry — codes are append-only; SDK retry policy generates from retryable/idem flags. → object

### `GET /v1/models`

<a id="get-v1-models"></a>**List available models**

**Responses**

- `200` — Model catalog. Requests may say model:"latest"; responses always name the concrete version. → object

### `POST /v1/parse`

<a id="post-v1-parse"></a>**Compile a free-text prompt into a CompiledQuery (no analysis created)**

Stateless prompt compiler. Auth optional (anonymous allowed); rate-limited to 30 req/min per IP. Never touches the GPU and never burns credits — use POST /v1/analyses with validate_only:true when you also want cost/assessability estimates.

**Request body** — [ParseRequest](#schema-parserequest)

**Responses**

- `200` — The compiled query and which parser produced it. → [ParseResponse](#schema-parseresponse)
- `400` — empty_prompt — prompt missing, not a string, or blank. → [ErrorEnvelope](#schema-errorenvelope)
- `422` — parse_failed — prompt could not be compiled into a query. → [ErrorEnvelope](#schema-errorenvelope)
- `429` — rate_limit_exceeded (30 req/min per IP). → [ErrorEnvelope](#schema-errorenvelope)
- `503` — parse_unavailable — parse service temporarily unavailable. → [ErrorEnvelope](#schema-errorenvelope)

## Object schemas

Every named object in the contract. Field types link to their schema.

### Analysis

<a id="schema-analysis"></a>A video analysis (§13.2).

- `artifacts` — [Artifacts](#schema-artifacts) *(required)* — Signed URL TTL 1h; re-fetch GET analysis for a fresh URL.
- `completed_at` — string,null (date-time) *(required)*
- `created_at` — string (date-time) *(required)*
- `error` — [ErrorObject](#schema-errorobject) *(required)*
- `id` — string *(required)* — Analysis resource id (prefixed ULID).
- `livemode` — boolean *(required)* — true when the request used a live key; false for test keys and sandbox. Stripe-style.
- `metadata` — object — Free-form key/value metadata. ≤16 keys, key ≤40 chars [a-zA-Z0-9_.-], value ≤500 chars.
- `model` — string *(required)*
- `narrative` — [Narrative](#schema-narrative) *(required)*
- `object` — `"analysis"` *(required)*
- `options` — object *(required)*
- `origin` — `"api"` · `"console"` · `"system"` *(required)* — `api` — submitted via the public API; `console` — uploaded via the dashboard; `system` — generated internally (e.g. auto-probe, fixture seeding, run_prompt). System analyses are never billed. Agents should normally only process api/console-origin analyses. (Renamed 2026-07-27: former value `user` is now `api`.)
- `parse_mode` — `"llm"` · `"heuristic"` · `"client"` *(required)*
- `progress` — [Progress](#schema-progress) *(required)* — Non-terminal only. percent is monotonic non-decreasing per analysis.
- `prompt` — string,null *(required)*
- `query` — object *(required)* — CompiledQuery (schema at /v1/schemas/compiled-query.json). Always populated in responses.
- `queue_position` — integer,null *(required)*
- `result` — [Result](#schema-result) *(required)* — Analysis result — the product (§13.3). Reserved (always absent in v1): masks, bounding_boxes, events.
- `started_at` — string,null (date-time) *(required)*
- `status` — [AnalysisStatus](#schema-analysisstatus) *(required)* — Analysis lifecycle state (§14.2). Clients MUST tolerate unknown future non-terminal values.
- `usage` — [AnalysisUsage](#schema-analysisusage) *(required)* — Terminal only. fps-independent clock seconds (pricing doc). credit_balance_after is the balance immediately after THIS analysis settled — an immutable point-in-time snapshot; it does not change as later analyses run. Null when the account row is unavailable (e.g. free-grant exhausted before settlement).
- `video_id` — string *(required)* — Video resource id (prefixed ULID). Immutable.

### AnalysisBatch

<a id="schema-analysisbatch"></a>- `analyses` — array<[Analysis](#schema-analysis)> *(required)*
- `id` — string *(required)* — Stable batch identifier.
- `object` — `"analysis_batch"` *(required)*
- `pricing` — [AnalysisBatchPricing](#schema-analysisbatchpricing) *(required)*
- `video_id` — string *(required)* — Video resource id (prefixed ULID). Immutable.

### AnalysisBatchPreview

<a id="schema-analysisbatchpreview"></a>- `object` — `"analysis_batch_preview"` *(required)*
- `pricing` — [AnalysisBatchPricing](#schema-analysisbatchpricing) *(required)*
- `prompts` — array<[AnalysisBatchPreviewPrompt](#schema-analysisbatchpreviewprompt)> *(required)*
- `video_id` — string *(required)* — Video resource id (prefixed ULID). Immutable.

### AnalysisBatchPreviewPrompt

<a id="schema-analysisbatchpreviewprompt"></a>- `assessable` — boolean *(required)* — True when the query type can produce a yes/no/count answer.
- `discount_pct` — `0` | `50` *(required)* — 0 for the first (full-price) prompt; 50 for additional prompts.
- `estimated_cost_usd` — number,null *(required)* — Estimated cost in USD. Null when video has no known duration.
- `estimated_seconds` — integer,null *(required)* — Estimated credit-seconds for this prompt. Null when video has no known duration.
- `index` — integer *(required)* — Zero-based prompt index.
- `parse_mode` — `"heuristic"` · `"llm"` · `"client"` *(required)*
- `query` — object *(required)* — Compiled query structure.

### AnalysisBatchPricing

<a id="schema-analysisbatchpricing"></a>- `discount_pct` — integer *(required)*
- `discounted_prompts` — integer *(required)*
- `estimated_total_cost_usd` — number,null *(required)* — Estimated total cost in USD. Null when duration unknown.
- `estimated_total_seconds` — integer,null *(required)* — Estimated total credit-seconds for all prompts combined (full + discounted). Null when the video has no known duration.
- `full_price_prompts` — integer *(required)*

### AnalysisList

<a id="schema-analysislist"></a>- `data` — array<[Analysis](#schema-analysis)> *(required)*
- `has_more` — boolean *(required)*
- `object` — `"list"` *(required)*

### AnalysisPreview

<a id="schema-analysispreview"></a>- `assessable` — boolean *(required)* — False for open_ended queries — the model cannot score them, so detections will be empty.
- `estimated_cost_usd` — number,null *(required)* — Estimated cost at current pricing. Null when duration is unknown.
- `estimated_seconds` — integer,null *(required)* — Billable seconds this analysis would consume. Null when duration is unknown.
- `object` — `"analysis_preview"` *(required)*
- `parse_mode` — `"llm"` · `"heuristic"` · `"client"` *(required)* — How the prompt was compiled. "client" means you supplied query directly.
- `query` — object *(required)* — The compiled query the analysis would run.
- `video_duration_s` — number,null *(required)* — Null when duration is not yet known (probe pending).

### AnalysisStatus

<a id="schema-analysisstatus"></a>Analysis lifecycle state (§14.2). Clients MUST tolerate unknown future non-terminal values.

Enum: `"queued"` · `"preparing"` · `"analyzing"` · `"rendering"` · `"completed"` · `"failed"` · `"canceled"`

### AnalysisUsage

<a id="schema-analysisusage"></a>Terminal only. fps-independent clock seconds (pricing doc). credit_balance_after is the balance immediately after THIS analysis settled — an immutable point-in-time snapshot; it does not change as later analyses run. Null when the account row is unavailable (e.g. free-grant exhausted before settlement).

- `billed_seconds` — integer *(required)*
- `credit_balance_after` — integer,null *(required)*

### Artifacts

<a id="schema-artifacts"></a>Signed URL TTL 1h; re-fetch GET analysis for a fresh URL.

- `annotated_video_url` — string (uri) *(required)*
- `expires_at` — string (date-time) *(required)*

### ClientToken

<a id="schema-clienttoken"></a>- `expires_at` — string *(required)*
- `id` — string *(required)*
- `object` — `"client_token"` *(required)*
- `scopes` — array<string> *(required)*
- `stream_id` — string
- `token` — string *(required)* — 256-bit CSPRNG bearer, SHA-256 at rest — shown exactly once. Browser-safe: accepted on product-plane endpoints (CORS *).
- `video_id` — string — Video resource id (prefixed ULID). Immutable.

### Clip

<a id="schema-clip"></a>- `confidence` — number *(required)*
- `end_s` — number *(required)*
- `start_s` — number *(required)*
- `terms` — object *(required)*

### CreateAnalysisBatchRequest

<a id="schema-createanalysisbatchrequest"></a>- `metadata` — object — Free-form key/value metadata. ≤16 keys, key ≤40 chars [a-zA-Z0-9_.-], value ≤500 chars.
- `model` — string
- `options` — object
- `prompts` — array<string> *(required)* — 2–10 prompts to run against the video. First prompt is full price; each additional is 50%.
- `validate_only` — boolean — Dry-run mode. When true, parses all prompts and returns an analysis_batch_preview (HTTP 200) with per-prompt cost estimates. No credits are reserved and no jobs are created.
- `video_id` — string *(required)* — Video resource id (prefixed ULID). Immutable.

### CreateAnalysisRequest

<a id="schema-createanalysisrequest"></a>- `metadata` — object — Free-form key/value metadata. ≤16 keys, key ≤40 chars [a-zA-Z0-9_.-], value ≤500 chars.
- `model` — string — Defaults to latest; response always echoes the concrete version.
- `options` — object
- `prompt` — string — Free text — compiled server-side. Exactly one of prompt/query.
- `query` — object — Structured CompiledQuery JSON (schema at /v1/schemas/compiled-query.json).
- `validate_only` — boolean — Dry run (PRI-480). Compiles the prompt, reports whether the query is assessable, and estimates duration/cost — WITHOUT creating an analysis, touching the GPU, or burning credits. Returns an AnalysisPreview (object: "analysis_preview") with 200, not an Analysis. Use it to check a prompt before paying for it.
- `video_id` — string *(required)* — Video resource id (prefixed ULID). Immutable.
- `webhook` — object — Per-request webhook override (delivery ships P7). Write-only — never echoed.

### CreateClientTokenRequest

<a id="schema-createclienttokenrequest"></a>- `scopes` — array<`"videos:write"` · `"analyses:read"` · `"analyses:write"` · `"streams:create"` · `"streams:signal"`> *(required)* — Non-empty subset of the §4.9 scope set. Must also be a subset of the minting key’s scopes.
- `stream_id` — string — Optional resource binding to one stream (WS acceptance ships P6).
- `ttl_s` — integer — Token lifetime in seconds (60–900, default 900). Tokens are never refreshable — mint a new one.
- `video_id` — string — Optional resource binding: narrows the token to this one video. Mutually exclusive with stream_id.

### CreateStreamRequest

<a id="schema-createstreamrequest"></a>Requires positive credit balance. One queued|ready|live stream per account by default (409 stream_already_active otherwise).

- `metadata` — object — Free-form key/value metadata. ≤16 keys, key ≤40 chars [a-zA-Z0-9_.-], value ≤500 chars.
- `model` — string
- `prompt` — string — Free text — compiled server-side. Exactly one of prompt/query.
- `query` — object — Structured CompiledQuery JSON.
- `webhook` — object — Per-request webhook override (delivery ships P7). Write-only.

### CreateVideoRequest

<a id="schema-createvideorequest"></a>Exactly one mode: presigned ({filename?, content_type, size_bytes?}) or ingest ({url}). Unknown fields are rejected.

- `content_type` — `"video/mp4"` · `"video/quicktime"` — Required in presigned mode.
- `filename` — string
- `metadata` — object — Free-form key/value metadata. ≤16 keys, key ≤40 chars [a-zA-Z0-9_.-], value ≤500 chars.
- `size_bytes` — integer
- `url` — string (uri) — URL-ingest mode: https-only, SSRF-guarded (§18.2). Mutually exclusive with filename/size_bytes.

### CreateWebhookEndpointRequest

<a id="schema-createwebhookendpointrequest"></a>- `description` — string
- `events` — array<[WebhookEventType](#schema-webhookeventtype)> *(required)* — Subscribed event types — a non-empty subset of the closed v1 vocabulary.
- `url` — string (uri) *(required)* — HTTPS delivery URL.

### CreditPricing

<a id="schema-creditpricing"></a>Current pricing configuration. Data-driven — a price change is a config update, not a schema change.

- `allowed_purchase_cents` — array<integer> *(required)* — Preset purchase amounts, in cents.
- `auto_refill_threshold_seconds` — integer *(required)* — Balance below which auto-refill (if enabled) triggers.
- `card_grant_seconds` — integer *(required)* — Bonus credit-seconds granted when a card is added.
- `custom_purchase_enabled` — boolean *(required)*
- `min_purchase_cents` — integer *(required)*
- `price_per_second_cents` — number *(required)* — Price per billed video-second, in cents. Cost of an analysis = usage.billed_seconds × this value.
- `signup_grant_seconds` — integer *(required)* — Free credit-seconds granted on signup / free-grant upgrade.

### CreditTransaction

<a id="schema-credittransaction"></a>- `balance_after_seconds` — number *(required)*
- `created_at` — string *(required)*
- `id` — string *(required)*
- `kind` — string *(required)* — e.g. signup_grant, analysis_debit, stream_debit, purchase.
- `seconds_delta` — number *(required)* — Signed: negative debits, positive credits.
- `source_id` — string,null *(required)*
- `source_type` — string,null *(required)*

### CreditsResponse

<a id="schema-creditsresponse"></a>- `balance_seconds` — number *(required)* — Seconds of analysis remaining. The authoritative balance.
- `grant_seconds` — number,null *(required)* — Original signup grant, when one exists.
- `has_more` — boolean *(required)*
- `object` — `"credits"` *(required)*
- `transactions` — array<[CreditTransaction](#schema-credittransaction)> *(required)*
- `used_seconds` — number,null *(required)* — Consumed against the signup grant (grant minus balance).

### DeletedVideo

<a id="schema-deletedvideo"></a>- `deleted` — `true` *(required)*
- `id` — string *(required)* — Video resource id (prefixed ULID). Immutable.
- `object` — `"video"` *(required)*

### DeviceCodeRequest

<a id="schema-devicecoderequest"></a>- `claim_url` — string (uri) *(required)* — Open in browser for the user to approve.
- `device_code` — string *(required)* — Opaque code; use as path param when polling.
- `expires_at` — string (date-time) *(required)*
- `instructions` — string
- `object` — `"device_code_request"` *(required)*
- `poll_interval` — integer *(required)* — Seconds between polls.

### DeviceCodeStatus

<a id="schema-devicecodestatus"></a>- `object` — `"device_code_request"` *(required)*
- `status` — `"pending"` · `"consumed"` *(required)*

### ErrorEnvelope

<a id="schema-errorenvelope"></a>- `error` — object *(required)*

### ErrorObject

<a id="schema-errorobject"></a>- `code` — string *(required)*
- `message` — string *(required)*

### ErrorRegistryEntry

<a id="schema-errorregistryentry"></a>- `code` — string *(required)*
- `docs_url` — string (uri) *(required)*
- `idem` — boolean *(required)*
- `kind` — `"http"` · `"resource"` *(required)*
- `message` — string *(required)*
- `retryable` — boolean *(required)*
- `status` — integer,null *(required)*

### Meter

<a id="schema-meter"></a>One named usage meter (§4.6). The list is data-driven — pricing changes add/change rows, never the schema.

- `balance` — number — Balance-shaped meters (e.g. credit_seconds).
- `limit` — number,null — null = unlimited/PAYG.
- `meter` — string *(required)*
- `resets_at` — string (date-time) — Omitted for non-period meters.
- `unit` — string *(required)*
- `used` — number — Period-shaped meters.

### Model

<a id="schema-model"></a>- `capabilities` — object *(required)*
- `created_at` — string *(required)*
- `default` — boolean *(required)*
- `id` — string *(required)*
- `object` — `"model"` *(required)*
- `status` — `"stable"` · `"preview"` · `"deprecated"` *(required)*
- `sunset_at` — string,null *(required)*

### Narrative

<a id="schema-narrative"></a>- `entries` — array<object> *(required)*
- `status` — `"generating"` · `"ready"` · `"failed"` *(required)*

### ParseRequest

<a id="schema-parserequest"></a>- `prompt` — string *(required)* — Free-text video question. Must be a non-empty string.

### ParseResponse

<a id="schema-parseresponse"></a>- `compiled_query` — object *(required)* — CompiledQuery — canonical JSON Schema served at /v1/schemas/compiled-query.json. Same structure accepted by POST /v1/analyses `query`.
- `parse_mode` — `"haiku"` · `"heuristic"` *(required)* — How the prompt was compiled: "haiku" = LLM parser; "heuristic" = deterministic fallback (used when the LLM is unavailable).

### Progress

<a id="schema-progress"></a>Non-terminal only. percent is monotonic non-decreasing per analysis.

- `percent` — number *(required)*
- `stage` — string *(required)*

### Result

<a id="schema-result"></a>Analysis result — the product (§13.3). Reserved (always absent in v1): masks, bounding_boxes, events.

- `answer` — `"yes"` · `"no"` · `"indeterminate"` *(required)*
- `clips` — array<[Clip](#schema-clip)> *(required)*
- `confidence` — number *(required)*
- `detected_count` — integer,null
- `indeterminate_reason` — `"low_confidence"` · `"nothing_detected"` · `"unsupported_query_form"` · `"duration_mismatch"`
- `query_type` — `"object"` · `"action"` · `"compound"` · `"attribute"` · `"open_ended"` *(required)*
- `term_confidences` — object *(required)*
- `video_duration_s` — number,null *(required)*

### SandboxKey

<a id="schema-sandboxkey"></a>- `api_key` — string *(required)* — Shown exactly once — store it now. Fixture-only test mode: no GPU, no credits.
- `docs_url` — string *(required)*
- `expected_answer` — string *(required)* — Deterministic canned answer for the fixture prompt — assert this in CI.
- `expires_at` — string *(required)* — Keys expire 7 days after issuance.
- `fixture_prompt` — string *(required)*
- `fixture_video_id` — string,null *(required)* — Pre-seeded ready video for immediate POST /v1/analyses.
- `mode` — `"test"` *(required)*
- `note` — string *(required)*
- `object` — `"sandbox_key"` *(required)*

### Stream

<a id="schema-stream"></a>A real-time analysis stream (§4.8).

- `created_at` — string (date-time) *(required)*
- `duration_s` — integer,null *(required)* — Live-clock duration. Terminal only.
- `end_reason` — [StreamEndReason](#schema-streamendreason) *(required)* — Why the stream ended. completed — normal end after live. canceled — ended before going live (client action). insufficient_credits — balance exhausted. error — server-side failure. timeout — max_session_s cutoff. ice_failed — WebRTC ICE never connected (see failure_diagnostic). media_timeout — connected but no media/results within the first-frame window. A stream that never went live is never `completed`.
- `ended_at` — string,null (date-time) *(required)*
- `estimated_start_s` — integer,null *(required)*
- `failure_diagnostic` — object,null *(required)* — Present when end_reason is ice_failed (and some media_timeout cases): ICE candidate summary from the server side — {local_candidates: string[], hint}. Use it to distinguish "server advertised only private/host candidates" from "client could not reach relay".
- `ice_servers` — array,null *(required)*
- `id` — string *(required)* — Stream resource id (prefixed ULID). P6 §4.8.
- `limits` — [StreamLimits](#schema-streamlimits) *(required)*
- `live_started_at` — string,null (date-time) *(required)*
- `metadata` — object — Free-form key/value metadata. ≤16 keys, key ≤40 chars [a-zA-Z0-9_.-], value ≤500 chars.
- `model` — string *(required)*
- `object` — `"stream"` *(required)*
- `prompt` — string,null *(required)*
- `query` — object,null *(required)*
- `queue_position` — integer,null *(required)*
- `results_summary` — object,null *(required)* — Terminal summary: {result_frames, frames (deprecated alias of result_frames — removed ~2026-08-28), last_detections}. result_frames counts result EVENTS emitted (results are sampled, not per-source-frame). Detection rows use the same enums/casing as the file-API result contract (answer: yes|no|indeterminate, lowercase; prompt echoed exactly as submitted).
- `signaling` — [StreamSignaling](#schema-streamsignaling) *(required)* — Present while the stream is joinable; null once ended.
- `status` — [StreamStatus](#schema-streamstatus) *(required)* — Stream lifecycle state (§4.8): queued → ready → live → ended. Terminal immutable.
- `usage` — [StreamUsage](#schema-streamusage) *(required)* — Terminal only. Same ledger as uploads — one balance.

### StreamEndReason

<a id="schema-streamendreason"></a>Why the stream ended. completed — normal end after live. canceled — ended before going live (client action). insufficient_credits — balance exhausted. error — server-side failure. timeout — max_session_s cutoff. ice_failed — WebRTC ICE never connected (see failure_diagnostic). media_timeout — connected but no media/results within the first-frame window. A stream that never went live is never `completed`.

Enum: `"completed"` · `"canceled"` · `"insufficient_credits"` · `"error"` · `"timeout"` · `"ice_failed"` · `"media_timeout"`

### StreamLimits

<a id="schema-streamlimits"></a>- `max_session_s` — integer *(required)* — Plan entitlement (max_session_s) — hard session cutoff.
- `warn_at_remaining_s` — integer *(required)* — The `warning {remaining_s}` threshold surfaced mid-stream.

### StreamList

<a id="schema-streamlist"></a>- `data` — array<[Stream](#schema-stream)> *(required)*
- `has_more` — boolean *(required)*
- `object` — `"list"` *(required)*

### StreamSignaling

<a id="schema-streamsignaling"></a>Present while the stream is joinable; null once ended.

- `expires_at` — string (date-time) *(required)*
- `url` — string *(required)* — Versioned signaling WS. Auth: ?token= accepts a client token (pvct_) or first-party Clerk JWT — NEVER a secret key.

### StreamStatus

<a id="schema-streamstatus"></a>Stream lifecycle state (§4.8): queued → ready → live → ended. Terminal immutable.

Enum: `"queued"` · `"ready"` · `"live"` · `"ended"`

### StreamUsage

<a id="schema-streamusage"></a>Terminal only. Same ledger as uploads — one balance.

- `billed_seconds` — integer *(required)* — LIVE-clock seconds (join/negotiation free). fps-independent by construction.
- `credit_balance_after` — integer,null *(required)*

### UpgradeKeyResponse

<a id="schema-upgradekeyresponse"></a>- `api_key` — string *(required)* — The raw `pv_live_` key — store securely, shown only once.
- `claim_flow` — object
- `expires_at` — string (date-time) *(required)*
- `grant_seconds` — integer *(required)*
- `livemode` — `true` *(required)*
- `note` — string
- `object` — `"api_key"` *(required)*
- `tier` — `"free_grant"` *(required)*

### UsageResponse

<a id="schema-usageresponse"></a>Usage meters + legacy dashboard fields (the flat fields are deprecated in favor of meters[]; they remain during the web-client migration).

- `meters` — array<[Meter](#schema-meter)> *(required)* — Data-driven meter list (§4.6 invariant #1). At launch: credit_seconds (balance) + seconds_processed.period (used/resets_at). Pricing changes add/change rows — never the schema. quota_exceeded errors carry details.meter naming which meter tripped.

### Video

<a id="schema-video"></a>A source video (§13.1).

- `content_type` — `"video/mp4"` · `"video/quicktime"` *(required)*
- `created_at` — string (date-time) *(required)*
- `duration_s` — number,null *(required)*
- `error` — [ErrorObject](#schema-errorobject) *(required)*
- `expires_at` — string,null (date-time) *(required)*
- `filename` — string,null *(required)*
- `fps` — number,null *(required)*
- `height` — integer,null *(required)*
- `id` — string *(required)* — Video resource id (prefixed ULID). Immutable.
- `livemode` — boolean *(required)* — true when the request used a live key; false for test keys and sandbox. Stripe-style.
- `metadata` — object — Free-form key/value metadata. ≤16 keys, key ≤40 chars [a-zA-Z0-9_.-], value ≤500 chars.
- `object` — `"video"` *(required)*
- `size_bytes` — integer,null *(required)*
- `source` — `"upload"` · `"url"` *(required)*
- `status` — [VideoStatus](#schema-videostatus) *(required)* — Video lifecycle state (§14.1).
- `upload` — [VideoUpload](#schema-videoupload) *(required)* — Presigned upload instructions. Present only while awaiting_upload. URL TTL 1h.
- `width` — integer,null *(required)*

### VideoList

<a id="schema-videolist"></a>- `data` — array<[Video](#schema-video)> *(required)*
- `has_more` — boolean *(required)*
- `object` — `"list"` *(required)*

### VideoStatus

<a id="schema-videostatus"></a>Video lifecycle state (§14.1).

Enum: `"awaiting_upload"` · `"processing"` · `"ready"` · `"failed"`

### VideoUpload

<a id="schema-videoupload"></a>Presigned upload instructions. Present only while awaiting_upload. URL TTL 1h.

- `expires_at` — string (date-time) *(required)*
- `headers` — object *(required)*
- `method` — `"PUT"` *(required)*
- `url` — string (uri) *(required)*

### WebhookDelivery

<a id="schema-webhookdelivery"></a>One event→endpoint delivery record (§16.3). Dead-lettered rows visible 30 days.

- `attempt_count` — integer *(required)*
- `created_at` — string (date-time) *(required)*
- `delivered_at` — string,null (date-time) *(required)*
- `event_id` — string *(required)* — Stable across retries — consumers dedupe on this.
- `event_type` — [WebhookEventType](#schema-webhookeventtype) *(required)* — Closed v1 event vocabulary (§4.5). Payload = the full resource DTO.
- `id` — string *(required)* — Webhook delivery id (prefixed ULID).
- `last_attempt_at` — string,null (date-time) *(required)*
- `last_error` — string,null *(required)*
- `last_response_status` — integer,null *(required)*
- `next_attempt_at` — string,null (date-time) *(required)* — Null once delivered or dead-lettered.
- `object` — `"webhook_delivery"` *(required)*
- `status` — `"pending"` · `"delivering"` · `"delivered"` · `"failed"` · `"dead"` *(required)*

### WebhookDeliveryList

<a id="schema-webhookdeliverylist"></a>- `data` — array<[WebhookDelivery](#schema-webhookdelivery)> *(required)*
- `has_more` — boolean *(required)*
- `object` — `"list"` *(required)*

### WebhookEndpoint

<a id="schema-webhookendpoint"></a>A customer webhook endpoint (§4.5). Standard Webhooks signing.

- `created_at` — string (date-time) *(required)*
- `description` — string,null *(required)*
- `disabled_reason` — `"auto_failure"` · `"user"` *(required)* — auto_failure = 72h of 100% delivery failure (§16.3). Re-enable via POST …/enable.
- `events` — array<[WebhookEventType](#schema-webhookeventtype)> *(required)*
- `id` — string *(required)* — Webhook endpoint id (prefixed ULID).
- `last_success_at` — string,null (date-time) *(required)*
- `object` — `"webhook_endpoint"` *(required)*
- `secret` — string — Signing secret — returned EXACTLY ONCE (create / rotate_secret), never readable again.
- `status` — `"enabled"` · `"disabled"` *(required)*
- `url` — string (uri) *(required)*

### WebhookEndpointList

<a id="schema-webhookendpointlist"></a>- `data` — array<[WebhookEndpoint](#schema-webhookendpoint)> *(required)*
- `has_more` — boolean *(required)*
- `object` — `"list"` *(required)*

### WebhookEventType

<a id="schema-webhookeventtype"></a>Closed v1 event vocabulary (§4.5). Payload = the full resource DTO.

Enum: `"video.ready"` · `"video.failed"` · `"analysis.completed"` · `"analysis.failed"` · `"analysis.canceled"` · `"stream.ended"`

