Concepts
The building blocks shared by every endpoint and SDK.
Authentication
Send your key on every request as the X-API-Key header (or Authorization: Bearer nv_… — both work):
curl https://api.niravi.io/api/v1/videos -H "X-API-Key: nv_..."
Keys are created and revoked in the dashboard → Settings → Developer. They are stored hashed; the plaintext is shown only once at creation.
Workspaces & scopes
Each key has an explicit workspace access list — its home workspace (the default) plus any workspaces you grant it (your other workspaces and/or the read-only demos). You set this when you create the key and edit it anytime under Settings → API access. A key can only ever reach workspaces on its list, and only ones you yourself can access.
Target one workspace — send the X-Workspace-Id header (the SDKs take a workspace option).
Omit it to use the home workspace.
curl https://api.niravi.io/api/v1/search \
-H "X-API-Key: nv_..." -H "X-Workspace-Id: demo_newsroom" \
-H "Content-Type: application/json" -d '{"query":"a reporter speaking to the camera"}'
import requests # the Python SDK is not on PyPI yet: plain HTTP works
BASE = "https://api.niravi.io/api/v1"
H = {"X-API-Key": "nv_...", "X-Workspace-Id": "demo_newsroom"}
r = requests.post(f"{BASE}/search", headers=H, json={"query": "a reporter speaking to the camera"})
hits = r.json()["data"]["results"]
const niravi = connect("nv_...", { workspace: "demo_newsroom" });
Search across several at once (federated) — pass workspace_ids to /search. Results from
every listed workspace merge into one ranked list, and each hit carries its own
workspace_id. Only workspaces on the key’s list are queried (others are silently skipped).
r = requests.post(f"{BASE}/search", headers={"X-API-Key": "nv_..."},
json={"query": "a handshake", "workspace_ids": ["demo_newsroom", "demo_enterprise"]})
for h in r.json()["data"]["results"]:
print(h["workspace_id"], h["score"], h["video_id"]) # each hit tagged with its workspace
const hits = await niravi.search("a handshake", { workspaceIds: ["demo_newsroom", "demo_enterprise"] });
Discover what a key can reach with GET /api/v1/workspaces (SDK: niravi.workspaces()):
returns the key’s access list, each with an access field (home / member / demo).
Demos (
demo_newsroom,demo_enterprise,demo_creator) are read-only and on every key by default — search/recall/chat work, but upload/delete return403. Targeting a workspace not on the key’s list returns403.
Each key also carries scopes:
| Scope | Grants |
|---|---|
read | Search, recall, chat, and all read endpoints |
write | Upload, delete, index options, People edits |
Calling a write endpoint with a read-only key returns 403. Deleting needs the workspace that owns the asset: an asset shared into your workspace answers 403.
Assets
The API calls everything a “video”: photos and documents share its IDs and endpoints. kind says which it is (video, image, document); a document has one frame per page (page k at timestamp = k − 1) and a page_count. Every box (objects, faces, text, passages) is x, y, w, h normalized to 0..1 with the origin at the top-left. → Images & documents
Engines
Arrow indexes and searches everything new, and it is the default search engine of every new workspace (and of every workspace that is empty or holds only Arrow assets). The older NiNo pipelines and NiQu (engine: "classic") search are deprecated: they keep working for libraries they indexed. → Indexing & engines, Deprecations
The response envelope
A successful response is an envelope:
// success
{ "success": true, "data": { /* ... */ }, "meta": { "request_id": "…", "rate_limit": { /* the X-RateLimit-* headers */ } } }
// list (paginated)
{ "success": true, "data": [ /* ... */ ], "pagination": { "limit": 50, "offset": 0, "total": 1280, "has_more": true } }
A failure has the same outer shape with "success": false: see Errors. The SDKs unwrap data for you and surface pagination/errors as language-native types.
Pagination
List endpoints (e.g. GET /videos) take limit (max 200) and offset, and return a pagination object with has_more. To page through everything, advance offset by the number of items until has_more is false — or let an SDK do it:
videos, offset = [], 0
while True:
page = requests.get(f"{BASE}/videos", headers=H, params={"limit": 200, "offset": offset}).json()
videos += page["data"]
if not page["pagination"]["has_more"]:
break
offset += len(page["data"])
const videos = await niravi.videos({ allPages: true });
Errors
Every /api/v1 failure, whatever the endpoint and whatever went wrong, has one body:
{
"success": false,
"error": { "code": "not_found", "message": "Video not found", "status": 404 },
"meta": { "request_id": "c796118b-81b2-4741-a9ad-12ecc3be646e" },
"detail": "Video not found"
}
Branch on error.code; show error.message; quote meta.request_id when you write to us. Some codes add a field to error (workspace_id on workspace_forbidden, retry_after on rate_limit_exceeded). detail is a legacy mirror kept for older clients (a string, an object or a list of field errors): read error instead.
| Status | error.code (examples) | When | SDK error |
|---|---|---|---|
| 400 | bad_request, search_error | Invalid request, e.g. "query is required" | NiraviError |
| 401 | unauthorized | Missing or invalid key | AuthError |
| 403 | forbidden, workspace_forbidden | Scope denied ("Missing required scope: write"), or a workspace not on the key’s list (error.workspace_id names it) | AuthError |
| 404 | not_found | Unknown asset, or an unknown path ("No such endpoint: GET /api/v1/nope") | NotFoundError |
| 409 | not_ready | The asset is still processing (e.g. a .nmap export) | NiraviError |
| 422 | invalid_options, unprocessable | Invalid request body or options | ValidationError |
| 429 | rate_limit_exceeded | Rate limited: wait error.retry_after seconds (also the Retry-After header) | RateLimitError |
| 502 | chat_unavailable | Chat or recall could not compose an answer: retry | ServerError |
| 5xx | Server error | ServerError |
def niravi_error(r):
e = r.json()["error"]
return e["code"], e["message"]
The SDKs raise these as typed errors that carry code, status, requestId / request_id and the original detail.
Rate limits
Limits apply per key and workspace, by the workspace’s plan. A key follows its workspace’s current plan: an upgrade applies to existing keys at once.
| Plan | Requests / minute | Requests / day |
|---|---|---|
| Free | 10 | 100 |
| Starter, Studio | 60 | 5,000 |
| Team, Team Premium | 120 | 50,000 |
| Enterprise | 600 | 500,000 |
Free also caps processing at 60 minutes of media a month and one upload processing at a time; Enterprise sets both by agreement. GET /api/v1/me shows your key’s plan_tier.
Every v1 response carries the key’s current limits, errors included (once the key has been recognised; a 401 has none). Limits are counted per API server instance, so they are enforced approximately:
| Header | Meaning |
|---|---|
X-RateLimit-Limit-Minute | Requests allowed per minute |
X-RateLimit-Limit-Day | Requests allowed per day |
X-RateLimit-Remaining-Minute | Requests left in the current minute |
X-RateLimit-Reset | Unix time the current minute window resets |
X-RateLimit-Limit / X-RateLimit-Remaining | Aliases of the minute pair, for clients that read the common names |
Successful responses repeat them under meta.rate_limit. A 429 adds Retry-After (seconds) and error.retry_after. Both SDKs retry a 429 with backoff that honours Retry-After, and expose the last response’s limits as rateLimit / rate_limit. For higher limits, write to hello@niravi.io.