# 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 `` on every page: ```html ``` Reload your site. The chat button appears in the corner. [More install options](/widget/install). ### Make an API call (optional) [#make-an-api-call-optional] Create an API token in **دسترسی API** (`/workspace/api`), then check your credit balance: ```bash curl https://manova.ir/api/v1/external/usage \ -H "Authorization: Bearer $MANOVA_TOKEN" ``` ```json { "periodStart": "2026-10-01T00:00:00.000000Z", "periodEnd": "2026-11-01T00:00:00.000000Z", "includedCredits": 1000, "purchasedCredits": 0, "usedCredits": 120, "reservedCredits": 0, "remainingCredits": 880, "updatedAt": "2026-10-30T09:12:44.000000Z" } ``` Next, [send a message](/chat-api/overview) and stream the reply. # Rate limits (/rate-limits) | API | Limit | Counted per | | --- | --- | --- | | Chat API (`/chat/messages`, `/conversations`, `/usage`) | 30 requests a minute | account | | Store API (`/store/...`) | 240 requests a minute | IP address | | Order lookups (Manova → your store) | 6 an hour | conversation | Every response carries the current budget: ```http X-RateLimit-Limit: 240 X-RateLimit-Remaining: 231 ``` Past the limit you get `429` with a `Retry-After` header, in seconds: ```http HTTP/1.1 429 Too Many Requests Retry-After: 21 X-RateLimit-Limit: 240 X-RateLimit-Remaining: 0 {"code": "RATE_LIMITED", "message": "درخواست‌ها زیاد شد؛ کمی بعد دوباره تلاش کن."} ``` ## Staying under the limits [#staying-under-the-limits] - **Batch products.** Send up to 100 per `POST /store/products` instead of one per call. A full sync of 20,000 products is 200 calls, under a minute of budget. - **Queue product updates.** Collect changes for a few seconds and send them together, rather than one call per save. - **Stream, don't poll.** A chat reply arrives on the same connection; there is nothing to poll. Plans also have their own daily message limits and credits; those return `429 RATE_LIMITED` or `402 INSUFFICIENT_CREDITS` from `POST /chat/messages`. # Custom CMS guide (/store/custom-cms) This guide builds what the WooCommerce plugin does, on any platform. You need a server-side language that can make HTTPS requests and one public route. The examples use plain PHP; the same steps work in Laravel, Django, Node.js or Go. ## What you build [#what-you-build] ### Store the key [#store-the-key] Create a connection key in the assistant's **فروشگاه** section and keep it in your server's environment, for example `MANOVA_STORE_KEY`. Never put it in templates or front-end code. ### Connect once [#connect-once] Call [`POST /store/connect`](/store/product-sync#1-connect) from an admin action or a deploy script. Save the `publicKey` it returns. ### Embed the widget [#embed-the-widget] Print the widget tag in your layout with that key: ```html ``` ### Sync products [#sync-products] A full sync from a scheduled job (nightly is plenty), plus a single-product update from your product save and delete hooks. See [Product sync](/store/product-sync). ### Answer order lookups [#answer-order-lookups] Add `POST {apiUrl}/order`. See [Order lookup](/store/order-lookup). ## A small client [#a-small-client] ```php title="ManovaStore.php" call('POST', '/connect', ['siteUrl' => $siteUrl, 'apiUrl' => $apiUrl, 'currency' => 'IRT', 'pluginVersion' => 'my-cms-1.0']); } /** Sends every product, 100 at a time, then removes what was not sent. */ public function fullSync(iterable $products): array { $syncId = 'sync_' . bin2hex(random_bytes(8)); $this->call('POST', '/sync', ['syncId' => $syncId, 'phase' => 'start']); $batch = []; foreach ($products as $product) { $batch[] = $product; if (count($batch) === 100) { $this->call('POST', '/products', ['syncId' => $syncId, 'products' => $batch]); $batch = []; } } if ($batch) $this->call('POST', '/products', ['syncId' => $syncId, 'products' => $batch]); return $this->call('POST', '/sync', ['syncId' => $syncId, 'phase' => 'finish']); } /** One changed product, or ids of deleted ones. */ public function update(array $products, array $deleted = []): array { return $this->call('POST', '/products', ['products' => $products, 'deleted' => $deleted]); } private function call(string $method, string $path, ?array $body = null): array { $ch = curl_init(self::BASE . $path); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $this->key, 'Accept: application/json', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body, JSON_UNESCAPED_UNICODE), ]); $response = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $data = json_decode((string) $response, true) ?? []; if ($status === 429) throw new RuntimeException('Rate limited, retry later'); if ($status >= 400) throw new RuntimeException(($data['code'] ?? 'HTTP_' . $status) . ': ' . ($data['message'] ?? '')); return $data; } } ``` Map your product rows to Manova's fields: ```php function toManova(array $row): array { $onSale = $row['sale_price'] && $row['sale_price'] < $row['price']; return [ 'id' => (int) $row['id'], 'name' => $row['title'], 'url' => 'https://shop.example.com/p/' . $row['slug'], 'image' => $row['image_url'], 'sku' => $row['sku'], 'price' => $onSale ? $row['sale_price'] : $row['price'], 'price_text' => number_format($onSale ? $row['sale_price'] : $row['price']) . ' تومان', 'regular_price_text' => $onSale ? number_format($row['price']) . ' تومان' : null, 'in_stock' => $row['stock'] > 0, 'stock_quantity' => (int) $row['stock'], 'categories' => $row['category_names'], 'summary' => $row['description_html'], 'cart_url' => 'https://shop.example.com/cart/add/' . $row['id'], 'total_sales' => (int) $row['sold'], 'visible' => $row['status'] === 'published', ]; } ``` ## Test the order route locally [#test-the-order-route-locally] Sign a request yourself to see that your route accepts it: ```bash SECRET="${MANOVA_STORE_KEY##*_}" BODY='{"number":"10457","phone":"09121234567"}' TS=$(date +%s) SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //') curl -i -X POST https://shop.example.com/api/manova/order \ -H "Content-Type: application/json" \ -H "X-Manova-Timestamp: $TS" \ -H "X-Manova-Signature: $SIG" \ -d "$BODY" ``` Also check the failures: a changed body or a timestamp older than five minutes must get `401`, and a wrong phone must get the same `404 {"found": false}` as an unknown order. ## Go-live checklist [#go-live-checklist] - [ ] The key is only in server configuration. - [ ] `connect` returned `"active": true`, and the widget appears on the shop. - [ ] A full sync finished, and the panel's **فروشگاه** section shows the right product count. - [ ] Saving a product in your admin updates it in the panel within a minute. - [ ] The order route verifies the signature and the timestamp, and answers in well under 10 seconds. - [ ] In the widget on your shop, "پیگیری سفارش 10457 09121234567" returns the real order. # Order lookup (/store/order-lookup) When a customer asks about an order ("سفارشم کجاست؟", or the **پیگیری سفارش** button), the assistant asks for the order number and the mobile number they bought with. Manova then asks your store, and replies with the status, the items and the tracking code. Both numbers must match the same order, so nobody can read someone else's order by guessing a number. Order lookup is on when the store is connected with an `apiUrl` and **پیگیری سفارش** is enabled in the panel. ## The request [#the-request] ```http POST {apiUrl}/order Content-Type: application/json Accept: application/json User-Agent: ManovaBot/1.0 (+https://manova.ir) X-Manova-Timestamp: 1793371200 X-Manova-Signature: 5d1c2a0b9e...f4 {"number":"10457","phone":"09121234567"} ``` | Field | Format | | --- | --- | | `number` | The order number as the customer typed it, digits only (Persian digits are converted). | | `phone` | An Iranian mobile number, normalised to `09xxxxxxxxx`. | Manova waits up to **10 seconds**, does not follow redirects and only calls public addresses. ## Verify the signature [#verify-the-signature] The signature is a hex HMAC-SHA256 of `timestamp + "." + rawBody`, keyed with the **secret**: the part of your connection key after the last underscore. Reject the request with `401` unless: 1. the timestamp is within **5 minutes** of your clock, and 2. the signature matches, compared in constant time. Always hash the raw body bytes exactly as received, before parsing the JSON. ```php _ $secret = substr($key, strrpos($key, '_') + 1); $body = file_get_contents('php://input'); $time = $_SERVER['HTTP_X_MANOVA_TIMESTAMP'] ?? ''; $signature = $_SERVER['HTTP_X_MANOVA_SIGNATURE'] ?? ''; $expected = hash_hmac('sha256', $time . '.' . $body, $secret); if (abs(time() - (int) $time) > 300 || !hash_equals($expected, $signature)) { http_response_code(401); exit(json_encode(['error' => 'unauthorized'])); } $data = json_decode($body, true); ``` ```js import crypto from 'node:crypto'; import express from 'express'; const key = process.env.MANOVA_STORE_KEY; // mnv__ const secret = key.slice(key.lastIndexOf('_') + 1); const app = express(); app.post('/api/manova/order', express.raw({ type: 'application/json' }), (req, res) => { const time = req.get('X-Manova-Timestamp') ?? ''; const signature = req.get('X-Manova-Signature') ?? ''; const expected = crypto.createHmac('sha256', secret).update(`${time}.`).update(req.body).digest('hex'); const fresh = Math.abs(Date.now() / 1000 - Number(time)) <= 300; const valid = signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); if (!fresh || !valid) return res.status(401).json({ error: 'unauthorized' }); const { number, phone } = JSON.parse(req.body.toString('utf8')); // ...find the order, then res.json({ found: true, order: { ... } }) }); ``` ```python import hashlib, hmac, os, time from flask import Flask, request, jsonify key = os.environ["MANOVA_STORE_KEY"] # mnv__ secret = key.rsplit("_", 1)[1].encode() app = Flask(__name__) @app.post("/api/manova/order") def order(): body = request.get_data() # raw bytes ts = request.headers.get("X-Manova-Timestamp", "") signature = request.headers.get("X-Manova-Signature", "") expected = hmac.new(secret, ts.encode() + b"." + body, hashlib.sha256).hexdigest() if not ts.isdigit() or abs(time.time() - int(ts)) > 300 or not hmac.compare_digest(expected, signature): return jsonify(error="unauthorized"), 401 data = request.get_json() # ...find the order, then return jsonify(found=True, order={...}) ``` ## Find the order [#find-the-order] Look the order up by the number customers see (your order number, which may differ from your database id). Then check that `phone` matches one of the order's phone numbers. Compare the **last 10 digits** of each, so `+989121234567`, `00989121234567` and `09121234567` all match. If there is no such order, **or the phone does not match**, answer the same way, so a caller cannot tell which one was wrong: ```http HTTP/1.1 404 Not Found Content-Type: application/json {"found": false} ``` ## The response [#the-response] ```json title="200 OK" { "found": true, "order": { "number": "10457", "status": "processing", "status_label": "در حال انجام", "date": "۸ آبان ۱۴۰۵", "date_iso": "2026-10-30T10:24:00+03:30", "total": "۴٬۹۰۰٬۰۰۰ تومان", "items": [ { "name": "کفش رانینگ مردانه مدل آریا", "qty": 1 }, { "name": "جوراب ورزشی", "qty": 2 } ], "shipping": "پست پیشتاز", "tracking": { "code": "123456789012345678901234", "carrier": "پست", "url": "https://tracking.post.ir/?id=123456789012345678901234" }, "url": "https://shop.example.com/account/orders/10457" } } ``` Everything inside `order` is optional except `number` and `status`. HTML is stripped from every field. | `status` | Meaning shown to the customer | | --- | --- | | `pending` | Waiting for payment | | `on-hold` | Placed, waiting for payment confirmation or review | | `processing` | Paid, being prepared and shipped | | `completed` | Completed and shipped | | `cancelled` | Cancelled | | `refunded` | Refunded | | `failed` | Payment failed | Any other status is shown with your `status_label`. ## Failures [#failures] A response other than `200` or `404 {"found": false}`, a timeout or invalid JSON counts as a failure: the customer is told the store cannot be reached right now, and the error appears in the panel's **فروشگاه** section. Each conversation can make up to **6** lookups an hour, which stops number guessing in the chat itself. # Store integration (/store/overview) A connected store makes the assistant a salesperson: it answers with your real prices and stock, shows product cards with an add-to-cart button, and tells customers where their order is. It works the same on the website widget, Telegram and Bale. There are two directions of traffic: | Direction | What | Auth | | --- | --- | --- | | Your store → Manova | Connect the shop, then send products and every change to them | Store connection key as a bearer token | | Manova → your store | Ask for one order's status when a customer wants it | HMAC signature made with the key's secret | connect, full sync and incremental updates the signed request your store answers build the whole thing on your own platform ## WooCommerce [#woocommerce] On WordPress with WooCommerce you do not need to write code. Install the **Manova AI Sales Assistant** plugin, paste the connection key from the assistant's **فروشگاه** section, and the plugin does everything on these pages: it installs the widget, syncs products in the background and answers order lookups. ## Your own platform [#your-own-platform] Any platform can implement the same contract. You need: 1. A **connection key**, from the assistant's **فروشگاه** section. 2. Calls from your server to the four [store endpoints](/store/product-sync). 3. One public HTTPS endpoint, `POST {apiUrl}/order`, that answers [signed order lookups](/store/order-lookup). This is optional: skip it and turn off order tracking in the panel. The [custom CMS guide](/store/custom-cms) walks through a complete implementation. ## Limits [#limits] - Up to **20,000** products per store. Products beyond that are not stored. - Up to **100** products per request, and **500** deleted ids per request. - **240** requests per minute per IP address across the store endpoints. See [Rate limits](/rate-limits). # Product sync (/store/product-sync) All calls go to `https://manova.ir/api/v1/external/store` with the connection key as a bearer token. Full request and response schemas are in the [API reference](/api-reference). ## 1. Connect [#1-connect] Tell Manova where the shop is and where to send order lookups. Call it once when the integration is set up, and again whenever any of these values changes. ```bash curl -X POST https://manova.ir/api/v1/external/store/connect \ -H "Authorization: Bearer $MANOVA_STORE_KEY" \ -H "Content-Type: application/json" \ -d '{ "siteUrl": "https://shop.example.com", "apiUrl": "https://shop.example.com/api/manova", "currency": "IRT", "pluginVersion": "my-cms-1.0" }' ``` - `apiUrl` must be on the same domain as `siteUrl` (with or without `www.`) and reachable from the internet. Manova appends `/order` to it for [order lookups](/store/order-lookup). - The shop's domain is added to the assistant's allowed domains, so the widget works there right away. - Connecting a **different** shop with the same key deletes the products of the previous one. The response tells you what to embed: ```json { "publicKey": "pk_8Wc1...", "botName": "دستیار فروشگاه", "active": true, "widgetUrl": "https://manova.ir/widget.js", "ordersEnabled": true, "panelUrl": "https://manova.ir/workspace/site/3c0d.../store", "products": 0, "inStock": 0, "lastSyncAt": null } ``` `GET /store` returns the same object at any time, which makes it a good connection check. ## 2. Full sync [#2-full-sync] A full sync sends every published product and lets Manova delete the ones you no longer have. ### Start [#start] Pick a random `syncId` (8 to 40 characters: letters, digits, `_` and `-`). ```json title="POST /store/sync" { "syncId": "sync_20261030_a81f", "phase": "start" } ``` ### Send batches [#send-batches] Send products in batches of up to 100, each with the same `syncId`: ```json title="POST /store/products" { "syncId": "sync_20261030_a81f", "products": [ { "id": 1042, "name": "کفش رانینگ مردانه مدل آریا", "url": "https://shop.example.com/product/aria-running", "image": "https://shop.example.com/media/aria.jpg", "sku": "ARIA-42", "type": "variable", "price": 2450000, "price_text": "۲٬۴۵۰٬۰۰۰ تومان", "regular_price_text": "۲٬۹۰۰٬۰۰۰ تومان", "in_stock": true, "stock_quantity": 7, "categories": ["کفش", "ورزشی"], "tags": ["رانینگ", "سبک"], "summary": "

