> For the complete documentation index, see [llms.txt](https://verbatim-ai.gitbook.io/docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://verbatim-ai.gitbook.io/docs/integration/api-domains.md).

# API Domains & Conventions

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](https://www.verbatim-ai.com/api-docs/openapi.json) ([APIDocs](https://www.verbatim-ai.com/api-docs/)) and the [Swagger playground](https://www.verbatim-ai.com/api-docs/swagger/).

***

## 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](/docs/integration/authentication.md#scopes-domainaction) match on.
* **Authentication** — every request needs `Authorization: Bearer <jwt>` or `X-Access-Token: <token>`. See [Authentication](/docs/integration/authentication.md).
* **Content type** — request bodies and responses are JSON (`application/json`) unless noted (e.g. document summaries are `text/markdown`; presigned uploads go straight to storage).
* **Pagination** — list endpoints accept `pageSize` (default `25`) and `pageIndex` (default `0`, zero-based). Responses echo `pageIndex` and carry an `items` array.
* **IDs** — all identifiers are UUIDv4 strings.
* **Timestamps** — ISO-8601 UTC, e.g. `2026-04-23T04:06:51Z`.
* **Metadata** — most resources accept a free-form `metadata` JSON object for your own use.

### Errors

Non-2xx responses share one shape (the `Error` schema):

```json
{
  "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.                    |
| `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](#auth)                   | `/v1/auth`           | `auth`     | Mint/revoke access tokens; identity (`whoami`).          |
| [Configuration](#configuration) | `/v1/config`         | `config`   | List supported LLM/embedding models.                     |
| [Corpus](#corpus)               | `/v1/corpus`         | `corpus`   | Manage knowledge bases.                                  |
| [Document](#document)           | `/v1/doc`            | `doc`      | Upload, list, download and delete documents.             |
| [Chunk](#chunk)                 | `/v1/chunk`          | `chunk`    | Inspect and repair the pieces a document was split into. |
| [Session](#session)             | `/v1/session`        | `session`  | Open and manage conversation sessions.                   |
| [Post](#post)                   | `/v1/post`           | `post`     | Run queries and read answers/attachments.                |
| [Agent](#agent)                 | `/v1/agent`          | `agent`    | Manage the setups RAG queries run on.                    |
| [Usage](#usage)                 | `/v1/usage`          | `usage`    | Aggregated usage metrics.                                |
| [Widget](#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](https://verbatim-ai.gitbook.io/docs/api-keys).

***

## Auth

Manage the credentials untrusted clients use. Full details in [Access keys](/docs/integration/access-keys.md).

| Method & path                          | Summary                                                      |
| -------------------------------------- | ------------------------------------------------------------ |
| `POST /v1/auth/access-token`           | Create a short-lived, scoped access token (call with a JWT). |
| `DELETE /v1/auth/access-token/{token}` | Revoke an access token immediately.                          |
| `GET /v1/auth/whoami`                  | Return the identity resolved from the caller's credential.   |

***

## Configuration

| Method & path          | Summary                                                             |
| ---------------------- | ------------------------------------------------------------------- |
| `GET /v1/config/model` | List the LLM / embedding models available for corpora and sessions. |

Call this first to discover valid model names before creating a corpus or session.

***

## 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](#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). |

```bash
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.

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}/preview-urls` | Presigned URLs for page-image previews — `pages` required, 1 to 10 per call.    |
| `GET /v1/doc/accept`            | List accepted content types.                                                    |
| `DELETE /v1/doc/{id}`           | Delete a document; its chunks are deleted with it.                              |

### 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.     |
| `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.

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

# 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 source dates are kept — use `PATCH /v1/doc/{id}` to change those.

### Chunking configuration (`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.

```jsonc
// 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`, `nbWords`). 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

```jsonc
{
  "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 — the document summary is the usual case — 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

The text does not live in the database — it is an object in the archive, fetched per chunk. So:

* `GET /v1/chunk/{chunkId}` always carries `body`. 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 a `400` rather 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.

```bash
# 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

# the summary chunk of every document in a corpus
GET /v1/chunk/q?corpusId=<uuid>&key=kind&value=summary

# 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); `kind` is the key the platform sets — `chunk` for a piece of the document, `summary` for the generated summary. Naming a `corpusId` or `documentId` outside your organization answers `403` on the request that named it, rather than an empty page.

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, with its summary heading the block, 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.

```jsonc
{"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 merge
```

**Rewriting `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. Re-ingesting the document rebuilds every chunk from the file, this one included.

**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](#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/byUser` and `GET /v1/session/byMetadata` are **deprecated** — `GET /v1/session/q` takes the same filters and combines them. See [Searching sessions](#searching-sessions).

```bash
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** — use `GET /v1/post/q` instead.

```bash
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](#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:

```bash
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:

```json
{
  "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 through `GET /v1/agent/{agentId}` then answers `404`. 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 `agentId` your organization cannot see — someone else's, or one that never existed — answers `404`, 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](#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.

```bash
# 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.

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:

* `to` is the exclusive end of the newest **completed** bucket — the instant the bucket in progress starts at. It is never in the future.
* `timestamp` is the server time the report was computed at. It falls inside the bucket the report leaves out, so it is always later than `to`. If you want "now", read this one.

Lifetime `total`s are still read as of `timestamp`; the deltas are not. Something created today is counted in `total`but 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.

```bash
curl -G https://staging-api.verbatim-ai.com/v1/usage/all \
  -H "Authorization: Bearer $JWT" \
  --data-urlencode "timeframe=Month"
```

```jsonc
{
  "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 `timeframe` selected a rolling window (`Day` = last 24 hours, `Year` = last 365 days). The response shape is unchanged and no field was removed, but the top-level `created` / `removed` / `inPeriod` now span the whole range rather than one period, and `to` is no longer equal to `timestamp`. To recover the old single-period figure, read the last entry of `series`.
>
> Since 2026-08-25 the range also stops at the last **completed** bucket rather than running to the end of the one in progress, so `to` is 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](https://gitlab.com/verbatim.cloud/backend/genai-server/-/tree/develop/docs/\[./widget-installation-guide.md]\(https:/verbatim-ai.gitbook.io/docs/widget-website?fallback=true\)/README.md) and [Access keys](/docs/integration/access-keys.md#using-an-access-token-with-the-chatbot-widget).

***

## 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.

1. **Authenticate.** Mint an RSA JWT on your backend — see [RSA keys](/docs/integration/rsa-keys.md) — and set `Authorization: Bearer $JWT`. Sanity-check with `GET /v1/auth/whoami`.
2. **Pick models.** `GET /v1/config/model` to see valid model names.
3. **Create a corpus.** `POST /v1/corpus/` → keep the returned `id`.
4. **Ingest a document.**
   * `POST /v1/doc/init` with `{ corpusId, filename, contentType }` → `{ document, uploadUrl }`.
   * `PUT` the file bytes to `uploadUrl` with the matching `Content-Type`.
   * `POST /v1/doc/{id}/commit`, then poll `GET /v1/doc/{id}/status` until `READY`.
5. **Open a session.** `POST /v1/session/` with `{ corpusIds: [corpusId], model, system }` → keep the returned session `id`.
6. **Ask.** `GET /v1/post/q?sessionId=<id>&body=<question>` → response has `query` and `answer`.
7. **Show sources (optional).** `GET /v1/post/attachment/{answerPostId}` — each item carries the `pages` its chunks came from — then `.../attachment/{docId}/preview-urls?pages=…` to render them. `pages` is 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](/docs/integration/access-keys.md) and let the browser do steps 6–7 with `X-Access-Token`.

***

**See also:** [Authentication](/docs/integration/authentication.md) · [RSA keys](/docs/integration/rsa-keys.md) · [Access keys](/docs/integration/access-keys.md) · [Security](/docs/integration/security.md) · [Widget installation](https://gitlab.com/verbatim.cloud/backend/genai-server/-/tree/develop/docs/\[./widget-installation-guide.md)] (<https://verbatim-ai.gitbook.io/docs/widget-website?fallback=true>)


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://verbatim-ai.gitbook.io/docs/integration/api-domains.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
