VectorDB API
This page lists every endpoint on the VectorDB service (vectordb-api), with request fields, response shapes, and the exact error messages the server returns. Use it when you are writing code against the API; for a guided introduction, start with the VectorDB overview and quickstart.
Base URL
VectorDB has its own API service, separate from the core platform API, so it has its own base URL.
On the public API, https://api.codyhill.dev, which serves the VectorDB routes alongside every other service. platformctl goes there by default; curl needs the variable set.
export CAI_VECTORDB_API=https://api.codyhill.dev
Every path on this page is relative to that base URL.
Authentication
Every route except GET /healthz requires a bearer credential:
Authorization: Bearer <token-or-api-key>
The credential is either a session token (from POST /v1/auth/login on the core API, 12-hour life) or a service-account or personal API key (prefix cai_). Your project role is re-read from the database on every request, so a role change takes effect immediately. See API authentication.
Two roles matter here:
| Role | Can call |
|---|---|
| member | list, get, create index; :upsert, :query, :scroll, :delete-points |
| admin | everything a member can, plus PATCH index, DELETE index, and GET /vectordb/credentials |
Conventions
- Error envelope. Every non-2xx response is
{"error": "<message>", "request_id": "<id>"}. Unknown paths return a 404 in this envelope; a wrong HTTP method returns a 405. - The 404 rule. A request against an index (or project) you hold no grant on returns 404 — never 403 — so resource existence is not discoverable. You only see 403 when you are a member but the action needs admin.
- Pagination. List endpoints accept
page_size(1–200, default 50;limitis an alias) andpage_token(opaque). Responses carrynext_page_token, absent on the last page. Out-of-range sizes are rejected with 400page_size must be between 1 and 200, never clamped. A token from a different list or project returns 400page_token is invalid or was issued for a different list; start from the first page. - Custom methods. Data operations use GCP-style
:verbsuffixes on the index path (:upsert,:query,:scroll,:delete-points), always with POST. - Rate limiting. A per-principal token bucket wraps every route except
/healthz; shed requests get 429.
Endpoints at a glance
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /v1/projects/{projectID}/indexes | member | List indexes |
| POST | /v1/projects/{projectID}/indexes | member | Create an index |
| GET | /v1/projects/{projectID}/indexes/{name} | member | Get one index |
| PATCH | /v1/projects/{projectID}/indexes/{name} | admin | Edit mutable fields |
| DELETE | /v1/projects/{projectID}/indexes/{name} | admin | Delete an index |
| POST | /v1/projects/{projectID}/indexes/{name}:upsert | member | Write points |
| POST | /v1/projects/{projectID}/indexes/{name}:query | member | Similarity search |
| POST | /v1/projects/{projectID}/indexes/{name}:scroll | member | Browse points in id order |
| POST | /v1/projects/{projectID}/indexes/{name}:delete-points | member | Delete points by id or filter |
| GET | /v1/projects/{projectID}/vectordb/credentials | admin | Direct-access credential |
| GET | /healthz | none | Health check |
The index object
Every read returns this shape (the IndexResponse):
{
"name": "docs",
"resource_path": "projects/<short>/indexes/docs",
"collection": "p_<short>_docs",
"dimensions": 1536,
"distance": "cosine",
"on_disk": false,
"payload_on_disk": false,
"quantization": {"kind": "none", "quantile": "", "always_ram": false},
"shards": 1,
"replicas": 1,
"state": "ready",
"ready": true,
"collection_status": "green",
"points_count": 0,
"vectors_count": 0,
"segments_count": 0,
"message": "",
"created_at": "2026-08-06T12:00:00Z"
}
| Field | Type | Meaning |
|---|---|---|
name | string | The index name you chose |
resource_path | string | Stable path: projects/<short>/indexes/<name> |
collection | string | The internal storage name (p_<short>_<name>). Reported so you can find your data; never accepted as input anywhere |
dimensions | int | Vector width. Immutable |
distance | string | cosine, dot, or euclid. Immutable |
on_disk | bool | Vectors stored on disk instead of memory. Immutable |
payload_on_disk | bool | Payloads stored on disk. Editable via PATCH |
quantization | object | kind (none/scalar/binary), quantile, always_ram. Immutable |
shards | int | Shard count. Immutable |
replicas | int | Replica count (1–8). Editable via PATCH |
state | string | pending, creating, ready, degraded, conflict, or blocked |
ready | bool | The authoritative "you can use it now" signal |
collection_status | string | The storage layer's own health word (green/yellow/red/grey); present only once the collection exists |
points_count, vectors_count, segments_count | int | Counts from the vector store, refreshed about every 5 minutes — not live, see below |
points_count lags your writes by up to 5 minutesUpsert 1,000 points and read the index straight back, and points_count will very likely still say 0. Nothing is wrong: these counts are collected from the vector store on a periodic sweep (about every 5 minutes), not measured when you ask.
Your points are queryable immediately — the upsert is synchronous, and a :query right after it returns them. Only the count is behind.
So: to check an upsert worked, query for the points, do not watch the counter. Use the counter for "roughly how big is this index", which is the question it can actually answer.
# Right: proves the write landed, immediately
curl -sS -X POST "$CAI_VECTORDB_API/v1/projects/$PROJECT/indexes/docs:query" \
-H "Authorization: Bearer $CAI_TOKEN" -H 'Content-Type: application/json' \
-d '{"vector": [0.1, 0.2, 0.3, 0.4], "top_k": 5}'
# Misleading: may read 0 for several minutes after a successful upsert
curl -sS "$CAI_VECTORDB_API/v1/projects/$PROJECT/indexes/docs" \
-H "Authorization: Bearer $CAI_TOKEN" | jq .index.points_count
| message | string | Why the index is not ready (for example, a conflict explanation) |
| created_at | string | RFC 3339 timestamp |
List indexes
GET /v1/projects/{projectID}/indexes
Auth: member.
Query parameters:
| Name | Type | Required | Default | Notes |
|---|---|---|---|---|
page_size | int | no | 50 | 1–200; limit is an alias |
page_token | string | no | — | Opaque cursor from a previous page |
Response 200:
{"indexes": [], "next_page_token": "..."}
next_page_token is absent on the last page.
Errors: 400 page_size must be between 1 and 200; 400 page_token is invalid or was issued for a different list; start from the first page.
Create an index
POST /v1/projects/{projectID}/indexes
Auth: member. Returns 201 with an index object in state pending — the collection is created asynchronously. Poll GET until ready: true; data-plane calls return 409 until then.
Request body (only name is required — {"name":"docs"} is a complete request):
{
"name": "docs",
"dimensions": 256,
"distance": "cosine",
"on_disk": false,
"payload_on_disk": false,
"quantization": {"kind": "scalar", "quantile": "0.99", "always_ram": true},
"shards": 1,
"replicas": 1
}
| Field | Type | Required | Default | Rules |
|---|---|---|---|---|
name | string | yes | — | Lowercase letters, digits, hyphens; must start and end alphanumeric; max 48 chars |
dimensions | int | no | the platform embedding model's width | 1–65536; omitted or 0 sizes the index for the platform's own model. Immutable after create - see the note below |
distance | string | no | cosine | Case-insensitive: cosine; dot (aliases dotproduct, dot_product); euclid (aliases euclidean, l2) |
on_disk | bool | no | false | Immutable |
payload_on_disk | bool | no | false | Editable later |
quantization.kind | string | no | none | none, scalar (alias int8), or binary. Immutable |
quantization.quantile | string | no | — | Scalar quantization quantile |
quantization.always_ram | bool | no | false | Keep quantized vectors in memory |
shards | int | no | 1 | Immutable |
replicas | int | no | 1 | 1–8; editable later |
dimensions is fixed when the index is created. Existing vectors cannot be
re-embedded into a different width, so a mismatch is repaired only by deleting
the index and building it again.
Omitting dimensions sizes the index for the platform's own hosted embedding
model, qwen-embedding - the one served at POST /v1/embeddings and injected
as EMBED_MODEL. Embed with that and write the result and the two match with
nothing set. Name a width explicitly only when your vectors come from a
different model.
{"name": "docs-rag-index", "dimensions": 4096}
Better than trusting either number: read the width off the model you will
actually embed with. A workload is injected with EMBED_BASE_URL and
EMBED_MODEL, and one embedding call reports its own length.
Errors:
| Status | Message |
|---|---|
| 400 | dimensions must be between 1 and 65536, or omit it for the platform default |
That default is the width of the platform's own embedding model, so a create that omits it fits vectors from POST /v1/embeddings - see the note above.
| 400 | distance must be one of "cosine", "dot" or "euclid" |
| 400 | quantization.kind must be one of "none", "scalar" or "binary" |
| 400 | index name %q must match [a-z0-9]([a-z0-9-]*[a-z0-9])? - lowercase letters, digits and hyphens, starting and ending with an alphanumeric |
| 400 | index name %q is longer than 48 characters |
| 409 | an index with that name already exists |
Get an index
GET /v1/projects/{projectID}/indexes/{name}
Auth: member. Returns 200 with the index object. A missing index, a malformed name, and another project's index all return the same 404 — deliberately indistinguishable.
Edit an index
PATCH /v1/projects/{projectID}/indexes/{name}
Auth: admin. Returns 200 with the updated index object. Only two fields are editable:
{"replicas": 2, "payload_on_disk": true}
| Field | Type | Rules |
|---|---|---|
replicas | int | 1–8. 400 replicas must be between 1 and 8 |
payload_on_disk | bool | — |
Sending an immutable field returns a 400 that names it:
| Field sent | Message |
|---|---|
dimensions | dimensions is immutable: existing points cannot be re-embedded, so a different width is a new index, not an edit |
distance | distance is immutable: the metric is baked into the graph built over the existing points |
shards | shards is immutable: resharding moves data and is not performed on a spec edit |
on_disk | on_disk is immutable: vectors are not restaged between memory and disk in place |
quantization | quantization is immutable: it is fixed when the collection is built |
| (empty patch) | no mutable fields to change; the editable fields are replicas and payload_on_disk |
Delete an index
DELETE /v1/projects/{projectID}/indexes/{name}
Auth: admin. Returns 204 with no body.
Deleting an index destroys the collection and every vector in it. There are no snapshots, no backups, and no restore.
Custom methods
The four data operations are POSTs to the index path with a :verb suffix. Two routing errors apply to all of them:
- A POST with an unknown verb returns 400
unknown method "<verb>"; expected upsert, query, scroll or delete-points. - A POST with no
:verbat all returns 400expected a custom method: POST .../indexes/{name}:upsert, :query, :scroll or :delete-points. - While the index is still pending, all four return 409
index is not ready yet; its collection has not been created.
Upsert points
POST /v1/projects/{projectID}/indexes/{name}:upsert
Auth: member.
{"points": [
{"id": 42, "vector": [0.1, 0.2], "payload": {"tag": "alpha"}},
{"vector": [0.3, 0.4]}
]}
| Field | Type | Required | Rules |
|---|---|---|---|
points | array | yes | 1–1000 points per request |
points[].id | uint or UUID string | no | Omitted means a UUID is generated |
points[].vector | array of numbers | yes | Width must equal the index's dimensions |
points[].payload | object | no | Arbitrary JSON metadata, filterable at query time |
Batches are all-or-nothing: one bad point rejects the whole request, and nothing is written.
Response 200:
{"index": "docs", "upserted_count": 2}
Errors:
| Status | Message |
|---|---|
| 400 | points must contain at least one point |
| 400 | at most 1000 points per request (got N) |
| 400 | point 3 has 1536 dimensions; index "docs" expects 256 |
| 400 | point 0: id must be an unsigned integer or a UUID string |
| 400 | a string id must be a UUID (or use an unsigned integer) |
Query (similarity search)
POST /v1/projects/{projectID}/indexes/{name}:query
Auth: member.
{
"vector": [0.1, 0.2],
"top_k": 5,
"filter": {"must": [{"key": "tag", "match": {"value": "alpha"}}]},
"with_payload": true,
"with_vector": false,
"score_threshold": 0.5,
"offset": 0
}
| Field | Type | Required | Default | Rules |
|---|---|---|---|---|
vector | array of numbers | yes | — | Width must equal the index's dimensions |
top_k | int | no | 10 | Max 1000 |
filter | object | no | — | A payload filter (syntax); must be a JSON object; max 64 KiB |
with_payload | bool | no | true | Include each hit's payload |
with_vector | bool | no | false | Include each hit's vector |
score_threshold | number | no | — | Drop hits scoring below this |
offset | int | no | 0 | Must not be negative |
Response 200 (payload and vector appear per the flags):
{"index": "docs", "results": [{"id": 42, "score": 0.98, "payload": {"tag": "alpha"}, "vector": [0.1, 0.2]}]}
Errors:
| Status | Message |
|---|---|
| 400 | top_k must be at most 1000 |
| 400 | filter must be a JSON object |
| 400 | filter is too large |
| 400 | offset must not be negative |
| 400 | query vector has N dimensions; index "docs" expects M |
| 400 | vector database rejected the request: <message> (storage-side rejections passed through verbatim) |
Scroll (browse points)
POST /v1/projects/{projectID}/indexes/{name}:scroll
Auth: member. Lists points in id order — no vector needed. This is what the console's data browser uses.
{"offset": null, "limit": 50}
| Field | Type | Required | Default | Rules |
|---|---|---|---|---|
limit | int | no | 50 | Max 1000 |
offset | uint or UUID string | no | — | The previous page's next_offset, echoed back |
Response 200:
{"index": "docs", "points": [{"id": 42, "payload": {"tag": "alpha"}}], "next_offset": 99}
next_offset is omitted when the walk is exhausted. Scrolled points never carry vectors.
Errors: 400 limit must be at most 1000; 400 offset must be an unsigned integer or a UUID string.
Delete points
POST /v1/projects/{projectID}/indexes/{name}:delete-points
Auth: member. Provide exactly one of ids or filter:
{"ids": [42, "6f1c..."]}
{"filter": {"must": [{"key": "tag", "match": {"value": "beta"}}]}}
| Field | Type | Rules |
|---|---|---|
ids | array | Max 1000 ids; each an unsigned integer or UUID string |
filter | object | A payload filter (syntax); an empty object is refused |
Response 200 (ids_requested is omitted for filter deletes):
{"index": "docs", "status": "ok", "ids_requested": 2}
Errors:
| Status | Message |
|---|---|
| 400 | provide exactly one of ids or filter |
| 400 | at most 1000 ids per request |
| 400 | an empty filter would delete every point; delete the index instead if that is the intent |
Direct-access credentials
GET /v1/projects/{projectID}/vectordb/credentials
Auth: admin. Returns a credential for talking to the underlying vector store directly — an escape hatch for clients that need its native API rather than the REST surface above. The credential is scoped to your project's collections only.
Response 200:
{
"service_url": "http://<private-hostname>:6333",
"external_service_url": "https://<endpoint>-<short>.apps.<domain>",
"token": "<HS256 JWT>",
"collection_prefix": "p_<short>_",
"collections": ["p_<short>_docs"],
"truncated": false,
"note": "this credential is scoped to only this project's index collections..."
}
| Field | Meaning |
|---|---|
service_url | The store's platform-internal address. Reachable only from workloads running on the platform, never from your laptop |
external_service_url | The address of your own published endpoint for the vector database, over TLS. Present only when you have published one and a token was issued — nothing is reachable from the internet until you publish it. When present it is reachable from the internet, with the project token as the only boundary; add an address allow list to the endpoint to narrow that |
token | A JWT you present as the api-key header (or as a Bearer token). Read-write on the listed collections; cannot create collections |
collection_prefix | Your project's collection name prefix |
collections | The exact collection names the token covers, snapshotted at mint time |
truncated | true when more than 256 collections exist; only 256 are enumerated per token |
note | Plain-English explanation of exactly which of the above applies to this response |
The token is minted when the platform enforces per-project access control on the store. Without it you get the addresses and collection names. The token's scope is a snapshot of exact collection names: an index created later needs a fresh fetch. The store's own master key is never returned.
Health check
GET /healthz
No auth. Response 200:
{"status": "ok", "dependencies": {
"vector_store": {"ok": true, "latency_ms": 3},
"orchestration": {"ok": true, "latency_ms": 12},
"database": {"ok": true, "latency_ms": 1}}}
When any dependency fails, the status becomes "degraded", the HTTP status is 503, and each failing dependency carries an error field. The internal probe budget is 5 seconds.
Server limits
These are enforced by the server. The values below are the defaults; your administrator can tune a few of them, so treat these as the starting point rather than a promise.
| Limit | Value |
|---|---|
| Points per upsert request | 1000 |
| Any request body | 32 MiB |
top_k | max 1000, default 10 |
| Payload filter size | 64 KiB |
| Scroll page | default 50, max 1000 |
List page_size | default 50, max 200 |
| Index name length | 48 characters |
dimensions | 1–65536, defaults to the platform embedding model's width |
replicas | 1–8 |
| Collections per direct-access token | 256 |
See platform limits for cross-service limits.
Status codes
| Code | When |
|---|---|
| 400 | Validation failure; malformed project id (project id must be a UUID); storage-side 4xx passthrough |
| 401 | Missing or invalid credential |
| 403 | You are a member but the route needs admin |
| 404 | Not found, or not yours — deliberately identical |
| 409 | Index name taken; index not reconciled yet |
| 429 | Per-principal rate limit |
| 500 | internal error |
| 503 | Health degraded; session signing key not loaded |
Related pages
- VectorDB overview — what the service is and when to use it
- Indexes and points — concepts behind this API
- Search — query patterns and filters
- CLI: data and messaging commands — the read-only
platformctl vectordbcommands - API overview — shared conventions across all platform APIs