رویه مش تنفس‌پذیر، زیره EVA.

", "cart_url": "https://shop.example.com/cart?add=1042", "total_sales": 318 } ] } ``` ```json title="Response" { "ok": true, "saved": 1, "removed": 0 } ```
### Finish [#finish] ```json title="POST /store/sync" { "syncId": "sync_20261030_a81f", "phase": "finish" } ``` ```json title="Response" { "ok": true, "removed": 12, "products": 1840, "inStock": 1610 } ``` Every product that was not sent in this sync is removed.
Starting a new sync replaces the previous one. A batch or a finish carrying an older `syncId` gets `409 CONFLICT`: stop that sync, because a newer one is running. ## 3. Incremental updates [#3-incremental-updates] When a product changes, send just that product, **without** `syncId`. Send the ids of deleted products in `deleted`: ```json title="POST /store/products" { "products": [{ "id": 1042, "name": "کفش رانینگ مردانه مدل آریا", "url": "https://shop.example.com/product/aria-running", "price_text": "۲٬۲۰۰٬۰۰۰ تومان", "in_stock": true }], "deleted": [977, 978] } ``` A product with `"visible": false` is removed too, which is handy for drafts and hidden products. Run a full sync now and then (for example nightly) to catch anything an update missed. ## Product fields [#product-fields] `id`, `name` and `url` are required; a product without a name or a URL on the shop's own domain is skipped. Each update **replaces** the stored product, so always send every field you have. Links on product cards get `utm_source=manova&utm_medium=assistant`, so sales from the chat show up in your analytics. ## Errors [#errors] | Status | Code | Meaning | | --- | --- | --- | | `401` | `UNAUTHENTICATED` | The key is wrong or was replaced by a new one in the panel. | | `409` | `CONFLICT` | Products were sent before `connect`, or a newer full sync has started. | | `422` | `VALIDATION_ERROR` | A field is invalid; `details` names it. Also when `apiUrl` is not on the shop's domain or is not public. | # Install the widget (/widget/install) ## The snippet [#the-snippet] ```html ``` Put it before ``, once per page. Find your key and a ready-made snippet in the assistant's **نصب روی سایت** section. The script is small, loads asynchronously and draws itself in a Shadow DOM, so your site's CSS does not leak into it and its CSS does not leak into your site. The widget loads only on the domains listed for the assistant (**ظاهر و رفتار**). A domain also covers its subdomains: `example.com` allows `www.example.com` and `shop.example.com`. Anywhere else the widget stays hidden and logs a warning in the browser console. ## Attributes [#attributes] Without `data-theme` and `data-color` the widget uses what you set in the panel, so you can restyle it later without touching your site. ## Platforms [#platforms] Paste the snippet into your shared footer template, just before ``. Install the **Manova AI Sales Assistant** plugin and paste your store connection key. The plugin adds the widget, syncs your products and answers order lookups; you do not need the snippet. See [Store integration](/store/overview). ```tsx title="app/layout.tsx" import Script from 'next/script'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children}