API Domains & Conventions
Every public API domain, the shared conventions, and an end-to-end client walkthrough.
This page introduces every public API domain, the conventions shared across the API, and an end-to-end walkthrough
that ties them together. For exact request/response schemas, use
the OpenAPI spec (APIDocs)
and the
Swagger playground.
Conventions
- Base URL —
https://staging-api.verbatim-ai.com(staging) or
https://api.verbatim-ai.com(production). - Versioning — public endpoints are under
/v1/. The first path segment after/v1/
names the domain (e.g./v1/corpus/…→corpus), which is also what
scopes match on. - Authentication — every request needs
Authorization: Bearer <jwt>or
X-Access-Token: <token>. See Authentication. - Content type — request bodies and responses are JSON (
application/json) unless noted (e.g. document summaries
aretext/markdown; presigned uploads go straight to storage;POST /v1/doc/converttakes the raw file as
application/octet-stream). - Pagination — list endpoints accept
pageSize(default25) andpageIndex
(default0, zero-based). Responses echopageIndexand carry anitemsarray. - IDs — all identifiers are UUIDv4 strings.
- Timestamps — ISO-8601 UTC, e.g.
2026-04-23T04:06:51Z. - Metadata — most resources accept a free-form
metadataJSON object for your own use.
Errors
Non-2xx responses share one shape (the Error schema):
{
"timestamp": "2026-04-23T04:06:51Z",
"status": 404,
"error": "Resource not found",
"message": "Resource not exist or id is invalid",
"path": "/v1/corpus/id"
}| Status | When |
|---|---|
400 Bad Request | Malformed request or invalid parameters. |
403 Forbidden | Missing/invalid/expired credential, or scope doesn't cover the request. |
404 Not Found | The referenced resource doesn't exist (or isn't yours). |
409 Conflict | Request conflicts with the resource's current state. |
413 Content Too Large | Request body above the endpoint's limit (POST /v1/doc/convert). |
415 Unsupported Media Type | Content type not accepted — see GET /v1/doc/accept. |
500 Internal Server Error | Unexpected server error; the message field has detail. |
Domains overview
| Domain | Base path | Scope name | Purpose |
|---|---|---|---|
| Auth | /v1/auth | auth | Mint/revoke access tokens; identity (whoami). |
| Configuration | /v1/config | config | List supported LLM/embedding models. |
| Corpus | /v1/corpus | corpus | Manage knowledge bases. |
| Document | /v1/doc | doc | Upload, list, download and delete documents. |
| Chunk | /v1/chunk | chunk | Inspect and repair the pieces a document was split into. |
| Session | /v1/session | session | Open and manage conversation sessions. |
| Post | /v1/post | post | Run queries and read answers/attachments. |
| Agent | /v1/agent | agent | Manage the setups RAG queries run on. |
| Usage | /v1/usage | usage | Aggregated usage metrics. |
| Widget | /v1/webhook/widget | — | Backend for the embeddable chatbot widget. |
Private endpoints (
/_/v1/…, e.g. key management) are backoffice-only and require
Firebase authentication. They are not reachable with an RSA JWT or access token and are out
of scope for this integration guide — manage keys through the
backoffice.
Auth
Manage the credentials untrusted clients use. Full details in
Access keys.
| Method & path | Summary |
|---|---|
POST /v1/auth/access-token | Create a short-lived, scoped access token (call with a JWT). |
GET /v1/auth/access-token | List the organization's access tokens; token values are masked (8Jf3kQ2p...). |
GET /v1/auth/access-token/scopes | List every scope an access token can be created with, grouped by domain. |
DELETE /v1/auth/access-token/id/{id} | Revoke an access token by its id, immediately. |
DELETE /v1/auth/access-token/{token} | Revoke an access token by its value, immediately. |
GET /v1/auth/whoami | Return the identity resolved from the caller's credential. |
Token management — create, list, revoke — needs a JWT: an access token is refused with 403
whatever its scope. A token's ttl is capped by the platform (24 hours by default).
Configuration
| Method & path | Summary |
|---|---|
GET /v1/config/model | List the LLM models available, each with the name, description and icon to present it with. |
Call this first to discover valid model names before creating an agent or a session — a model name is
validated at write time, so a name absent from this list is a 400 on the request that set it
rather than a failure on every query that agent later runs.
{
"total": 2,
"models": [
{
"id": "gemma4",
"name": "Gemma 4",
"description": "Google's open lightweight model. The platform default: quick to answer and inexpensive, a good fit for everyday questions over a corpus.",
"iconUrl": "https://cdn.simpleicons.org/google"
},
{
"id": "mistral",
"name": "Mistral Small 3.2",
"description": "Mistral AI's 24B instruction-tuned model. Stronger on European languages and on long, nuanced answers, for a higher cost per query.",
"iconUrl": "https://cdn.simpleicons.org/mistralai"
}
],
"items": ["gemma4", "mistral"]
}id is the contract — the value to send anywhere a model is named, and the only field the server
reads back. name, description and iconUrl are there to be rendered: build the picker from this
response rather than keeping your own table of labels, which drifts every time the platform's list
moves. Both are editorial and may be reworded, so do not match on them, and treat iconUrl as a
remote SVG with a fallback — it points at the provider's own logo, off-platform.
The response is not paginated: models holds the whole catalog in the order it is meant to be
offered — preselect the first entry — and total is its length. Which concrete provider model an
id runs on is deliberately absent: it changes under the alias without the alias changing, which is
why you store the alias.
itemsis deprecated. It carries the same identifiers, in the same order, derived from the
same catalog asmodels, and is still served for clients written beforemodelsexisted. Read
models[].idinstead;itemswill be removed in a future release.
Embedding models are chosen by the platform and are not listed here. Accepted document content types
are not either — they belong to ingestion, at GET /v1/doc/accept.
Corpus
A corpus is a knowledge base: the container documents are ingested into, and the scope a query is answered from. It
is also the unit ownership is checked against — every document, chunk, session and post hangs off a corpus. How a
query against it is answered is not its business: retrieval and the models that answer belong to the
agent named on each query.
| Method & path | Summary |
|---|---|
POST /v1/corpus/ | Create a corpus. |
GET /v1/corpus/ | List corpora (paginated). |
GET /v1/corpus/{corpusId} | Get one corpus. |
PATCH /v1/corpus/{corpusId} | Update name/description/metadata. Omitted fields keep their value. |
DELETE /v1/corpus/{corpusId} | Delete a corpus (cascades to its documents, their chunks & sessions). |
curl -X POST https://staging-api.verbatim-ai.com/v1/corpus/ \
-H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{
"name": "Support knowledge base",
"description": "Tickets, FAQs and runbooks used by the support team.",
"metadata": { "owner": "support-team", "language": "fr" }
}'Nothing on a corpus affects how its documents were ingested or how queries against it run, so no edit
re-processes anything.
Document
Documents are uploaded straight to object storage via presigned URLs — file bytes never flow through the API server.
Ingestion (markdown conversion → summarization → chunking → embedding) is asynchronous. The one exception is
POST /v1/doc/convert, which converts a file sent in the request body and stores
nothing. POST /v1/doc/url needs no transfer at all: the server prints a web page to PDF and
commits it into a corpus itself.
Beyond the free-form metadata map, a document carries two optional typed attributes, both settable at init and
patchable afterwards: tags, a list of labels you can filter on with
GET /v1/doc/?tags=… or GET /v1/doc/q, and chunk, a JSON object overriding the chunking strategy for that document.
Omit chunk to use the platform default — the API stores it verbatim and applies no defaults of its own.
| Method & path | Summary |
|---|---|
POST /v1/doc/init | Step 1: create a doc in AWAITING_UPLOAD and get a presigned PUT URL. |
POST /v1/doc/{id}/commit | Step 2: confirm the upload and queue ingestion (→ PROCESSING). |
PUT /v1/doc/{id}/init | Replace the content of an ingested doc: back to AWAITING_UPLOAD, new PUT URL. |
GET /v1/doc/{id}/status | Poll ingestion status (PROCESSING → READY / FAILED). |
GET /v1/doc/{id} | Get document metadata. |
GET /v1/doc/ | List documents (paginated, filterable by status and tags). |
GET /v1/doc/q | Search documents: filename, tags, status, dates — sorted and paginated. |
GET /v1/doc/{id}/summary | Get the LLM-generated summary (text/markdown). |
GET /v1/doc/{id}/download-url | Presigned URL to download the original file. |
GET /v1/doc/{id}/md | Presigned URL to the Markdown conversion ingestion produced. |
GET /v1/doc/{id}/preview-urls | Presigned URLs for page-image previews — pages required, 1 to 10 per call. |
GET /v1/doc/accept | List accepted content types. |
POST /v1/doc/url | Import a web page: printed to PDF, committed, ingestion queued (→ PENDING). |
POST /v1/doc/convert | Convert a file (raw body) to Markdown, synchronously — nothing is stored. |
DELETE /v1/doc/{id} | Delete a document; its chunks are deleted with it. |
What ingestion reports back
Six read-only counters are filled in by the processing pipeline and served on every response carrying a document.
| Field | Meaning |
|---|---|
size | Bytes of the source file, as uploaded. |
storage | Bytes the document occupies on the platform — see below. |
nbPages | Pages of the source document. Bounds the pages indices of GET /v1/doc/{id}/preview-urls. |
nbChunks | Chunks the document was split into — what GET /v1/chunk/q?documentId=… returns for it. Goes down by one when a chunk is deleted. |
tokens | LLM tokens consumed producing the summary. |
nbWords | Words in the source document. |
size, tokens and nbWords are omitted from the response while unset. storage, nbPages and nbChunks are always
present, and 0 on them means not computed yet rather than none — the pipeline reports them during ingestion, so
they stay 0 until it does.
storage counts the source file plus everything ingestion derived from it: the rendered page previews, the markdown
conversion, the summary and the embedding payloads. It is therefore always larger than size once a document has been
ingested, often several times larger for one that renders and chunks, and it is the figure the storage totals of
GET /v1/usage/* are built from. Bill on storage, display size — they answer different questions, and only the
first accounts for what the platform actually keeps.
The four dates on a document
Two of them are the platform's and two are the file's, and mixing them up is the usual source of confusion.
| Field | Describes | Who writes it |
|---|---|---|
createdAt | the platform row | Server. When the document was uploaded here. Not settable. |
updatedAt | the platform row | Server. Last change to the row, ingestion counters included. Not settable. |
docCreate | the source file | You, at init or by PATCH. When the file itself was written. |
docUpdate | the source file | You, with one exception below. When the file was last modified. |
A contract signed in 2019 and uploaded yesterday has a docCreate of 2019 and a createdAt of yesterday. Sort a
"recently added to the workspace" list on createdAt; sort a "most recent version of this document" list on docUpdate.
docCreate and docUpdate are optional on the request and always present on the response. Send neither when you
have no file metadata to read them from — a raw content push, a mailbox collecting a message it assembled itself — and
the upload instant is stored on whichever you left out. They fall back independently: sending docCreate alone does not
fill docUpdate in from it. Neither can be null, and neither can be cleared — on PATCH /v1/doc/{id} an omitted
field means leave it alone, which is also what makes the patch the way to replace a guessed date with a real one.
The one exception: PUT /v1/doc/{id}/init re-stamps docUpdate with the moment of the call, because the stored
date described the content being replaced. docCreate survives, like the rest of the document's identity. Patch the
real modification date after the re-init — patching it before is overwritten.
Searching documents
GET /v1/doc/ lists a corpus; GET /v1/doc/q searches it. Only corpusId is required — every other parameter is an
optional filter, and they all narrow together.
| Parameter | Meaning |
|---|---|
q | Filename, case-insensitive, anchored at the start. * anywhere to match elsewhere. |
tags, tagsMatch | Repeatable. ANY (default) keeps at least one match, ALL requires every tag. |
meta, metaMatch | Repeatable key:value. ALL (default) requires every condition, ANY one. |
status | Repeatable; any of the listed states matches. |
contentType, lang, provider | Exact match. |
createdAfter, createdBefore | Half-open window on the ingestion date: inclusive, then exclusive. |
sort, order | CREATED_AT | UPDATED_AT | FILENAME | SIZE, and ASC | DESC. |
pageSize, pageIndex | As everywhere else — 1 to 100 per page, default 25. |
q is anchored at the start of the filename: q=annual finds Annual-Report-2025.pdf,
q=report does not — q=*report does. That default is not arbitrary; a left-anchored pattern is the only shape
document_filename_lower_idx can serve, so it is a range scan where a leading *
is a filter over the corpus. Both return the same rows. % and _ match themselves; * is the only wildcard.
meta filters on the document's metadata, one key:value condition per parameter, split at the first : — a
value may hold colons (meta=source:https://…) and commas, a key may not hold a colon. Keys are top-level and
case-sensitive; values compare as text, so meta=year:2026 matches "year": "2026" and "year": 2026 alike, and
meta=archived:true a boolean. With several conditions, metaMatch=ALL (the default) keeps documents satisfying every
one — AND — and metaMatch=ANY those satisfying at least one — OR. A condition without : or with an empty key is a
400; at most 16 conditions, keys up to 128 characters, values up to 1024.
The response adds pageSize and total — the number of matches across all pages — to the usual corpusId,
pageIndex and items, and the ordering is stable, so walking pageIndex
never repeats nor skips a document.
# every document whose name starts with "annual", newest first
GET /v1/doc/q?corpusId=<uuid>&q=annual
# "report" anywhere in the name, at the cost of a scan over the corpus
GET /v1/doc/q?corpusId=<uuid>&q=*report*
# the ingestion backlog, longest-waiting first
GET /v1/doc/q?corpusId=<uuid>&status=PENDING&status=FAILED&sort=UPDATED_AT&order=ASC
# metadata: team is legal AND year is 2026 — then either team
GET /v1/doc/q?corpusId=<uuid>&meta=team:legal&meta=year:2026
GET /v1/doc/q?corpusId=<uuid>&meta=team:legal&meta=team:hr&metaMatch=ANY
# last quarter's legal PDFs, biggest first
GET /v1/doc/q?corpusId=<uuid>&tags=legal&tags=2026&tagsMatch=ALL&contentType=application/pdf
&createdAfter=2026-07-01T00:00:00Z&createdBefore=2026-10-01T00:00:00Z
&sort=SIZE&order=DESC
Upload flow
1. POST /v1/doc/init { corpusId, filename, contentType } → { document, uploadUrl }
2. PUT <uploadUrl> (raw bytes, Content-Type MUST match) → 200 from storage
3. POST /v1/doc/{id}/commit → 202, status = PROCESSING
4. GET /v1/doc/{id}/status (poll) → READY or FAILED
The presigned uploadUrl is single-use and bound to the contentType you declared — the
PUT must send a matching Content-Type header or storage rejects it. commit is idempotent (committing a READY
document returns it unchanged) and rejects unsupported types, oversized files, and duplicates (by content hash) within
the same corpus.
Replacing the content of a document
PUT /v1/doc/{id}/init re-opens an already ingested document (status READY or FAILED; anything else is a 409) and
returns the same payload as step 1 — so the flow above resumes at step 2 with the same document id:
1. PUT /v1/doc/{id}/init → { document, uploadUrl }
2. PUT <uploadUrl> (raw bytes, Content-Type MUST match) → 200 from storage
3. POST /v1/doc/{id}/commit → 202, status = PROCESSING
Only the content is replaced: the id, filename, userId, provider, lang, metadata,
tags, chunk and docCreate are kept — use PATCH /v1/doc/{id} to change those. docUpdate is the exception:
it is the source file's last-modified date, and the source file is being replaced, so it is re-stamped with the
moment of the call.
Importing a web page
POST /v1/doc/url prints a web page to PDF and adds it to a corpus — init, the upload and commit in one call, with
the server producing the file. The page is loaded in a headless Chromium, so scripts run and pages rendered
client-side import like any other.
curl -X POST "https://api.verbatim-ai.com/v1/doc/url" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"corpusId": "550e8400-e29b-41d4-a716-446655440001",
"url": "https://intranet.example.com/wiki/onboarding",
"scale": 0.8,
"headers": {"Authorization": "Bearer eyJhbGciOiJSUzI1NiJ9..."}
}'The answer is a 202 with the document, already committed (PENDING) — poll GET /v1/doc/{id}/status from there as
after any upload. The page names the document:
| Field | Value |
|---|---|
filename | The page's <title> + .pdf — its address when the page has no title |
lang | The page's <html lang>, reduced to its language (fr-FR → fr); en when absent |
provider | web |
metadata.url | The URL the page was imported from |
contentType | application/pdf |
Rules on the request:
urlmust behttpsand at most 2048 characters. Credentials do not go in the URL (https://user:pass@…is a
400): send them inheaders.scale(optional,0.1to2, default1) is Chromium's rendering scale — below 1 fits wide layouts on fewer pages.headers(optional, at most 20) are sent when loading the page, for pages behind a login (Authorization,
Cookie…). They go to the page's origin only — same scheme, host and port asurl— never to the scripts,
stylesheets and images it loads from elsewhere, nor to another origin a redirect leads to. They are not stored.
The page is checked before it is printed: one GET with your headers, following redirects like a browser (at
most 20), must end on an https URL answering HTTP 200 with HTML (text/html or application/xhtml+xml). When a
redirect leaves the page's origin, Authorization and cookies are dropped from this check; other headers follow.
Otherwise nothing is printed:
| What the page did | Answer |
|---|---|
| Host unknown, connection refused, no answer within 15 s | 400 |
Answered anything but 200 (404, 401, 500…) | 400 |
Redirected more than 20 times, or ended on a non-https URL | 400 |
Answered 200 with something other than HTML — a PDF, an image, JSON | 415 |
The message names the URL and what went wrong. A page that passes the check but still fails to print is a 400 too;
a PDF above the per-document size limit is a 409, as at commit. Being a POST under /v1/doc/, the endpoint needs
the doc:create scope on an access token.
Reading a document as Markdown
Ingestion converts every file to Markdown before splitting it into chunks. GET /v1/doc/{id}/md hands out a
time-limited presigned URL to that conversion, so the client reads the whole document as text straight from object
storage — the content does not flow through the API server.
curl "https://api.verbatim-ai.com/v1/doc/550e8400-e29b-41d4-a716-446655440000/md" \
-H "Authorization: Bearer $TOKEN"{
"url": "https://s3.fr-par.scw.cloud/bucket/documents/<corpus>/md/<doc>?X-Amz-Algorithm=...",
"timestamp": "2026-10-08T10:00:00Z",
"expiresAt": "2026-10-08T10:15:00Z"
}GET the url without any token — the signature is the credential, so whoever holds the URL can read the file until
expiresAt; ask again after that. timestamp is when it was issued. The body is UTF-8 Markdown.
The conversion exists once ingestion has written it — the document is READY. No existence check is made when the
URL is issued, so before that the URL answers 404 from storage. A document still AWAITING_UPLOAD is a 409. The
endpoint needs the doc:read scope on an access token.
AI assistants read the same content, inline, through the get_document_markdown
MCP tool.
Converting a file to Markdown
POST /v1/doc/convert turns a file into Markdown and hands it straight back — typically to put a document into an
LLM's context without adding it to a corpus. It is synchronous and stateless: no document is created, nothing is
ingested, the file is not kept.
The body is the file, sent as application/octet-stream (not JSON, not multipart), up to 25 MB — above that the
answer is 413. There is no format to declare: it is detected from the file's content and returned as
contentType. The optional filename query parameter is echoed back, and its extension helps when the content alone
is ambiguous — plain-text formats such as Markdown or CSV.
Readable formats include PDF (with a text layer), Word, Excel and PowerPoint — current (.docx .xlsx .pptx) and
legacy (.doc .xls .ppt) — OpenDocument, RTF, EPUB, HTML, XML, Markdown, plain text, CSV and e-mail
(.eml .msg). A file no parser recognises — an image, random bytes — is a 415.
curl -X POST "https://api.verbatim-ai.com/v1/doc/convert?filename=annual-report-2025.docx" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary @annual-report-2025.docx{
"contentType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"filename": "annual-report-2025.docx",
"size": 48213,
"pages": 3,
"markdown": "# Annual report 2025\n\nRevenue grew **12%** over the year.\n\n|Quarter|Revenue|\n|---|---|\n|Q1|4.2 M€|",
"warnings": []
}What to expect in markdown:
- headings, emphasis, lists and pipe tables; a spreadsheet gives one section per sheet;
- plain text and CSV come back as they were sent, line breaks included;
- images are not described, so a scanned PDF yields no text — still a
200, with an emptymarkdownand a
warningsentry saying so.
pages is set for paged formats (PDF, Word, PowerPoint). warnings lists what the converter recovered from — an
embedded file it could not read, a damaged part it skipped; a 200 with warnings is still a usable result. A file that
cannot be read at all — corrupt, truncated, password-protected — is a 400 whose message says why.
Being a POST under /v1/doc/, the endpoint needs the doc:create scope
on an access token.
Chunking configuration (chunk)
chunk)Ingestion splits a document into chunks before embedding them: a chunk is the unit that gets retrieved and handed to the
LLM as context, so its size and boundaries drive both answer quality and citation precision. chunk lets you override
that per document.
| Key | Type | Default | Strategy | Meaning |
|---|---|---|---|---|
strategy | string | by_title | — | by_title or basic |
max_characters | int | 10000 | both | Hard cap. No chunk exceeds it; a larger element is text-split. |
new_after_n_chars | int | max_characters | both | Soft cap. A chunk past this size is not extended, but is not split either. |
overlap | int | 0 | both | Characters carried from the end of the previous chunk as a prefix. Applies only to text-split chunks. |
overlap_all | bool | false | both | Apply overlap between all chunks, not just text-split ones. |
combine_text_under_n_chars | int | max_characters | by_title | Merge consecutive small sections up to this size. 0 disables merging. |
multipage_sections | bool | true | by_title | Allow a section to span a page break. false forces a new chunk per page. |
Choosing a strategy. by_title opens a new chunk at each section heading, so a chunk never straddles two topics —
use it for reports, contracts and manuals. basic ignores structure and fills each chunk to the limit — use it for
transcripts, articles and other flat prose, usually with overlap_all so a sentence cut across a boundary stays
retrievable from both sides.
Sizes are in characters, not tokens. Bigger chunks give the LLM more context per citation but retrieve less
precisely; 2000–10000 is the usual working range.
// Structured document — the platform default, spelled out
{"strategy": "by_title", "max_characters": 10000, "combine_text_under_n_chars": 1000}
// Flat prose — smaller chunks, overlapping so boundaries don't lose sentences
{"strategy": "basic", "max_characters": 4000, "new_after_n_chars": 3000,
"overlap": 200, "overlap_all": true}
// One chunk per section, never spanning a page — FAQs, catalogues, handbooks
{"strategy": "by_title", "max_characters": 6000, "combine_text_under_n_chars": 0,
"multipage_sections": false}Keys are not validated by the API: the object is stored verbatim and handed to the chunker, so a bad key surfaces as
a failed ingestion (status: FAILED, reason in statusMsg) rather than a
400 on init. That is deliberate — new options work the day they ship, with no API change. Omit chunk
entirely, or send {} on PATCH, to use the platform default.
Changing chunk applies to the next ingestion; it does not re-chunk an already ingested document. To rebuild
embeddings with a new configuration: PATCH /v1/doc/{id} → PUT /v1/doc/{id}/init → re-PUT the bytes → POST /v1/doc/{id}/commit.
Everything derived from the previous content is dropped:
its embeddings, its summary, and the ingestion counters — size, tokens and nbWords are cleared, and storage,
nbPages and nbChunks go back to 0. Two consequences: posts that cited this document lose their attachments to it (the citations
point at the deleted embeddings), and the previously uploaded file stays in storage until your PUT overwrites it —
committing without uploading re-ingests the old content.
Chunk
A chunk is one embeddable piece of a document: the text that was vectorised, the pages it came from, and the
metadata ingestion attached to it. It is the unit retrieval actually returns — a post's attachments point at chunks,
not at documents — so it is also the unit to look at when an answer cites something surprising.
There is no create. Chunks come out of ingestion, which splits a document, embeds each piece and stores both
halves. What this domain adds is the two things ingestion cannot do: showing you what came out, and letting you fix a
chunk that came out wrong.
| Method & path | Summary |
|---|---|
GET /v1/chunk/ | List every chunk of your organization, in reading order (paginated). |
GET /v1/chunk/q | Search chunks: corpus, document, hash, page, metadata — all combining. |
GET /v1/chunk/{chunkId} | Get one chunk with its text. |
PATCH /v1/chunk/{chunkId} | Patch its page span, metadata or text. |
DELETE /v1/chunk/{chunkId} | Take the chunk out of the index. Soft delete. |
What a chunk carries
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"documentId": "550e8400-e29b-41d4-a716-446655440001",
"corpusId": "550e8400-e29b-41d4-a716-446655440002",
"pages": [2, 3], // 1-based, ascending; a span, not a single page
"hash": "9e107d9d372bb6826bd81d3542a419d6",
"metadata": {"kind": "chunk", "section": "Article 4"},
"body": "Article 4 — The supplier shall deliver within thirty (30) days…"
}pages is a span. A chunk is built from consecutive elements and happily crosses page boundaries, so one chunk can
cover pages 2 through 5. A chunk belonging to no page in particular carries an empty array, and it sorts first within
its document.
hash is the MD5 of the text as it was pushed to storage. Equal hashes mean equal text, which makes it the cheapest
integrity check there is and the way to find duplicated content: read a chunk, then search its hash with no
documentId to see every copy of that passage across your corpora.
Two fields of the underlying row are deliberately not published. The 1024-dimension vector is the search index: it
is meaningless to read and would dominate every payload. The old scalar page column is superseded by pages.
body costs a storage read
body costs a storage readThe text does not live in the database — it is an object in the archive, fetched per chunk. So:
GET /v1/chunk/{chunkId}always carriesbody. That is one round-trip, which is what the endpoint is for.- the two listings omit it unless you pass
body=true, and with it the page size is capped at 25. Asking for 100
bodies is asking for 100 round-trips behind one request, and it is refused with a400rather than served slowly.
An empty body on a chunk that exists is not an error, and it is worth acting on: the row is there and the stored
object is not, so the chunk still matches vector searches and then contributes nothing to the answer. Surfacing exactly
that is one of the reasons this domain exists.
Searching chunks
Every filter on GET /v1/chunk/q is optional and they narrow together. The organization is never a parameter — it
comes from your token and is always applied, so no combination of filters reaches another tenant.
# everything one document was split into, in reading order
GET /v1/chunk/q?documentId=<uuid>
# the same, with the text, ten at a time
GET /v1/chunk/q?documentId=<uuid>&body=true&pageSize=10
# every chunk covering page 4 — including one that starts on page 3
GET /v1/chunk/q?documentId=<uuid>&page=4
# every copy of one passage in the organization, across documents
GET /v1/chunk/q?hash=9e107d9d372bb6826bd81d3542a419d6
# a nested metadata fragment
GET /v1/chunk/q?json={"section":"Article 4"}page is 1-based and matches membership in the span, so a chunk covering pages 3–5 answers to page=3, page=4
and page=5 alike. page=0 is a 400, not an empty page. Metadata is matched by containment (extra keys on the chunk
are fine). Naming a corpusId or documentId outside your organization answers 403 on the request that named it,
rather than an empty page.
Both listings serve chunks only. metadata.kind is set by the platform — chunk for a piece of the document,
summary for the generated summary — and GET /v1/chunk/ and /q always apply kind: chunk, whatever you filter on.
So the summary vector is not listed, and neither is any row whose metadata carries no kind at all: containment is
false for a fragment without the key and unknown for a row with no metadata. kind is therefore not a filter you can
use — passing key=kind&value=summary is answered with chunks, not with the summary and not with an empty page. Every
other metadata key narrows normally. A row that is not a chunk is still reachable by id with
GET /v1/chunk/{chunkId}; the rule is about what a listing returns.
Chunks come back in reading order: by document, then by the first page each one covers, then by id. So a document's
chunks arrive as a contiguous block in the order they appear in the file, and
walking pageIndex never shows the same chunk twice — several chunks per page is the ordinary case, and the id is what
keeps them apart.
A chunk lives exactly as long as its document
Deleting a document takes its chunks out of this API in the same breath: they stop being listed and resolving one
answers 403. The rows survive underneath — a document is only soft-deleted — but nothing reaches them until the
document is restored.
Patching a chunk, and what it does not do
PATCH is a repair endpoint. Only the fields you send are applied; omitted ones keep their value.
{"pages": [2, 3, 4]} // fix a span ingestion got wrong
{"body": "Article 4 — The supplier ([REDACTED])…"} // rewrite the text in place
{"metadata": {"kind": "chunk", "reviewed_by": "ops"}} // replaces the object, does not mergeRewriting body does not re-embed the chunk. The vector is the search index and it is not recomputed, so after the
patch the chunk is still retrieved for the text it used to hold and is then handed to the model as the text it holds
now. For a mangled character or a name to redact that is exactly right — the passage means the same thing and is found
the same way. For a rewrite it is wrong: re-ingest the document (PUT /v1/doc/{id}/init), which re-splits and
re-embeds it.
hash is deliberately not recomputed either. It records the MD5 of what was embedded, so leaving it alone is what
makes the divergence visible afterwards: a chunk whose hash no longer matches its body is one that has been
patched.
pages is sorted and de-duplicated server-side; [] clears the span; a value below 1 is a 400. id, documentId,
corpusId and hash are not patchable — a chunk cannot be moved to another document.
Deleting a chunk
Deleting a chunk is a soft delete, exactly like deleting a document or a session. The chunk stops existing as far
as this API is concerned — it disappears from GET /v1/chunk/, from /q and from GET /v1/chunk/{chunkId} — and it
stops being retrievable as context, so no answer produced from now on can be built on it. Nothing is destroyed
underneath: neither the row nor the text it was vectorised from. That is what makes the deletion a decision about what
may be retrieved rather than an erasure of what was. There is no endpoint that undoes it.
It also reaches past the chunk. Past answers that cited it keep their text and lose the citation pointing here. The
document itself is untouched — its file, its summary and its other chunks stay exactly as they were, which is what
makes this usable for taking one passage out of the index without destroying the document it came from. Its nbChunks
does move: the count follows the chunks this API returns, so it goes down by one. A document still reporting 0
because ingestion has not counted it stays at 0 — that value means not computed yet, and a deletion cannot turn it
into a real number. Re-ingesting the document rebuilds every chunk from the file, this one included, and the count with
them.
Deleting the document does the same thing to every chunk at once. DELETE /v1/doc/{docId} cascades: each chunk is
soft-deleted with it, so a document and its pieces disappear together and by the same mechanism rather than the chunks
merely being hidden behind their document. Deleting a corpus cascades the same way, through every document it holds.
Session
A session is a conversation against one or more corpora. It carries the corpora, the user who opened it, and whatever
metadata you attach — how its queries are answered is decided per query by the agent you name, not by the
session.
| Method & path | Summary |
|---|---|
POST /v1/session/ | Create a session over one or more corpora. |
GET /v1/session/ | List every session in the organization. |
GET /v1/session/q | Search sessions by user, corpus and metadata. |
GET /v1/session/{sessionId} | Get a session. |
PATCH /v1/session/{sessionId} | Update a session's metadata. |
DELETE /v1/session/{sessionId} | Delete a session. |
GET /v1/session/byUserandGET /v1/session/byMetadataare deprecated —GET /v1/session/qtakes the same
filters and combines them. See Searching sessions.
curl -X POST https://staging-api.verbatim-ai.com/v1/session/ \
-H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{
"corpusIds": ["550e8400-e29b-41d4-a716-446655440000"],
"metadata": {"customer_id": "42"}
}'A session also reports createdAt and updatedAt. They are equal until the session is patched — PATCH moves
updatedAt, which is how "last touched" is answerable at all.
Searching sessions
GET /v1/session/ lists your organization; GET /v1/session/q searches it. Nothing is required — every parameter
is an optional filter, and they all narrow together. A search carrying none of them returns the same page as the
listing.
| Parameter | Meaning |
|---|---|
userId | Exact owner. Sent empty, it is treated as absent rather than as an empty-string match. |
corpusId | Sessions bound to that corpus. A session bound to several matches on any one of them. |
key, value | A single metadata pair. They go together — one without the other is a 400. |
json | Raw JSON fragment for nested or multi-key metadata filters. Wins over key/value. |
pageSize, pageIndex | As everywhere else — 1 to 100 per page, default 25. |
The organization is not a parameter. It comes from your token and is always applied, so no combination of filters
reaches another tenant's sessions — including the case two tenants share a user identifier.
Metadata is matched by containment: a session matches when its metadata carries the fragment, extra keys being
fine. The response echoes the filters that were actually applied alongside orgId, pageIndex, pageSize, total —
the number of matches across all pages — and items. Sessions come back newest first, and the ordering is closed by
the session id, so walking pageIndex never repeats nor skips one.
# every session in your organization, newest first
GET /v1/session/
# everything one user opened, across corpora
GET /v1/session/q?userId=user_42
# one corpus's sessions, whoever opened them
GET /v1/session/q?corpusId=<uuid>
# the combination none of the by… endpoints could express
GET /v1/session/q?userId=user_42&corpusId=<uuid>&key=customer_id&value=42
# a nested metadata fragment
GET /v1/session/q?json={"channel":{"kind":"web"}}
Migrating off the deprecated listings is a rename: byUser?userId=…&corpusId=… becomes q?userId=…&corpusId=…, and
byMetadata?key=…&value=… becomes q?key=…&value=…. One difference is worth knowing — on /q, a request with no
metadata parameter at all is legal and means "do not filter on metadata", where byMetadata answers 400.
Post
A post is a single message in a session. Sending a query runs the full RAG pipeline and returns both the user post
(query) and the system answer (answer); the answer references the document chunks used as context (attachments).
| Method & path | Summary |
|---|---|
GET /v1/post/q | Send a query and get the answer (runs RAG). |
GET /v1/post/ | List posts in a session (paginated). |
GET /v1/post/{postId} | Get one post. |
GET /v1/post/attachment/{postId} | List the attachments (source chunks) of an answer. |
GET /v1/post/attachment/{docId}/download-url | Presigned download URL for a source document. |
GET /v1/post/attachment/{docId}/preview-urls | Presigned preview URLs — pages required, 1–10. |
DELETE /v1/post/{postId} | Delete a post. |
POST /v1/post/also sends a query but is deprecated — useGET /v1/post/qinstead.
curl -G https://staging-api.verbatim-ai.com/v1/post/q \
-H "Authorization: Bearer $JWT" \
--data-urlencode "sessionId=$SESSION_ID" \
--data-urlencode "body=What is the refund policy?" \
--data-urlencode "lang=en"Choosing an agent per query
How the pipeline runs — retrieval width, re-ranking, the system instruction, how much of the conversation is replayed,
which model answers — comes from an agent. Omit agentId and the query runs on the platform default, which is
what every query did before agents existed. Pass one to run a single query under a different setup:
curl -G https://staging-api.verbatim-ai.com/v1/post/q \
-H "Authorization: Bearer $JWT" \
--data-urlencode "sessionId=$SESSION_ID" \
--data-urlencode "body=Which clause covers early termination?" \
--data-urlencode "agentId=$AGENT_ID"The choice is per query, not per session. The next query on the same session is independent, so a client can
escalate one hard question to a wider, slower agent without changing the conversation it belongs to.
The agent is recorded on both posts of the exchange as agentId — the question and the answer, since what ran
under the agent is the exchange:
{
"query": {
"owner": "USER",
"body": "Which clause covers early termination?",
"agentId": "1a7c9e10-5b3d-4a2f-8c6e-9d0b3f4a5c61"
},
"answer": {
"owner": "SYSTEM",
"body": "Clause 7.2 …",
"agentId": "1a7c9e10-5b3d-4a2f-8c6e-9d0b3f4a5c61"
}
}agentId is always present. A query naming no agent is stamped with the platform default, resolved at the moment it
runs, so you never have to know that an absent value used to mean anything. Two consequences worth knowing:
- Deleting an agent does not rewrite the posts it produced, so this still names an agent you have since deleted —
resolving it throughGET /v1/agent/{agentId}then answers404. That is deliberate: the answer really was produced
by that agent, and re-attributing it to the default one would be a lie about your own history. Deleting an agent
changes what you can use from now on, not what already happened. - An
agentIdyour organization cannot see — someone else's, or one that never existed — answers404, and no post
is written: the agent is resolved before the question is persisted, so a rejected query leaves the conversation
untouched rather than stranding a question with no answer.
Posts written before this release carried no agent at all. They were backfilled with the platform default, which is
what an absent value meant — accurate for what they ran on, though it names the agent that is default today rather
than the one that answered, should it have been re-seeded since.
Agent
An agent is the setup a RAG query runs on: how many chunks the vector search returns, whether an LLM re-ranks them, what
system instruction the model receives, how much of the conversation is replayed, and which models answer. Before agents
these were platform-wide constants; an agent makes them a per-query choice.
| Method & path | Summary |
|---|---|
GET /v1/agent/ | List agents — core and yours, core first. |
GET /v1/agent/{agentId} | Get one agent. |
POST /v1/agent/ | Create a custom agent. |
PATCH /v1/agent/{agentId} | Update a custom agent. |
DELETE /v1/agent/{agentId} | Delete a custom agent. |
Core and custom agents
Two kinds of agent share these endpoints, told apart by lock:
lock | orgId | Who can write it | |
|---|---|---|---|
| Core | true | absent | Nobody — seeded and owned by Verbatim |
| Custom | false | yours | Your organization |
Core agents are visible to every organization and are what you get without configuring anything. Exactly one of them
carries default: true: that is the agent a query naming no agent runs on. Create a custom agent when you want to
depart from it — POST always produces a custom agent, and PATCH / DELETE on a core one answer 400.
Listing is scoped by your credentials, so an agent belonging to another organization is not merely hidden from the
list — addressing it by id answers 404, indistinguishable from an id that never existed.
Six core agents ship with the platform. They differ only in the system instruction they carry — same retrieval width,
same re-ranking, same models — so choosing between them chooses a register and a set of habits, not a different search:
| Agent | Use it for |
|---|---|
Verbatim Default | Anything, and what a query naming no agent runs on. Balanced retrieval, neutral source-bound tone. |
Verbatim Helpdesk | Everyday end-user questions. Answer first, then the steps in the order they are performed, in plain language. |
Verbatim Legal | Contracts, regulations and policies. Names the clause each part of the answer rests on, separates what the text says from what it implies, and flags what the documents do not settle. |
Verbatim Marketing | Positioning and product questions. Leads with the point that matters, then the evidence; never invents a figure or a customer name. |
Verbatim Daily | Day-to-day use. The answer in the first sentence, direct and friendly. |
Verbatim R&D | Technical questions. Keeps identifiers, signatures, units and versions exactly as the sources write them, and shows code as code. |
Only Verbatim Default carries default: true; the other five are choices, so nothing about your existing queries
changes because they exist. All six are lock: true — read them, query with them, copy their behaviour into an agent of
your own, but you cannot edit them. Their names are taken (see below).
Names have to be unmistakable in your listing
GET /v1/agent/ returns your own agents merged with Verbatim's core ones, and a name is what
identifies an agent to whoever picks one out of that list. So a name has to be free on both sides of
the merge: creating or renaming an agent onto a name one of your agents already uses, or one a
core agent carries, answers 409 and writes nothing.
What the rule covers is worth being precise about:
| Comparison | Exact. Support and support are two names, and Verbatim Default v2 is free. |
| Your organization | Reserved. Another organization using a name has no effect on you — names are not global. |
| Core agents | Reserved. The names they actually carry, not a namespace: nothing about the word "Verbatim" is off limits. |
| Deleted agents | Hold nothing. Deleting an agent frees its name immediately, yours or Verbatim's. |
Two consequences of core names being reserved. A core agent Verbatim ships later does not rename
your agents: an existing agent keeps a name that has since become a core one — only writes are
checked — though it cannot get that name back once it changes it. And because core agents are shared
across the platform, this is the one part of the rule where another tenant's actions are not the
reason a name is unavailable to you: Verbatim's are.
Sending an agent's own current name back on PATCH is not a rename at all, so a client that re-sends
the object it just read is unaffected either way.
Deleting an agent
DELETE /v1/agent/{agentId} takes the agent out of circulation: it disappears from GET /v1/agent/, and get, update,
delete and any query naming it all answer 404. A deleted agent is indistinguishable from one that never existed —
there is no "deleted" state to observe.
It does not rewrite the past. Answers already produced under that agent keep naming it in their agentId
(see Post), so a conversation stays readable exactly as it happened; the id simply no longer resolves. Deleting
an agent changes what you can use from now on, not what already ran.
Sessions are unaffected — an agent is resolved per query, so a conversation that used the deleted one carries on under
the platform default. Its name goes back into circulation, so a replacement can be created under the same name
straight away.
Overrides, not copies
Every nullable field on an agent stores an override. Leaving one out ties it to the platform default, so a default
Verbatim retunes later moves your agent with it; setting it pins your own value. That is why GET reports these fields
as stored — an absent spirit means "tracking the default", not "empty".
Because an omitted field on PATCH already means "leave alone", it cannot also mean "put this back to the default".
reset is how you do that — list the nullable fields to un-set. It is applied after the rest of the body, so a field
named in both ends up cleared. Resettable: description, rerankTopK, context, behaviour, spirit,
historySize, temperature, rerankModel, baseModel. Naming anything else answers 400.
Fields
| Field | Effect on a query |
|---|---|
topK | Chunks the vector search returns, before re-ranking. |
rerank / rerankTopK / rerankModel | Whether an LLM re-ranks those chunks, how many survive, and which model does it. rerank: true with no rerankModel runs nothing. |
context / behaviour / spirit | The three parts of the system instruction. |
useHistory / historySize | false answers each question on its own; historySize keeps the last N posts (a question and its answer are two), so 10 is five exchanges. |
thinkingMode | LOW or HIGH reasoning budget. Only applies when the session left thinking unset. |
temperature | 0–1. Only applies when the session left temperature unset. |
baseModel | Model that answers. Unset keeps whatever model the session was created with. |
The last three are deliberately subordinate to the session: thinking, temperature and model are chosen when a
session is created, and an agent only fills in what the session left open. Sessions created before agents existed
therefore behave exactly as they did.
# What can I query with?
curl -G https://staging-api.verbatim-ai.com/v1/agent/ -H "Authorization: Bearer $JWT"
# A support agent: narrow retrieval, short memory, low latency
curl -X POST https://staging-api.verbatim-ai.com/v1/agent/ \
-H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{
"name": "Support assistant",
"topK": 8,
"rerankTopK": 4,
"spirit": "Warm and reassuring. Never blame the customer.",
"historySize": 10,
"thinkingMode": "LOW",
"temperature": 0.2,
"baseModel": "mistral"
}'
# Put the tone back under Verbatim's control
curl -X PATCH https://staging-api.verbatim-ai.com/v1/agent/$AGENT_ID \
-H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{"reset": ["spirit"]}'Usage
Aggregated metrics (tokens, corpora, sessions, posts, storage) for billing and monitoring.
| Method & path | Summary |
|---|---|
GET /v1/usage/all | Organization-wide usage. |
GET /v1/usage/user/USERID | Usage for one user. |
GET /v1/usage/corpus/{corpusId} | Usage for one corpus. |
Every report answers two questions at once. At the top level, each dimension carries a lifetime
total and the created / removed deltas over the reported range — what a billing summary shows. In series, the
same deltas appear bucket by bucket — what a metrics page charts.
Soft-deleted rows still count toward the lifetime totals: the tokens were billed and the storage was charged when they
were produced, and removing the row should not erase the consumption. The
removed delta is the way to see cleanup activity. storage reuses the same shape as the counts, but its values are
bytes, not item counts — the storage of each document in scope, so page previews, the markdown conversion, the
summary and the embedding payloads are all counted, not just the files that were uploaded.
Scope changes what is reported, not the shape. corpora is populated at organization scope only — cardinality is
meaningless per user and always 1 per corpus — and where it is null at the top level it is absent from every bucket
too, so a client never has to branch differently on the two. Tokens sum posts and documents at organization and user
scope, but posts only at corpus scope:
vectorization tokens are billed against the organization.
Timeframes and the series
timeframe selects the bucket size, and with it how far back the report reaches:
timeframe | bucket | buckets | history |
|---|---|---|---|
Day | one day | 30 | ~1 month |
Week | ISO week | 12 | ~3 months |
Month | one month | 12 | 1 year |
Year | one year | 5 | 5 years |
Buckets are aligned to UTC calendar boundaries — midnight, Monday, the 1st of the month, the 1st of January. That
alignment is not cosmetic: a window measured backwards from the moment of the call moves every time you call it, so two
requests a minute apart would return different edges and two reports could never be laid over each other. Aligned, a
bucket is a date, and a chart drawn on Tuesday still lines up with the one drawn on Monday.
The report covers completed buckets only. The bucket in progress — today, this week, this month, this year — is left
out: it is partial by construction, and its numbers would keep growing until it closes. Nothing in a series you have
already fetched can change afterwards. Two fields follow from that and are easy to confuse:
tois the exclusive end of the newest completed bucket — the instant the bucket in progress starts at. It is
never in the future.timestampis the server time the report was computed at. It falls inside the bucket the report leaves out, so it is
always later thanto. If you want "now", read this one.
Lifetime totals are still read as of timestamp; the deltas are not. Something created today is counted in totalbut
not in created, tokens.inPeriod or series — it lands there once the current bucket closes. A document uploaded
this morning is in storage.total and not yet in
storage.created.
The series is contiguous and gapless: series[i].to is always series[i+1].from, and a bucket in which nothing
happened is present with zeros rather than omitted — so a chart needs no gap handling. Entries are ordered oldest first,
and they sum exactly to the top-level created,
removed and tokens.inPeriod. Every range is half-open: from inclusive, to exclusive.
curl -G https://staging-api.verbatim-ai.com/v1/usage/all \
-H "Authorization: Bearer $JWT" \
--data-urlencode "timeframe=Month"{
"timeframe": "Month",
"from": "2025-08-01T00:00:00Z", // start of the oldest bucket
"to": "2026-08-01T00:00:00Z", // end of the newest completed bucket, i.e. the 1st of this month
"organizationId": "550e8400-e29b-41d4-a716-446655440000",
"tokens": { "total": 1245000, "inPeriod": 124500 },
"corpora": { "total": 12, "created": 4, "removed": 1 },
"sessions": { "total": 240, "created": 96, "removed": 3 },
"posts": { "total": 980, "created": 410, "removed": 12 },
"storage": { "total": 8402331, "created": 1204122, "removed": 40122 },
"series": [
{
"from": "2025-08-01T00:00:00Z",
"to": "2025-09-01T00:00:00Z",
"tokens": 0,
"corpora": { "created": 0, "removed": 0 },
"sessions": { "created": 0, "removed": 0 },
"posts": { "created": 0, "removed": 0 },
"storage": { "created": 0, "removed": 0 }
}
// ... 11 more, oldest first; the newest is last month, ending at `to`
],
"timestamp": "2026-08-24T09:12:04Z" // real "now"
}Before 2026-08-24 these endpoints returned a single bucket and
timeframeselected a rolling
window (Day= last 24 hours,Year= last 365 days). The response shape is unchanged and no
field was removed, but the top-levelcreated/removed/inPeriodnow span the whole range
rather than one period, andtois no longer equal totimestamp. To recover the old
single-period figure, read the last entry ofseries.Since 2026-08-25 the range also stops at the last completed bucket rather than running to the
end of the one in progress, sotois in the past and today's activity reaches the deltas only
once the current bucket closes.
Widget
/v1/webhook/widget/… is the backend the embeddable chatbot widget talks to (authenticated with an access token). You
normally don't call it directly — you configure the widget and let it call these endpoints. See the
Chatbot widget installation guide
and
Access keys.
End-to-end client walkthrough
A minimal integration, from zero to an answer. Steps 1–2 are one-time setup; 3–7 are the runtime flow.
-
Authenticate. Mint an RSA JWT on your backend — see RSA keys — and set
Authorization: Bearer $JWT. Sanity-check withGET /v1/auth/whoami. -
Pick models.
GET /v1/config/modelto see valid model names — each entry carries the display
name, description and icon to offer it with. -
Create a corpus.
POST /v1/corpus/→ keep the returnedid. -
Ingest a document.
POST /v1/doc/initwith{ corpusId, filename, contentType }→{ document, uploadUrl }.PUTthe file bytes touploadUrlwith the matchingContent-Type.POST /v1/doc/{id}/commit, then pollGET /v1/doc/{id}/statusuntilREADY.
-
Open a session.
POST /v1/session/with{ corpusIds: [corpusId], model, system }→ keep the returned session
id. -
Ask.
GET /v1/post/q?sessionId=<id>&body=<question>→ response hasqueryand
answer. -
Show sources (optional).
GET /v1/post/attachment/{answerPostId}— each item carries thepagesits chunks came
from — then
.../attachment/{docId}/preview-urls?pages=…to render them.pagesis required and takes at most 10 indices per
call.
For a browser frontend, do steps 1–5 on your backend, then mint a scoped
access token and let the browser do steps 6–7 with
X-Access-Token.
See also: Authentication · RSA keys ·
Access keys · Security ·
Widget installation
Updated about 1 hour ago