# API reference (/api-reference)
This reference is generated from the API's own code, so it always matches what the server accepts.
Base URL: `https://manova.ir/api/v1/external`.
## Store [#store]
Authenticated with the [store connection key](/authentication#store-connection-key-secret).
GET /store
POST /store/connect
POST /store/sync
POST /store/products
The request Manova sends **to** your store is described in [Order lookup](/store/order-lookup).
## Chat [#chat]
Authenticated with an [API token](/authentication#api-token-secret).
POST /chat/messages
GET /conversations
GET /conversations/{id}
GET /usage
## Try it [#try-it]
Each endpoint page has a playground. Paste a key into it and send a real request: it goes from your browser straight
to Manova, under your account. Use a test store key or a token you can revoke afterwards.
You can also download the [OpenAPI document](/openapi.json) for Postman, Insomnia or a client generator.
# Authentication (/authentication)
## Widget key (public) [#widget-key-public]
Each assistant has a public key, `pk_` followed by 32 letters and digits. It goes in the `data-bot` attribute of the
widget's script tag and is safe to publish: the widget only loads on the domains you allowed for that assistant.
## Store connection key (secret) [#store-connection-key-secret]
A shop connected to an assistant uses a connection key:
```txt
mnv_<32 hex characters: the store>_<40 characters: the secret>
```
Create it in the assistant's **فروشگاه** section. Send it as a bearer token on every [store API](/store/overview) call:
```http
Authorization: Bearer mnv_3f2b...c91a_Xk29...Qp
```
The part after the last underscore is also the secret Manova uses to [sign order lookups](/store/order-lookup).
Making a new key in the panel stops the old one at once; update your integration with the new key.
## API token (secret) [#api-token-secret]
For the [chat API](/chat-api/overview). Create one in **دسترسی API** (`/workspace/api`) in your workspace.
- A token looks like `manova_` followed by 64 hexadecimal characters.
- It is shown **once**, when you create it. Manova stores only a hash.
- It expires **90 days** after creation. Create a new one before then.
- You can have up to **5** active tokens. Revoke one from the same page; it stops working immediately.
- A token acts as your account: it uses your credits and sees your conversations.
```http
Authorization: Bearer manova_4c1f...e07b
```
Store keys and API tokens are full credentials. Do not ship them in browser JavaScript, mobile apps or public
repositories. If one leaks, make a new one in the panel; the old one stops working at once.
## Failed authentication [#failed-authentication]
A missing, malformed, revoked or expired key gets `401`:
```json
{ "code": "UNAUTHENTICATED", "message": "کلید API معتبر نیست." }
```
`message` is written for people (in Persian). Branch on `code`. See [Errors](/errors).
# Changelog (/changelog)
We add to the APIs without breaking them: new endpoints, new optional fields, new event types and new error codes
can appear at any time, so ignore what you do not recognise. A breaking change would get a new version and notice
here well in advance.
## 2026-10-30 [#2026-10-30]
- **Docs.** Manova's developer docs launch at docs.manova.ir, with an interactive [API reference](/api-reference)
generated from the API's own code.
- **Widget JavaScript API.** `window.Manova` with `open()`, `close()`, `toggle()`, `isOpen()` and `reset()`, and the
`manova:ready`, `manova:open` and `manova:close` events. See [JavaScript API](/widget/javascript-api).
- **Errors.** Rate-limited API calls now answer with the standard error shape,
`{"code": "RATE_LIMITED", "message": "..."}`, instead of a bare message.
## 2026-10-23 [#2026-10-23]
- **Store integration.** Product sync (`/store/connect`, `/store/sync`, `/store/products`) and signed order lookups,
used by the WooCommerce plugin and open to [custom platforms](/store/custom-cms).
# Chat API (/chat-api/overview)
The chat API is the same chat as manova.ir/app, under your own account: replies use your plan's models and credits,
and conversations you start through the API appear in your workspace.
Authenticate with an [API token](/authentication#api-token-secret):
```http
Authorization: Bearer manova_4c1f...e07b
```
| Endpoint | What it does |
| --- | --- |
| `POST /chat/messages` | Send a message and stream the reply |
| `GET /conversations` | List your conversations, newest first |
| `GET /conversations/{id}` | One conversation with its messages |
| `GET /usage` | Credits for the current period |
Full schemas and a request playground are in the [API reference](/api-reference).
## Send a message [#send-a-message]
```bash
curl -N https://manova.ir/api/v1/external/chat/messages \
-H "Authorization: Bearer $MANOVA_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1d2c9e-6b8a-4f0e-9a51-0c2a7d1f4b33" \
-d '{
"clientRequestId": "6f1d2c9e-6b8a-4f0e-9a51-0c2a7d1f4b33",
"mode": "auto",
"content": [{ "type": "text", "text": "یک متن کوتاه برای معرفی فروشگاه کفش بنویس" }]
}'
```
The reply arrives as [server-sent events](/chat-api/streaming) while the model writes it.
## Retries are safe [#retries-are-safe]
Every send carries an id, twice: `clientRequestId` in the body and the same value in the `Idempotency-Key` header.
Generate a fresh UUID per message and reuse it only to retry that message.
- Retrying a send that already **ended** (completed, aborted or failed) does not charge again: you get its final
events once more.
- Retrying one that is **still running** gets `409 CONFLICT`.
- Reusing an id with a **different** body gets `409 CONFLICT`.
## Credits [#credits]
Each reply costs credits according to the model, reserved when the send starts and settled when it ends. With too
few credits left the send is refused with `402 INSUFFICIENT_CREDITS`; a plan's daily message limit gives
`429 RATE_LIMITED`. Check your balance with `GET /usage`; the stream also reports it after every reply.
## Conversations [#conversations]
```bash
curl "https://manova.ir/api/v1/external/conversations?limit=20" \
-H "Authorization: Bearer $MANOVA_TOKEN"
```
```json
{
"items": [
{
"id": "9b2e4c1a-7d3f-4e8b-a0c2-5f6d7e8a9b0c",
"title": "معرفی فروشگاه کفش",
"createdAt": "2026-10-30T08:10:00.000000Z",
"updatedAt": "2026-10-30T08:10:21.000000Z",
"lastMessagePreview": "فروشگاه ما با بیش از ده سال تجربه…",
"projectId": null,
"agentId": null,
"isPinned": false,
"createdByUserId": "42"
}
],
"nextCursor": "eyJpZCI6Ij..."
}
```
- `limit` is 1 to 40 (default 40). Pass `nextCursor` back as `cursor` for the next page; it is `null` on the last.
- `projectId` filters to one project; `projectId=none` to conversations outside any project.
- Pinned conversations come first.
`GET /conversations/{id}` returns the same fields plus `messages`, oldest first. A message's `content` is a list of
parts; its text is in the part with `"type": "text"`.
# Streaming replies (/chat-api/streaming)
A successful send answers `200` with `Content-Type: text/event-stream` and writes events as the reply is generated.
Each event is an `event:` line and a JSON `data:` line, followed by a blank line:
```txt
event: message.started
data: {"clientRequestId":"6f1d2c9e-...","conversationId":"9b2e4c1a-...","userMessageId":"...","assistantMessageId":"...","generationId":"..."}
event: message.delta
data: {"generationId":"...","sequence":1,"text":"فروشگاه ما"}
event: message.delta
data: {"generationId":"...","sequence":2,"text":" با بیش از ده سال تجربه"}
event: message.completed
data: {"generationId":"...","message":{"id":"...","role":"assistant","content":[{"type":"text","text":"فروشگاه ما با بیش از ده سال تجربه…"}],"status":"completed", "...": "..."}}
event: usage.updated
data: {"usage":{"remainingCredits":868,"usedCredits":132,"...":"..."}}
```
Errors found **before** the stream starts (bad input, no credits, a bad token) are normal JSON [errors](/errors)
with a 4xx or 5xx status. Once the stream has started, the status is `200` and a failure arrives as `message.failed`.
## Events [#events]
| Event | Data | Notes |
| --- | --- | --- |
| `message.started` | `clientRequestId`, `conversationId`, `userMessageId`, `assistantMessageId`, `generationId` | Always first. Save `conversationId` to continue the conversation. |
| `message.delta` | `generationId`, `sequence`, `text` | A piece of the reply. Append `text` in `sequence` order. |
| `agent.step` | `generationId`, `tool`, `status`, `detail` | Only when answering as an agent: a tool is `running`, then `completed` or `failed`. |
| `message.completed` | `generationId`, `message` | The whole reply as stored. Prefer it over the joined deltas. |
| `message.aborted` | `generationId`, `message` | The reply was stopped; `message` has what was written so far. |
| `message.failed` | `generationId`, `error` (`code`, `message`) | The model could not finish. Nothing is charged for a failed reply. |
| `usage.updated` | `usage` | Always last. The same object as `GET /usage`. |
| `memory.suggested` | `id`, `note` | Something the chat offers to remember; can be ignored. |
Exactly one of `message.completed`, `message.aborted` and `message.failed` ends each reply. Ignore event types you do
not know: new ones may be added.
## Read the stream [#read-the-stream]
Works in Node.js 18+, Deno, Bun and browsers. `EventSource` cannot send a POST body or an `Authorization` header,
so read the response body yourself:
```js
const id = crypto.randomUUID();
const response = await fetch('https://manova.ir/api/v1/external/chat/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.MANOVA_TOKEN}`,
'Content-Type': 'application/json',
'Idempotency-Key': id,
},
body: JSON.stringify({ clientRequestId: id, mode: 'auto', content: [{ type: 'text', text: 'سلام!' }] }),
});
if (!response.ok) throw new Error((await response.json()).code);
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
let end;
while ((end = buffer.indexOf('\n\n')) !== -1) {
const block = buffer.slice(0, end);
buffer = buffer.slice(end + 2);
const event = /^event: (.*)$/m.exec(block)?.[1];
const data = JSON.parse(/^data: (.*)$/m.exec(block)?.[1] ?? '{}');
if (event === 'message.delta') process.stdout.write(data.text);
if (event === 'message.failed') throw new Error(data.error.code);
}
}
```
```python
import json, os, uuid, requests
rid = str(uuid.uuid4())
with requests.post(
"https://manova.ir/api/v1/external/chat/messages",
headers={"Authorization": f"Bearer {os.environ['MANOVA_TOKEN']}", "Idempotency-Key": rid},
json={"clientRequestId": rid, "mode": "auto", "content": [{"type": "text", "text": "سلام!"}]},
stream=True,
) as r:
r.raise_for_status()
event = None
for line in r.iter_lines(decode_unicode=True):
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: "):
data = json.loads(line[6:])
if event == "message.delta":
print(data["text"], end="", flush=True)
elif event == "message.failed":
raise RuntimeError(data["error"]["code"])
```
```php
true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('MANOVA_TOKEN'), 'Content-Type: application/json', 'Idempotency-Key: ' . $id],
CURLOPT_POSTFIELDS => json_encode(['clientRequestId' => $id, 'mode' => 'auto',
'content' => [['type' => 'text', 'text' => 'سلام!']]], JSON_UNESCAPED_UNICODE),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buffer, &$event) {
$buffer .= $chunk;
while (($pos = strpos($buffer, "\n")) !== false) {
$line = rtrim(substr($buffer, 0, $pos), "\r");
$buffer = substr($buffer, $pos + 1);
if (str_starts_with($line, 'event: ')) $event = substr($line, 7);
elseif (str_starts_with($line, 'data: ') && $event === 'message.delta') echo json_decode(substr($line, 6), true)['text'];
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
```
## Long replies and dropped connections [#long-replies-and-dropped-connections]
A reply can stream for several minutes. Turn off response buffering in proxies between you and Manova, and use a
read timeout of a few minutes rather than seconds.
Closing the connection **stops** the reply: it is saved as aborted and charged for what was written. Retrying with
the same `clientRequestId` replays that result (`message.aborted`) without charging again. To get a full answer,
send the message again with a **new** `clientRequestId`.
# Errors (/errors)
```json
{
"code": "VALIDATION_ERROR",
"message": "ورودی معتبر نیست.",
"details": {
"products.0.id": ["The products.0.id field is required."]
}
}
```
| Field | |
| --- | --- |
| `code` | Stable and in English. **Branch on this.** |
| `message` | For people, in Persian. It may change; show it, do not parse it. |
| `details` | Only on `VALIDATION_ERROR`: the messages for each invalid field, by path. |
## Codes [#codes]
| Status | Code | What to do |
| --- | --- | --- |
| `401` | `UNAUTHENTICATED` | The key is missing, malformed, revoked, expired or replaced. Check which key the endpoint needs. |
| `402` | `INSUFFICIENT_CREDITS` | Not enough credits for this reply. Top up or wait for the next period. |
| `403` | `PLAN_RESTRICTED` | Your plan does not include this (for example a model or a feature). |
| `403` | `FORBIDDEN` | The key is valid but may not do this. |
| `404` | `NOT_FOUND` | No such item, or it is not yours. |
| `409` | `CONFLICT` | The request clashes with the current state: a newer sync is running, a store is not connected yet, or a chat request id is still running or was reused. |
| `422` | `VALIDATION_ERROR` | Fix the fields listed in `details`. |
| `429` | `RATE_LIMITED` | Too many requests, or a plan's daily limit. Wait for `Retry-After` seconds. |
| `503` | `UPSTREAM_UNAVAILABLE` | An AI provider is not answering. Retry later with backoff. |
New codes may be added. Treat an unknown code by its HTTP status.
## Retrying [#retrying]
Retry `429` after `Retry-After`, and `5xx` and network errors with exponential backoff (for example 1 s, 2 s, 4 s,
with some jitter). Do not retry other `4xx` errors unchanged: they fail the same way every time.
Store sync calls are safe to repeat: sending the same product twice stores it once. Chat sends are safe to repeat
with the same `clientRequestId` (see [Retries are safe](/chat-api/overview#retries-are-safe)).
# Introduction (/)
Manova answers your customers on your website, Telegram and Bale. It knows your products and prices, looks up orders,
and hands the conversation to a person on your team when it should. These docs cover the parts you can build on.
One script tag puts the assistant on any website. Control it from your own buttons.
Send your catalog so the assistant shows real prices and stock, and answer "where is my order?".
Not on WooCommerce? Implement the same contract from your own platform in an afternoon.
Talk to Manova's AI models from your own code, with streamed replies.
## Three kinds of keys [#three-kinds-of-keys]
Manova has three credentials, each for one job. Never mix them up, and never put a secret one in a web page.
| Key | Looks like | Where it lives | Used for |
| --- | --- | --- | --- |
| Widget key | `pk_` + 32 characters | Public, in your page's HTML | Loading the [widget](/widget/install) |
| Store connection key | `mnv__` | Secret, on your server | The [store API](/store/overview) and signing [order lookups](/store/order-lookup) |
| API token | `manova_` + 64 hex characters | Secret, on your server | The [chat API](/chat-api/overview) |
See [Authentication](/authentication) for where to create each one.
## Base URL [#base-url]
All server APIs live under one base URL:
```txt
https://manova.ir/api/v1/external
```
Requests and responses are JSON (UTF-8), except the chat endpoint, which streams
[server-sent events](/chat-api/streaming). Errors always have the [same shape](/errors).
## For AI tools [#for-ai-tools]
These docs are available as plain text for LLMs and coding agents: [`/llms.txt`](/llms.txt) lists every page and
[`/llms-full.txt`](/llms-full.txt) has all of them in one file. Every page also has a **Copy Markdown** button.
# Quickstart (/quickstart)
### Create an assistant [#create-an-assistant]
Sign in at [manova.ir](https://manova.ir), open **دستیار فروش و پشتیبانی** in your workspace (`/workspace/site`) and create an assistant
for your site. Give it your site address; Manova reads your pages so it can answer from them.
### Allow your domain [#allow-your-domain]
In the assistant's **ظاهر و رفتار** section, add every domain the widget will run on (for example `shop.example.com`).
The widget refuses to load anywhere else, so a copied key is useless on someone else's site.
### Add the script tag [#add-the-script-tag]
Copy the snippet from **نصب روی سایت** and paste it before `