# FindByWords Developer API > A read-only API to your own shop's assistant: answers, as-you-type suggestions, similar products and the catalogue, for your own server, app or page. API version 1.0.0. https://findbywords.com/developers ## Overview The API gives your code what the on-site assistant gives your shoppers: a full answer to a question in plain words, product suggestions as someone types, lists of similar products, and read access to the catalogue. Base URL: `https://assistant.onesite.agency/api/v1`. It is read-only and limited to one shop: a key opens the shop that created it and nothing else. Product names, prices, stock and links always come from the catalogue; the AI writes only the short texts `say` and `why`, and the response marks them (see AI-generated text below). Requests and responses are JSON over HTTPS. The version is in the path (`/v1`). The API is available on plans that include the developer API. ## Quick start From nothing to a first answer in three steps. ### 1. Create a key In the cabinet open the Developers tab, name the key and choose its scopes, then optionally an IP list and browser origins. The key is shown once and we keep only a hash, so copy it straight into your server's secrets and never into a repository. A lost key cannot be recovered: revoke it and create another. ### 2. Check it Call `GET /me`. A `200` shows which shop the key opens, its scopes and its limits. Replace `YOUR_API_KEY` with your key. Example request, curl: ```bash curl 'https://assistant.onesite.agency/api/v1/me' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` Example request, JavaScript (fetch): ```javascript const response = await fetch("https://assistant.onesite.agency/api/v1/me", { method: "GET", headers: { "Authorization": "Bearer YOUR_API_KEY", }, }); const data = await response.json(); console.log(response.status, data); ``` Example request, Python (requests): ```python import requests response = requests.get( "https://assistant.onesite.agency/api/v1/me", headers={ "Authorization": "Bearer YOUR_API_KEY", }, ) print(response.status_code, response.json()) ``` ### 3. Ask a question Send the shopper's words to `POST /answers` (needs the `answer` scope). You get the best match, alternatives and a one-line reason for each. Example request, curl: ```bash curl -X POST 'https://assistant.onesite.agency/api/v1/answers' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: 3f9d1c52-7b0e-4a53-9a6f-0c2f8e1d77aa' \ -d '{ "query": "a cold, quiet science fiction novel for a long winter", "language": "en", "in_stock_only": true, "session_id": "visitor-8841" }' ``` Example request, JavaScript (fetch): ```javascript const response = await fetch("https://assistant.onesite.agency/api/v1/answers", { method: "POST", headers: { "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json", "Idempotency-Key": "3f9d1c52-7b0e-4a53-9a6f-0c2f8e1d77aa", }, body: JSON.stringify({ "query": "a cold, quiet science fiction novel for a long winter", "language": "en", "in_stock_only": true, "session_id": "visitor-8841" }), }); const data = await response.json(); console.log(response.status, data); ``` Example request, Python (requests): ```python import requests response = requests.post( "https://assistant.onesite.agency/api/v1/answers", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json", "Idempotency-Key": "3f9d1c52-7b0e-4a53-9a6f-0c2f8e1d77aa", }, json={ "query": "a cold, quiet science fiction novel for a long winter", "language": "en", "in_stock_only": True, "session_id": "visitor-8841", }, ) print(response.status_code, response.json()) ``` ## Authentication Send the key in the `Authorization` header as a bearer token. Never put it in the URL: URLs end up in logs and browser history. Authentication: ```bash Authorization: Bearer YOUR_API_KEY ``` A missing, malformed, unknown or revoked key all give the same `401` `invalid_api_key`, so an answer never tells someone guessing that a key is almost right. Repeated invalid keys from one address are slowed down with `429` `too_many_failed_attempts`. A key belongs to exactly one shop and cannot read another: a product id from another shop answers `404`, exactly like a missing one. You can narrow a key further with an IP allow-list (`403` `ip_not_allowed`) and a browser-origin allow-list (`403` `origin_not_allowed`). Scopes are fixed when the key is created; to widen them, create another key. ## Scopes and limits Each key carries the scopes it may use. Limits apply per key and per scope. **Scopes, their endpoints and limits** | Scope | Endpoints | Calls per 60 s | |---|---|---| | `answer` | `POST /answers` | 20 | | `suggest` | `GET /suggestions` | 120 | | `catalogue:read` | `GET /products, GET /products/{product_id}` | 120 | | `similar` | `GET /products/{product_id}/similar` | 60 | | `analytics:read` | `GET /analytics/summary, GET /analytics/series, GET /analytics/queries, GET /analytics/sales` | 30 | `POST /answers` also has a daily cap of 500 calls per key (UTC day), because every answer is a model call. A replay with the same `Idempotency-Key` does not count. Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`; a `429` adds `Retry-After` (seconds). Wait that long instead of retrying in a loop. `GET /me` needs a valid key but no particular scope. ## Endpoints Every operation below is built from the OpenAPI document, and every example is run against the real API by our tests, so the samples cannot drift from the API. Descriptions of operations and fields are in English, as in the document. ### GET /me Check which shop, scopes and limits a key has. Describes the key you are calling with: its scopes, shop, allow-lists and limits. Costs no quota; use it to check a key. - Required scope: any valid key No parameters. **Responses** | Status | Response schema | Description | |---|---|---| | `200` | `KeyInfo` | Successful Response | | `401` | `ErrorBody` | Missing, malformed, unknown or revoked API key (all look the same). | | `402` | `ErrorBody` | The shop's subscription is not active. | | `403` | `ErrorBody` | Key not allowed here: missing scope, IP or origin not on the key's list, plan without API. | | `429` | `ErrorBody` | Rate limit or daily quota reached. See `Retry-After`. | #### Check a key (HTTP 200) Example request, curl: ```bash curl 'https://assistant.onesite.agency/api/v1/me' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` Example request, JavaScript (fetch): ```javascript const response = await fetch("https://assistant.onesite.agency/api/v1/me", { method: "GET", headers: { "Authorization": "Bearer YOUR_API_KEY", }, }); const data = await response.json(); console.log(response.status, data); ``` Example request, Python (requests): ```python import requests response = requests.get( "https://assistant.onesite.agency/api/v1/me", headers={ "Authorization": "Bearer YOUR_API_KEY", }, ) print(response.status_code, response.json()) ``` Example response · HTTP 200: ```json { "id": 3, "name": "Storefront search", "hint": "osk_…PLEE", "scopes": [ "analytics:read", "answer", "catalogue:read", "similar", "suggest" ], "shop": { "domain": "alpha.example", "currency": "EUR", "language": "en" }, "origins": [], "networks": [], "created_at": "2026-10-13T09:00:00+00:00", "last_used_at": null, "limits": { "answer": { "per_minute": 20 }, "suggest": { "per_minute": 120 }, "similar": { "per_minute": 60 }, "catalogue:read": { "per_minute": 120 }, "analytics:read": { "per_minute": 30 } }, "answers_per_day": 500 } ``` #### Missing or invalid key (HTTP 401) Example request, curl: ```bash curl 'https://assistant.onesite.agency/api/v1/me' ``` Example request, JavaScript (fetch): ```javascript const response = await fetch("https://assistant.onesite.agency/api/v1/me", { method: "GET", }); const data = await response.json(); console.log(response.status, data); ``` Example request, Python (requests): ```python import requests response = requests.get( "https://assistant.onesite.agency/api/v1/me", ) print(response.status_code, response.json()) ``` Example response · HTTP 401: ```json { "error": { "type": "authentication_error", "code": "invalid_api_key", "message": "Missing or invalid API key. Send it as 'Authorization: Bearer osk_…'.", "param": null, "request_id": "req_9f2b000000000001" } } ``` ### POST /answers Ask a question in the shopper's own words and get the best match, alternatives and a reason for each. Answers a shopper's question the way the shop's own assistant does: the best match, alternatives and a one-line reason for each. Product facts (title, price, stock, link) always come from the shop's catalogue; only `say` and `why` are written by an AI model — see the `ai` object, and pass that on to your users. Counted against the key's per-minute limit and its daily cap; a replay with the same `Idempotency-Key` is free. - Required scope: `answer` - Limit: 20 calls per 60 s **Parameters** | Name | In | Type | Required | Constraints | Description | |---|---|---|---|---|---| | `Idempotency-Key` | `header` | `string \| null` | no | `length 1–64` | Makes a retry safe: the same key and body return the first answer without a second model call. | #### Request body: AnswerRequest **Request schema** | Field | Type | Required | Description | |---|---|---|---| | `query` | `string` | yes | The shopper's question, in their own words. | | `language` | `Language \| null` | no | Language of the reply (`ru en el es de it fr`). Defaults to the shop's language. | | `session_id` | `string \| null` | no | Opaque id of one conversation. Send the same id to let follow-ups (“cheaper”, “something lighter”) build on earlier turns. Sessions are private to your API key. | | `in_stock_only` | `boolean` | no | Only products that can be bought now (excludes pre-orders). | **Responses** | Status | Response schema | Description | |---|---|---| | `200` | `Answer` | Successful Response | | `401` | `ErrorBody` | Missing, malformed, unknown or revoked API key (all look the same). | | `402` | `ErrorBody` | The shop's subscription is not active. | | `403` | `ErrorBody` | Key not allowed here: missing scope, IP or origin not on the key's list, plan without API. | | `409` | `ErrorBody` | Same `Idempotency-Key` is still being processed. | | `422` | `ErrorBody` | Invalid request body, or `Idempotency-Key` reused with a different body. | | `429` | `ErrorBody` | Rate limit or daily quota reached. See `Retry-After`. | | `502` | `ErrorBody` | The answer could not be produced right now. Safe to retry. | #### A shopper asks in their own words (HTTP 200) Example request, curl: ```bash curl -X POST 'https://assistant.onesite.agency/api/v1/answers' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: 3f9d1c52-7b0e-4a53-9a6f-0c2f8e1d77aa' \ -d '{ "query": "a cold, quiet science fiction novel for a long winter", "language": "en", "in_stock_only": true, "session_id": "visitor-8841" }' ``` Example request, JavaScript (fetch): ```javascript const response = await fetch("https://assistant.onesite.agency/api/v1/answers", { method: "POST", headers: { "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json", "Idempotency-Key": "3f9d1c52-7b0e-4a53-9a6f-0c2f8e1d77aa", }, body: JSON.stringify({ "query": "a cold, quiet science fiction novel for a long winter", "language": "en", "in_stock_only": true, "session_id": "visitor-8841" }), }); const data = await response.json(); console.log(response.status, data); ``` Example request, Python (requests): ```python import requests response = requests.post( "https://assistant.onesite.agency/api/v1/answers", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json", "Idempotency-Key": "3f9d1c52-7b0e-4a53-9a6f-0c2f8e1d77aa", }, json={ "query": "a cold, quiet science fiction novel for a long winter", "language": "en", "in_stock_only": True, "session_id": "visitor-8841", }, ) print(response.status_code, response.json()) ``` Example response · HTTP 200: ```json { "id": "6f1b4a52-3c0d-4d5e-9a7e-2b8c1d0f4e11", "query": "a cold, quiet science fiction novel for a long winter", "intent": "search", "say": "Here is the best match for a cold, quiet read.", "main": { "product": { "id": 101, "title": "The Left Hand of Darkness", "maker": "Ursula K. Le Guin", "line": "Hainish Cycle", "url": "https://alpha.example/products/101", "handle": "the-left-hand-of-darkness", "external_id": "gid://shop/101", "price": { "amount": "12.90", "currency": "EUR" }, "availability": "in_stock", "ready_at": null, "image_url": "https://cdn.alpha.example/101.jpg", "thumb_url": "https://cdn.alpha.example/101-s.jpg" }, "why": "A quiet, snowbound classic - the cold-world mood you described, in one of the genre's best-loved novels.", "why_source": "model" }, "alternatives": [ { "product": { "id": 102, "title": "The Dispossessed", "maker": "Ursula K. Le Guin", "line": "Hainish Cycle", "url": "https://alpha.example/products/102", "handle": "the-dispossessed", "external_id": "gid://shop/102", "price": { "amount": "13.50", "currency": "EUR" }, "availability": "in_stock", "ready_at": null, "image_url": "https://cdn.alpha.example/102.jpg", "thumb_url": "https://cdn.alpha.example/102-s.jpg" }, "why": "Set in the same universe: the politics of a desert moon instead of snow.", "why_source": "model" }, { "product": { "id": 104, "title": "Solaris", "maker": "Stanislaw Lem", "line": null, "url": "https://alpha.example/products/104", "handle": "solaris", "external_id": "gid://shop/104", "price": { "amount": "11.00", "currency": "EUR" }, "availability": "in_stock", "ready_at": null, "image_url": "https://cdn.alpha.example/104.jpg", "thumb_url": "https://cdn.alpha.example/104-s.jpg" }, "why": "A different kind of stillness: an ocean planet that studies the people studying it.", "why_source": "model" } ], "shelves": [], "miss": false, "miss_reason": null, "clarify": null, "degraded": false, "currency": "EUR", "ai": { "generated": true, "fields": [ "say", "main.why", "alternatives[].why" ], "notice": "The `say` and `why` texts are generated by an AI model from this shop's catalogue and may contain mistakes. Product names, prices, stock and links come from the shop's catalogue, not from the model." } } ``` #### Rate limit reached (HTTP 429) Example request, curl: ```bash curl -X POST 'https://assistant.onesite.agency/api/v1/answers' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "query": "a cold, quiet science fiction novel for a long winter" }' ``` Example request, JavaScript (fetch): ```javascript const response = await fetch("https://assistant.onesite.agency/api/v1/answers", { method: "POST", headers: { "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json", }, body: JSON.stringify({ "query": "a cold, quiet science fiction novel for a long winter" }), }); const data = await response.json(); console.log(response.status, data); ``` Example request, Python (requests): ```python import requests response = requests.post( "https://assistant.onesite.agency/api/v1/answers", headers={ "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json", }, json={ "query": "a cold, quiet science fiction novel for a long winter", }, ) print(response.status_code, response.json()) ``` Example response · HTTP 429: ```json { "error": { "type": "rate_limit_error", "code": "rate_limited", "message": "Rate limit reached: 20 calls per minute for this scope.", "param": null, "request_id": "req_9f2b000000000005" } } ``` ### GET /suggestions Fast product suggestions under a search box as someone types. No AI model is involved. Instant products for a half-typed query. No model is called and nothing is journalled, so it is cheap — but it is not a substitute for `POST /answers` when the shopper asked a full question. - Required scope: `suggest` - Limit: 120 calls per 60 s **Parameters** | Name | In | Type | Required | Constraints | Description | |---|---|---|---|---|---| | `q` | `query` | `string` | yes | `length 1–500` | What has been typed so far. | | `limit` | `query` | `integer` | no | `1–20; default 6` | | | `in_stock_only` | `query` | `boolean` | no | `default false` | Only products that can be bought now. | **Responses** | Status | Response schema | Description | |---|---|---| | `200` | `SuggestionList` | Successful Response | | `304` | – | Not modified: your `If-None-Match` matched the current `ETag`. | | `401` | `ErrorBody` | Missing, malformed, unknown or revoked API key (all look the same). | | `402` | `ErrorBody` | The shop's subscription is not active. | | `403` | `ErrorBody` | Key not allowed here: missing scope, IP or origin not on the key's list, plan without API. | | `422` | `ErrorBody` | Invalid request. | | `429` | `ErrorBody` | Rate limit or daily quota reached. See `Retry-After`. | #### Suggestions while the shopper types (HTTP 200) Example request, curl: ```bash curl 'https://assistant.onesite.agency/api/v1/suggestions?q=left+hand&limit=3' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` Example request, JavaScript (fetch): ```javascript const response = await fetch("https://assistant.onesite.agency/api/v1/suggestions?q=left+hand&limit=3", { method: "GET", headers: { "Authorization": "Bearer YOUR_API_KEY", }, }); const data = await response.json(); console.log(response.status, data); ``` Example request, Python (requests): ```python import requests response = requests.get( "https://assistant.onesite.agency/api/v1/suggestions?q=left+hand&limit=3", headers={ "Authorization": "Bearer YOUR_API_KEY", }, ) print(response.status_code, response.json()) ``` Example response · HTTP 200: ```json { "object": "list", "query": "left hand", "data": [ { "id": 101, "title": "The Left Hand of Darkness", "maker": "Ursula K. Le Guin", "line": "Hainish Cycle", "url": "https://alpha.example/products/101", "handle": "the-left-hand-of-darkness", "external_id": "gid://shop/101", "price": { "amount": "12.90", "currency": "EUR" }, "availability": "in_stock", "ready_at": null, "image_url": "https://cdn.alpha.example/101.jpg", "thumb_url": "https://cdn.alpha.example/101-s.jpg" } ] } ``` ### GET /products Read the whole catalogue page by page. The shop's visible catalogue in a stable order (by product `id`), a page at a time. Products the owner has hidden or excluded are not listed. A page may hold fewer than `limit` items while `has_more` is still `true`; keep following `next_cursor` until it is `null`. - Required scope: `catalogue:read` - Limit: 120 calls per 60 s **Parameters** | Name | In | Type | Required | Constraints | Description | |---|---|---|---|---|---| | `limit` | `query` | `integer` | no | `1–100; default 20` | | | `cursor` | `query` | `string \| null` | no | `length ≤ 64` | `next_cursor` of the previous page. | **Responses** | Status | Response schema | Description | |---|---|---| | `200` | `ProductPage` | Successful Response | | `304` | – | Not modified: your `If-None-Match` matched the current `ETag`. | | `401` | `ErrorBody` | Missing, malformed, unknown or revoked API key (all look the same). | | `402` | `ErrorBody` | The shop's subscription is not active. | | `403` | `ErrorBody` | Key not allowed here: missing scope, IP or origin not on the key's list, plan without API. | | `422` | `ErrorBody` | Invalid request. | | `429` | `ErrorBody` | Rate limit or daily quota reached. See `Retry-After`. | #### First page of the catalogue (HTTP 200) Example request, curl: ```bash curl 'https://assistant.onesite.agency/api/v1/products?limit=2' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` Example request, JavaScript (fetch): ```javascript const response = await fetch("https://assistant.onesite.agency/api/v1/products?limit=2", { method: "GET", headers: { "Authorization": "Bearer YOUR_API_KEY", }, }); const data = await response.json(); console.log(response.status, data); ``` Example request, Python (requests): ```python import requests response = requests.get( "https://assistant.onesite.agency/api/v1/products?limit=2", headers={ "Authorization": "Bearer YOUR_API_KEY", }, ) print(response.status_code, response.json()) ``` Example response · HTTP 200: ```json { "object": "list", "data": [ { "id": 101, "title": "The Left Hand of Darkness", "maker": "Ursula K. Le Guin", "line": "Hainish Cycle", "url": "https://alpha.example/products/101", "handle": "the-left-hand-of-darkness", "external_id": "gid://shop/101", "price": { "amount": "12.90", "currency": "EUR" }, "availability": "in_stock", "ready_at": null, "image_url": "https://cdn.alpha.example/101.jpg", "thumb_url": "https://cdn.alpha.example/101-s.jpg" }, { "id": 102, "title": "The Dispossessed", "maker": "Ursula K. Le Guin", "line": "Hainish Cycle", "url": "https://alpha.example/products/102", "handle": "the-dispossessed", "external_id": "gid://shop/102", "price": { "amount": "13.50", "currency": "EUR" }, "availability": "in_stock", "ready_at": null, "image_url": "https://cdn.alpha.example/102.jpg", "thumb_url": "https://cdn.alpha.example/102-s.jpg" } ], "has_more": true, "next_cursor": "c1.MTAy" } ``` #### Following the cursor (HTTP 200) Example request, curl: ```bash curl 'https://assistant.onesite.agency/api/v1/products?limit=2&cursor=c1.MTAy' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` Example request, JavaScript (fetch): ```javascript const response = await fetch("https://assistant.onesite.agency/api/v1/products?limit=2&cursor=c1.MTAy", { method: "GET", headers: { "Authorization": "Bearer YOUR_API_KEY", }, }); const data = await response.json(); console.log(response.status, data); ``` Example request, Python (requests): ```python import requests response = requests.get( "https://assistant.onesite.agency/api/v1/products?limit=2&cursor=c1.MTAy", headers={ "Authorization": "Bearer YOUR_API_KEY", }, ) print(response.status_code, response.json()) ``` Example response · HTTP 200: ```json { "object": "list", "data": [ { "id": 103, "title": "A Wizard of Earthsea", "maker": "Ursula K. Le Guin", "line": "Earthsea", "url": "https://alpha.example/products/103", "handle": "a-wizard-of-earthsea", "external_id": "gid://shop/103", "price": { "amount": "9.99", "currency": "EUR" }, "availability": "in_stock", "ready_at": null, "image_url": "https://cdn.alpha.example/103.jpg", "thumb_url": "https://cdn.alpha.example/103-s.jpg" }, { "id": 104, "title": "Solaris", "maker": "Stanislaw Lem", "line": null, "url": "https://alpha.example/products/104", "handle": "solaris", "external_id": "gid://shop/104", "price": { "amount": "11.00", "currency": "EUR" }, "availability": "in_stock", "ready_at": null, "image_url": "https://cdn.alpha.example/104.jpg", "thumb_url": "https://cdn.alpha.example/104-s.jpg" } ], "has_more": true, "next_cursor": "c1.MTA0" } ``` #### A parameter out of range (HTTP 422) Example request, curl: ```bash curl 'https://assistant.onesite.agency/api/v1/products?limit=500' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` Example request, JavaScript (fetch): ```javascript const response = await fetch("https://assistant.onesite.agency/api/v1/products?limit=500", { method: "GET", headers: { "Authorization": "Bearer YOUR_API_KEY", }, }); const data = await response.json(); console.log(response.status, data); ``` Example request, Python (requests): ```python import requests response = requests.get( "https://assistant.onesite.agency/api/v1/products?limit=500", headers={ "Authorization": "Bearer YOUR_API_KEY", }, ) print(response.status_code, response.json()) ``` Example response · HTTP 422: ```json { "error": { "type": "invalid_request_error", "code": "invalid_request", "message": "Invalid request. limit: Input should be less than or equal to 100", "param": "limit", "request_id": "req_9f2b000000000004" } } ``` ### GET /products/{product_id} Read one product by its id. One product of the shop with its description and gallery. A product that belongs to another shop, does not exist or is hidden by the owner is the same `404`. - Required scope: `catalogue:read` - Limit: 120 calls per 60 s **Parameters** | Name | In | Type | Required | Constraints | Description | |---|---|---|---|---|---| | `product_id` | `path` | `integer` | yes | `1–9223372036854776000` | Product number (`id` in any product object). | **Responses** | Status | Response schema | Description | |---|---|---| | `200` | `ProductDetail` | Successful Response | | `304` | – | Not modified: your `If-None-Match` matched the current `ETag`. | | `401` | `ErrorBody` | Missing, malformed, unknown or revoked API key (all look the same). | | `402` | `ErrorBody` | The shop's subscription is not active. | | `403` | `ErrorBody` | Key not allowed here: missing scope, IP or origin not on the key's list, plan without API. | | `404` | `ErrorBody` | No such visible product in this shop. | | `422` | `ErrorBody` | Invalid request. | | `429` | `ErrorBody` | Rate limit or daily quota reached. See `Retry-After`. | #### One product with its description and gallery (HTTP 200) Example request, curl: ```bash curl 'https://assistant.onesite.agency/api/v1/products/101' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` Example request, JavaScript (fetch): ```javascript const response = await fetch("https://assistant.onesite.agency/api/v1/products/101", { method: "GET", headers: { "Authorization": "Bearer YOUR_API_KEY", }, }); const data = await response.json(); console.log(response.status, data); ``` Example request, Python (requests): ```python import requests response = requests.get( "https://assistant.onesite.agency/api/v1/products/101", headers={ "Authorization": "Bearer YOUR_API_KEY", }, ) print(response.status_code, response.json()) ``` Example response · HTTP 200: ```json { "id": 101, "title": "The Left Hand of Darkness", "maker": "Ursula K. Le Guin", "line": "Hainish Cycle", "url": "https://alpha.example/products/101", "handle": "the-left-hand-of-darkness", "external_id": "gid://shop/101", "price": { "amount": "12.90", "currency": "EUR" }, "availability": "in_stock", "ready_at": null, "image_url": "https://cdn.alpha.example/101.jpg", "thumb_url": "https://cdn.alpha.example/101-s.jpg", "description": "The Left Hand of Darkness by Ursula K. Le Guin.", "images": [ "https://cdn.alpha.example/101.jpg" ] } ``` #### A key without the needed scope (HTTP 403) Example request, curl: ```bash curl 'https://assistant.onesite.agency/api/v1/products/101' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` Example request, JavaScript (fetch): ```javascript const response = await fetch("https://assistant.onesite.agency/api/v1/products/101", { method: "GET", headers: { "Authorization": "Bearer YOUR_API_KEY", }, }); const data = await response.json(); console.log(response.status, data); ``` Example request, Python (requests): ```python import requests response = requests.get( "https://assistant.onesite.agency/api/v1/products/101", headers={ "Authorization": "Bearer YOUR_API_KEY", }, ) print(response.status_code, response.json()) ``` Example response · HTTP 403: ```json { "error": { "type": "permission_error", "code": "insufficient_scope", "message": "This key lacks the 'catalogue:read' scope. Create a key that has it.", "param": "catalogue:read", "request_id": "req_9f2b000000000002" } } ``` #### A product that is not in this shop (HTTP 404) Example request, curl: ```bash curl 'https://assistant.onesite.agency/api/v1/products/987654' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` Example request, JavaScript (fetch): ```javascript const response = await fetch("https://assistant.onesite.agency/api/v1/products/987654", { method: "GET", headers: { "Authorization": "Bearer YOUR_API_KEY", }, }); const data = await response.json(); console.log(response.status, data); ``` Example request, Python (requests): ```python import requests response = requests.get( "https://assistant.onesite.agency/api/v1/products/987654", headers={ "Authorization": "Bearer YOUR_API_KEY", }, ) print(response.status_code, response.json()) ``` Example response · HTTP 404: ```json { "error": { "type": "not_found_error", "code": "product_not_found", "message": "No such product in this shop.", "param": null, "request_id": "req_9f2b000000000003" } } ``` ### GET /products/{product_id}/similar Products similar to one product. No AI model is involved. Neighbours of the product's own embedding vector, ranked the way the shop's assistant ranks them. No model is called. Needs only the `similar` scope — a key without `catalogue:read` cannot fetch the product itself. - Required scope: `similar` - Limit: 60 calls per 60 s **Parameters** | Name | In | Type | Required | Constraints | Description | |---|---|---|---|---|---| | `product_id` | `path` | `integer` | yes | `1–9223372036854776000` | Product number (`id` in any product object). | | `limit` | `query` | `integer` | no | `1–24; default 8` | | **Responses** | Status | Response schema | Description | |---|---|---| | `200` | `SimilarList` | Successful Response | | `304` | – | Not modified: your `If-None-Match` matched the current `ETag`. | | `401` | `ErrorBody` | Missing, malformed, unknown or revoked API key (all look the same). | | `402` | `ErrorBody` | The shop's subscription is not active. | | `403` | `ErrorBody` | Key not allowed here: missing scope, IP or origin not on the key's list, plan without API. | | `404` | `ErrorBody` | No such visible product in this shop. | | `422` | `ErrorBody` | Invalid request. | | `429` | `ErrorBody` | Rate limit or daily quota reached. See `Retry-After`. | #### Products similar to one product (HTTP 200) Example request, curl: ```bash curl 'https://assistant.onesite.agency/api/v1/products/101/similar?limit=3' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` Example request, JavaScript (fetch): ```javascript const response = await fetch("https://assistant.onesite.agency/api/v1/products/101/similar?limit=3", { method: "GET", headers: { "Authorization": "Bearer YOUR_API_KEY", }, }); const data = await response.json(); console.log(response.status, data); ``` Example request, Python (requests): ```python import requests response = requests.get( "https://assistant.onesite.agency/api/v1/products/101/similar?limit=3", headers={ "Authorization": "Bearer YOUR_API_KEY", }, ) print(response.status_code, response.json()) ``` Example response · HTTP 200: ```json { "object": "list", "data": [ { "id": 102, "title": "The Dispossessed", "maker": "Ursula K. Le Guin", "line": "Hainish Cycle", "url": "https://alpha.example/products/102", "handle": "the-dispossessed", "external_id": "gid://shop/102", "price": { "amount": "13.50", "currency": "EUR" }, "availability": "in_stock", "ready_at": null, "image_url": "https://cdn.alpha.example/102.jpg", "thumb_url": "https://cdn.alpha.example/102-s.jpg" }, { "id": 103, "title": "A Wizard of Earthsea", "maker": "Ursula K. Le Guin", "line": "Earthsea", "url": "https://alpha.example/products/103", "handle": "a-wizard-of-earthsea", "external_id": "gid://shop/103", "price": { "amount": "9.99", "currency": "EUR" }, "availability": "in_stock", "ready_at": null, "image_url": "https://cdn.alpha.example/103.jpg", "thumb_url": "https://cdn.alpha.example/103-s.jpg" }, { "id": 109, "title": "Gift Cookbook", "maker": "Chef Example", "line": null, "url": "https://alpha.example/products/109", "handle": "gift-cookbook", "external_id": "gid://shop/109", "price": { "amount": "24.00", "currency": "EUR" }, "availability": "in_stock", "ready_at": null, "image_url": "https://cdn.alpha.example/109.jpg", "thumb_url": "https://cdn.alpha.example/109-s.jpg" } ], "product_id": 101 } ``` ### GET /analytics/summary Your shop's totals for a window — the same figures as the cabinet's Statistics — with an optional comparison. The cabinet's Statistics figures for the window: questions, people who asked, sessions, found and not found, clicks, and the shopper's path by step. Counted for people only. With `compare`, the same totals for the comparison window — only when the shop's history covers it; otherwise `previous` is `null` rather than a growth from nothing. - Required scope: `analytics:read` - Limit: 30 calls per 60 s **Parameters** | Name | In | Type | Required | Constraints | Description | |---|---|---|---|---|---| | `compare` | `query` | `"prev" \| "year" \| "none"` | no | `default "prev"` | Compare with the window just before (`prev`), the same window a year back (`year`), or nothing. | | `since` | `query` | `string (date-time) \| null` | no | | Start of the window (ISO 8601; without an offset — UTC). Default: `until` minus 30 days. | | `until` | `query` | `string (date-time) \| null` | no | | End of the window, exclusive (ISO 8601). Default: now. | | `first_day` | `query` | `string (date) \| null` | no | | First day of the window (`YYYY-MM-DD`) in `timezone`, inclusive. Use with `last_day` instead of `since`/`until`. | | `last_day` | `query` | `string (date) \| null` | no | | Last day of the window (`YYYY-MM-DD`) in `timezone`, inclusive. | | `timezone` | `query` | `string \| null` | no | `length ≤ 64` | IANA time zone for days, e.g. `Europe/Berlin`. Default: the shop's time zone, or UTC when the shop has none. | **Responses** | Status | Response schema | Description | |---|---|---| | `200` | `AnalyticsSummary` | Successful Response | | `304` | – | Not modified: your `If-None-Match` matched the current `ETag`. | | `401` | `ErrorBody` | Missing, malformed, unknown or revoked API key (all look the same). | | `402` | `ErrorBody` | The shop's subscription is not active. | | `403` | `ErrorBody` | Key not allowed here: missing scope, IP or origin not on the key's list, plan without API. | | `422` | `ErrorBody` | Invalid request. | | `429` | `ErrorBody` | Rate limit or daily quota reached. See `Retry-After`. | #### Last week in figures, compared with the week before (HTTP 200) Example request, curl: ```bash curl 'https://assistant.onesite.agency/api/v1/analytics/summary?first_day=2026-10-06&last_day=2026-10-12' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` Example request, JavaScript (fetch): ```javascript const response = await fetch("https://assistant.onesite.agency/api/v1/analytics/summary?first_day=2026-10-06&last_day=2026-10-12", { method: "GET", headers: { "Authorization": "Bearer YOUR_API_KEY", }, }); const data = await response.json(); console.log(response.status, data); ``` Example request, Python (requests): ```python import requests response = requests.get( "https://assistant.onesite.agency/api/v1/analytics/summary?first_day=2026-10-06&last_day=2026-10-12", headers={ "Authorization": "Bearer YOUR_API_KEY", }, ) print(response.status_code, response.json()) ``` Example response · HTTP 200: ```json { "object": "analytics.summary", "window": { "since": "2026-10-06T00:00:00+00:00", "until": "2026-10-13T00:00:00+00:00", "timezone": "UTC" }, "totals": { "queries": 280, "people": 154, "sessions": 174, "answered": 238, "missed": 42, "asked_again": 31, "impressions": 840, "clicks": 82, "reached": 63, "browsed": 7 }, "compare": "prev", "previous_window": { "since": "2026-09-29T00:00:00+00:00", "until": "2026-10-06T00:00:00+00:00" }, "previous": { "queries": 273, "people": 150, "sessions": 174, "answered": 238, "missed": 42, "asked_again": 31, "impressions": 840, "clicks": 82, "reached": 63, "browsed": 7 }, "funnel": [ { "stage": "visit", "sessions": 900, "share_of_previous": null }, { "stage": "offered", "sessions": 860, "share_of_previous": 0.9555555555555556 }, { "stage": "opened", "sessions": 140, "share_of_previous": 0.16279069767441862 }, { "stage": "asked", "sessions": 120, "share_of_previous": 0.8571428571428571 }, { "stage": "reached", "sessions": 30, "share_of_previous": 0.25 }, { "stage": "carted", "sessions": 70, "share_of_previous": null }, { "stage": "bought", "sessions": 12, "share_of_previous": 0.17142857142857143 } ], "funnel_since": null } ``` ### GET /analytics/series Questions, misses and questions that reached a product, for every day of the window. Questions, misses and questions that reached a product, for every day of the window in `timezone`. Days without questions are zeros. Only the window is counted, so the first and last day may be partial, and the days add up to the summary's totals. - Required scope: `analytics:read` - Limit: 30 calls per 60 s **Parameters** | Name | In | Type | Required | Constraints | Description | |---|---|---|---|---|---| | `since` | `query` | `string (date-time) \| null` | no | | Start of the window (ISO 8601; without an offset — UTC). Default: `until` minus 30 days. | | `until` | `query` | `string (date-time) \| null` | no | | End of the window, exclusive (ISO 8601). Default: now. | | `first_day` | `query` | `string (date) \| null` | no | | First day of the window (`YYYY-MM-DD`) in `timezone`, inclusive. Use with `last_day` instead of `since`/`until`. | | `last_day` | `query` | `string (date) \| null` | no | | Last day of the window (`YYYY-MM-DD`) in `timezone`, inclusive. | | `timezone` | `query` | `string \| null` | no | `length ≤ 64` | IANA time zone for days, e.g. `Europe/Berlin`. Default: the shop's time zone, or UTC when the shop has none. | **Responses** | Status | Response schema | Description | |---|---|---| | `200` | `AnalyticsSeries` | Successful Response | | `304` | – | Not modified: your `If-None-Match` matched the current `ETag`. | | `401` | `ErrorBody` | Missing, malformed, unknown or revoked API key (all look the same). | | `402` | `ErrorBody` | The shop's subscription is not active. | | `403` | `ErrorBody` | Key not allowed here: missing scope, IP or origin not on the key's list, plan without API. | | `422` | `ErrorBody` | Invalid request. | | `429` | `ErrorBody` | Rate limit or daily quota reached. See `Retry-After`. | #### Questions per day (HTTP 200) Example request, curl: ```bash curl 'https://assistant.onesite.agency/api/v1/analytics/series?first_day=2026-10-06&last_day=2026-10-12&timezone=Europe/Berlin' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` Example request, JavaScript (fetch): ```javascript const response = await fetch("https://assistant.onesite.agency/api/v1/analytics/series?first_day=2026-10-06&last_day=2026-10-12&timezone=Europe/Berlin", { method: "GET", headers: { "Authorization": "Bearer YOUR_API_KEY", }, }); const data = await response.json(); console.log(response.status, data); ``` Example request, Python (requests): ```python import requests response = requests.get( "https://assistant.onesite.agency/api/v1/analytics/series?first_day=2026-10-06&last_day=2026-10-12&timezone=Europe/Berlin", headers={ "Authorization": "Bearer YOUR_API_KEY", }, ) print(response.status_code, response.json()) ``` Example response · HTTP 200: ```json { "object": "analytics.series", "window": { "since": "2026-10-05T22:00:00+00:00", "until": "2026-10-12T22:00:00+00:00", "timezone": "Europe/Berlin" }, "data": [ { "date": "2026-10-06", "queries": 40, "missed": 6, "reached": 9 }, { "date": "2026-10-07", "queries": 40, "missed": 6, "reached": 9 }, { "date": "2026-10-08", "queries": 40, "missed": 6, "reached": 9 }, { "date": "2026-10-09", "queries": 40, "missed": 6, "reached": 9 }, { "date": "2026-10-10", "queries": 40, "missed": 6, "reached": 9 }, { "date": "2026-10-11", "queries": 40, "missed": 6, "reached": 9 }, { "date": "2026-10-12", "queries": 40, "missed": 6, "reached": 9 } ] } ``` ### GET /analytics/queries The questions people ask most, and the questions most often left without a product. The questions people ask most, and the questions most often left without a product — what to stock and what to describe better. Questions are grouped without regard to case; e-mail addresses and phone or card numbers in them are masked. - Required scope: `analytics:read` - Limit: 30 calls per 60 s **Parameters** | Name | In | Type | Required | Constraints | Description | |---|---|---|---|---|---| | `limit` | `query` | `integer` | no | `1–100; default 20` | Rows in each list. | | `since` | `query` | `string (date-time) \| null` | no | | Start of the window (ISO 8601; without an offset — UTC). Default: `until` minus 30 days. | | `until` | `query` | `string (date-time) \| null` | no | | End of the window, exclusive (ISO 8601). Default: now. | | `first_day` | `query` | `string (date) \| null` | no | | First day of the window (`YYYY-MM-DD`) in `timezone`, inclusive. Use with `last_day` instead of `since`/`until`. | | `last_day` | `query` | `string (date) \| null` | no | | Last day of the window (`YYYY-MM-DD`) in `timezone`, inclusive. | | `timezone` | `query` | `string \| null` | no | `length ≤ 64` | IANA time zone for days, e.g. `Europe/Berlin`. Default: the shop's time zone, or UTC when the shop has none. | **Responses** | Status | Response schema | Description | |---|---|---| | `200` | `AnalyticsQueries` | Successful Response | | `304` | – | Not modified: your `If-None-Match` matched the current `ETag`. | | `401` | `ErrorBody` | Missing, malformed, unknown or revoked API key (all look the same). | | `402` | `ErrorBody` | The shop's subscription is not active. | | `403` | `ErrorBody` | Key not allowed here: missing scope, IP or origin not on the key's list, plan without API. | | `422` | `ErrorBody` | Invalid request. | | `429` | `ErrorBody` | Rate limit or daily quota reached. See `Retry-After`. | #### What people ask most, and what they do not find (HTTP 200) Example request, curl: ```bash curl 'https://assistant.onesite.agency/api/v1/analytics/queries?first_day=2026-10-06&last_day=2026-10-12&limit=3' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` Example request, JavaScript (fetch): ```javascript const response = await fetch("https://assistant.onesite.agency/api/v1/analytics/queries?first_day=2026-10-06&last_day=2026-10-12&limit=3", { method: "GET", headers: { "Authorization": "Bearer YOUR_API_KEY", }, }); const data = await response.json(); console.log(response.status, data); ``` Example request, Python (requests): ```python import requests response = requests.get( "https://assistant.onesite.agency/api/v1/analytics/queries?first_day=2026-10-06&last_day=2026-10-12&limit=3", headers={ "Authorization": "Bearer YOUR_API_KEY", }, ) print(response.status_code, response.json()) ``` Example response · HTTP 200: ```json { "object": "analytics.queries", "window": { "since": "2026-10-06T00:00:00+00:00", "until": "2026-10-13T00:00:00+00:00", "timezone": "UTC" }, "top": [ { "text": "science fiction for winter", "count": 42, "missed": 0, "clicks": 28 }, { "text": "gift for a ten-year-old", "count": 28, "missed": 7, "clicks": 14 }, { "text": "write to me at [email]", "count": 1, "missed": 1, "clicks": 0 } ], "missed": [ { "text": "signed first editions", "count": 21, "missed": 21, "clicks": 0 }, { "text": "gift for a ten-year-old", "count": 28, "missed": 7, "clicks": 14 } ] } ``` ### GET /analytics/sales What the assistant sold, by your own orders — the same figures as the cabinet's Purchases summary. Orders and revenue the assistant helped with, by the shop's own orders: products its button put into the cart and products bought after it showed them. Orders after a conversation without either are counted apart and not as its revenue. The shop's total turnover is not part of this API. - Required scope: `analytics:read` - Limit: 30 calls per 60 s **Parameters** | Name | In | Type | Required | Constraints | Description | |---|---|---|---|---|---| | `days` | `query` | `integer` | no | `default 30` | Period: today and the days before it. One of 7, 30, 90. | | `timezone` | `query` | `string \| null` | no | `length ≤ 64` | IANA time zone for days. Default: the shop's time zone, or UTC. | **Responses** | Status | Response schema | Description | |---|---|---| | `200` | `AnalyticsSales` | Successful Response | | `304` | – | Not modified: your `If-None-Match` matched the current `ETag`. | | `401` | `ErrorBody` | Missing, malformed, unknown or revoked API key (all look the same). | | `402` | `ErrorBody` | The shop's subscription is not active. | | `403` | `ErrorBody` | Key not allowed here: missing scope, IP or origin not on the key's list, plan without API. | | `422` | `ErrorBody` | Invalid request. | | `429` | `ErrorBody` | Rate limit or daily quota reached. See `Retry-After`. | #### What the assistant sold in the last 7 days (HTTP 200) Example request, curl: ```bash curl 'https://assistant.onesite.agency/api/v1/analytics/sales?days=7' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` Example request, JavaScript (fetch): ```javascript const response = await fetch("https://assistant.onesite.agency/api/v1/analytics/sales?days=7", { method: "GET", headers: { "Authorization": "Bearer YOUR_API_KEY", }, }); const data = await response.json(); console.log(response.status, data); ``` Example request, Python (requests): ```python import requests response = requests.get( "https://assistant.onesite.agency/api/v1/analytics/sales?days=7", headers={ "Authorization": "Bearer YOUR_API_KEY", }, ) print(response.status_code, response.json()) ``` Example response · HTTP 200: ```json { "object": "analytics.sales", "days": 7, "since": "2026-10-07T00:00:00+00:00", "timezone": "UTC", "currency": "EUR", "assistant_orders": 2, "assistant_revenue": { "amount": "23.90", "currency": "EUR" }, "button_revenue": { "amount": "12.90", "currency": "EUR" }, "shown_revenue": { "amount": "11.00", "currency": "EUR" }, "carted": 3, "talked_orders": 1, "average_order": { "amount": "18.70", "currency": "EUR" }, "days_data": [ { "date": "2026-10-07", "orders": 0, "revenue": { "amount": "0.00", "currency": "EUR" } }, { "date": "2026-10-08", "orders": 0, "revenue": { "amount": "0.00", "currency": "EUR" } }, { "date": "2026-10-09", "orders": 0, "revenue": { "amount": "0.00", "currency": "EUR" } }, { "date": "2026-10-10", "orders": 0, "revenue": { "amount": "0.00", "currency": "EUR" } }, { "date": "2026-10-11", "orders": 1, "revenue": { "amount": "11.00", "currency": "EUR" } }, { "date": "2026-10-12", "orders": 0, "revenue": { "amount": "0.00", "currency": "EUR" } }, { "date": "2026-10-13", "orders": 1, "revenue": { "amount": "12.90", "currency": "EUR" } } ], "products": [ { "product_id": 101, "title": "The Left Hand of Darkness", "quantity": 1, "revenue": { "amount": "12.90", "currency": "EUR" } }, { "product_id": 104, "title": "Solaris", "quantity": 1, "revenue": { "amount": "11.00", "currency": "EUR" } } ] } ``` ## Data types The objects used in requests and responses. `| null` means the field is always present but may be `null`; an array is written `Type[]`. ### AiDisclosure Shown to your users? Then say so. The wording below is the minimum we ask integrations to pass on. **AiDisclosure** | Field | Type | Required | Description | |---|---|---|---| | `generated` | `boolean` | no | Some text in this response was written by an AI model. | | `fields` | `string[]` | yes | JSON paths of the AI-written strings. | | `notice` | `string` | yes | | ### AnalyticsFunnelStep **AnalyticsFunnelStep** | Field | Type | Required | Description | |---|---|---|---| | `stage` | `"visit" \| "offered" \| "opened" \| "asked" \| "reached" \| "carted" \| "bought"` | yes | | | `sessions` | `integer` | yes | Sessions that reached this step. Sessions, not people: events on the site carry no visitor mark. | | `share_of_previous` | `number \| null` | no | `sessions` divided by the previous step. `null` on the first step and where a step is larger than the one before it (for example, carts added with the shop's own button without a click in an answer). | ### AnalyticsPeriod **AnalyticsPeriod** | Field | Type | Required | Description | |---|---|---|---| | `since` | `string` | yes | | | `until` | `string` | yes | | ### AnalyticsPoint **AnalyticsPoint** | Field | Type | Required | Description | |---|---|---|---| | `date` | `string` | yes | The day (`YYYY-MM-DD`) in `window.timezone`. The first and last day may be partial: only the window is counted. | | `queries` | `integer` | yes | | | `missed` | `integer` | yes | | | `reached` | `integer` | yes | Questions of the day after which the shopper opened a product page from the answer. | ### AnalyticsQueries **AnalyticsQueries** | Field | Type | Required | Description | |---|---|---|---| | `object` | `"analytics.queries"` | no | | | `window` | `AnalyticsWindow` | yes | | | `top` | `AnalyticsQuery[]` | yes | Most frequent questions, most frequent first. | | `missed` | `AnalyticsQuery[]` | yes | Questions most often left without a product, by number of misses. Its own list, not a filter of `top`. | ### AnalyticsQuery **AnalyticsQuery** | Field | Type | Required | Description | |---|---|---|---| | `text` | `string` | yes | The question as asked (case-insensitive group). E-mail addresses, phone and card numbers are masked. | | `count` | `integer` | yes | How many times it was asked. | | `missed` | `integer` | yes | How many of those had nothing to show. | | `clicks` | `integer` | yes | Clicks from its answers to product pages. | ### AnalyticsSales What the assistant sold, by the shop's own orders — the same figures as the summary in the cabinet's Purchases section. **AnalyticsSales** | Field | Type | Required | Description | |---|---|---|---| | `object` | `"analytics.sales"` | no | | | `days` | `integer` | yes | Today and the days before it, from midnight in `timezone`. | | `since` | `string` | yes | Midnight of the first day (ISO 8601). | | `timezone` | `string` | yes | | | `currency` | `string` | yes | Orders in another currency are not added up. | | `assistant_orders` | `integer` | yes | Orders with a product added to the cart by the assistant's button or bought after the assistant showed it. | | `assistant_revenue` | `Money` | yes | The value of those products (not the whole orders). | | `button_revenue` | `Money` | yes | Part of `assistant_revenue` added to the cart by the assistant's button. | | `shown_revenue` | `Money` | yes | Part of `assistant_revenue` bought after the assistant showed the product. | | `carted` | `integer` | yes | Products the assistant helped to put into a cart in the period (by its button or after showing them). | | `talked_orders` | `integer` | yes | Orders placed after a conversation with the assistant without any of the above. Not counted as its revenue. | | `average_order` | `Money \| null` | no | Average total of `assistant_orders`; `null` when none has a total. | | `days_data` | `AnalyticsSalesDay[]` | yes | Every day of the period, empty days included. | | `products` | `AnalyticsSoldProduct[]` | yes | Top products sold through the assistant, by revenue. | ### AnalyticsSalesDay **AnalyticsSalesDay** | Field | Type | Required | Description | |---|---|---|---| | `date` | `string` | yes | The day (`YYYY-MM-DD`) in `timezone`. | | `orders` | `integer` | yes | | | `revenue` | `Money` | yes | | ### AnalyticsSeries **AnalyticsSeries** | Field | Type | Required | Description | |---|---|---|---| | `object` | `"analytics.series"` | no | | | `window` | `AnalyticsWindow` | yes | | | `data` | `AnalyticsPoint[]` | yes | Every day of the window, days without questions included as zeros. The sum of `queries` equals `totals.queries` of the summary. | ### AnalyticsSoldProduct **AnalyticsSoldProduct** | Field | Type | Required | Description | |---|---|---|---| | `product_id` | `integer \| null` | no | `null` when the product is no longer in the catalogue. | | `title` | `string \| null` | no | | | `quantity` | `integer` | yes | | | `revenue` | `Money` | yes | | ### AnalyticsSummary **AnalyticsSummary** | Field | Type | Required | Description | |---|---|---|---| | `object` | `"analytics.summary"` | no | | | `window` | `AnalyticsWindow` | yes | | | `totals` | `AnalyticsTotals` | yes | | | `compare` | `"prev" \| "year" \| "none"` | yes | | | `previous_window` | `AnalyticsPeriod \| null` | no | The window compared with. `null` when `compare` is `none` or the shop's history covers less than 90 % of it. | | `previous` | `AnalyticsTotals \| null` | no | Totals of `previous_window`, counted the same way. `null` — no comparison. | | `funnel` | `AnalyticsFunnelStep[]` | yes | Sessions by step of the shopper's path, in order. | | `funnel_since` | `string \| null` | no | Set when the funnel is counted from a later moment than `window.since`: before it one of the steps was not yet recorded. | ### AnalyticsTotals Counted for people only: bots, our own checks, calls made with API keys and conversations removed by the operator are not counted. **AnalyticsTotals** | Field | Type | Required | Description | |---|---|---|---| | `queries` | `integer` | yes | Questions people asked the assistant, in their own words. | | `people` | `integer` | yes | Different people who asked at least once, by the visitor mark in their browser: someone back three days later is one person, the same person on phone and laptop is two. A question without the mark (no analytics consent) counts its session. | | `sessions` | `integer` | yes | Different sessions with at least one question. A session lasts about a day, so this is not people. | | `answered` | `integer` | yes | Questions answered with at least one product. | | `missed` | `integer` | yes | Questions with nothing to show. | | `asked_again` | `integer` | yes | Sessions that asked another question after an answer. | | `impressions` | `integer` | yes | Product cards shown in answers. | | `clicks` | `integer` | yes | Clicks from answer cards to product pages. | | `reached` | `integer` | yes | Questions after which the shopper opened a product page from the answer. Counted per question, not per click. | | `browsed` | `integer` | yes | Clicks to product pages from shelves and collections, with no question before them. | ### AnalyticsWindow **AnalyticsWindow** | Field | Type | Required | Description | |---|---|---|---| | `since` | `string` | yes | Start of the window, inclusive (ISO 8601, UTC). | | `until` | `string` | yes | End of the window, exclusive (ISO 8601, UTC). | | `timezone` | `string` | yes | IANA time zone in which days are cut (daily series, `first_day`/`last_day`). | ### Answer **Answer** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Id of this answer in the shop's journal. Quote it when you report a problem. | | `query` | `string` | yes | | | `intent` | `string` | yes | What the question was understood to be: `search`, `browse`, `basket`, `question`, `greeting`, `off_topic`, `code`, `about_item`, `order`…. New values may appear; treat unknown ones as `search`. | | `say` | `string \| null` | no | A short spoken-style reply, written by an AI model. May be `null`. | | `main` | `Recommendation \| null` | no | The single best match, when there is one. | | `alternatives` | `Recommendation[]` | no | | | `shelves` | `Shelf[]` | no | For “everything by …” questions: whole shelves instead of picks. | | `miss` | `boolean` | yes | `true` when the shop has nothing that fits. Do not invent a product then. | | `miss_reason` | `"absent" \| "unstocked" \| "unmatched" \| null` | no | Why nothing is shown: not in the catalogue (`absent`), sold out (`unstocked`), or nothing matched the question's hard limits such as price (`unmatched`). | | `clarify` | `string \| null` | no | A clarifying question to ask the user, when the answer needs one. | | `degraded` | `boolean` | no | `true` when the answer was built without the model (it was unavailable). | | `currency` | `string` | yes | | | `ai` | `AiDisclosure` | yes | | ### AnswerRequest **AnswerRequest** | Field | Type | Required | Description | |---|---|---|---| | `query` | `string` | yes | The shopper's question, in their own words. | | `language` | `Language \| null` | no | Language of the reply (`ru en el es de it fr`). Defaults to the shop's language. | | `session_id` | `string \| null` | no | Opaque id of one conversation. Send the same id to let follow-ups (“cheaper”, “something lighter”) build on earlier turns. Sessions are private to your API key. | | `in_stock_only` | `boolean` | no | Only products that can be bought now (excludes pre-orders). | ### ErrorBody **ErrorBody** | Field | Type | Required | Description | |---|---|---|---| | `error` | `ErrorDetail` | yes | | ### ErrorDetail **ErrorDetail** | Field | Type | Required | Description | |---|---|---|---| | `type` | `"authentication_error" \| "permission_error" \| "invalid_request_error" \| "not_found_error" \| "conflict_error" \| "rate_limit_error" \| "billing_error" \| "api_error"` | yes | | | `code` | `string` | yes | Stable machine-readable reason. Branch on this, not on `message`. | | `message` | `string` | yes | For humans. The wording may change between releases. | | `param` | `string \| null` | no | The offending field or parameter, for `invalid_request`. | | `request_id` | `string` | yes | Also in the `X-Request-Id` response header. | ### KeyInfo **KeyInfo** | Field | Type | Required | Description | |---|---|---|---| | `id` | `integer` | yes | | | `name` | `string` | yes | | | `hint` | `string` | yes | Which key this is, e.g. `osk_…x9Zq`. The secret itself is never shown again. | | `scopes` | `string[]` | yes | | | `shop` | `KeyShop` | yes | | | `origins` | `string[]` | yes | Browser origins allowed to use this key. Empty = any (server-side use). | | `networks` | `string[]` | yes | IP networks the key is accepted from. Empty = any. | | `created_at` | `string` | yes | | | `last_used_at` | `string \| null` | no | | | `limits` | `map` | yes | Calls per minute, per scope. | | `answers_per_day` | `integer` | yes | Daily cap on `POST /answers` calls for this key (UTC day). | ### KeyShop **KeyShop** | Field | Type | Required | Description | |---|---|---|---| | `domain` | `string` | yes | | | `currency` | `string` | yes | | | `language` | `string` | yes | | ### Language Language code: `ru`, `en`, `el`, `es`, `de`, `it`, `fr`. `"ru" | "en" | "el" | "es" | "de" | "it" | "fr"` ### Money **Money** | Field | Type | Required | Description | |---|---|---|---| | `amount` | `string` | yes | Decimal amount as a string, e.g. `"12.90"`. Never a float: no rounding surprises. | | `currency` | `string` | yes | ISO 4217 code of the shop currency, e.g. `EUR`. | ### Product A catalogue card. Everything here comes from the shop's own catalogue, never from the model. **Product** | Field | Type | Required | Description | |---|---|---|---| | `id` | `integer` | yes | Product number in this shop's catalogue. Stable until the product is removed from the shop. | | `title` | `string` | yes | | | `maker` | `string \| null` | no | Whoever the shop credits as the maker of the product. | | `line` | `string \| null` | no | Series, collection or product line, when the shop names one. | | `url` | `string \| null` | no | Product page on the shop's own site. | | `handle` | `string \| null` | no | The platform's product handle (Shopify handle, WooCommerce slug), when known. | | `external_id` | `string \| null` | no | The shop platform's own product id, for mapping back to your system. | | `price` | `Money \| null` | no | Current price; `null` when the shop does not publish one. | | `availability` | `"in_stock" \| "preorder" \| "out_of_stock"` | yes | | | `ready_at` | `string \| null` | no | For `preorder`: the shop's promised ship date (ISO 8601). | | `image_url` | `string \| null` | no | | | `thumb_url` | `string \| null` | no | A smaller image, when the shop has one. | ### ProductDetail **ProductDetail** | Field | Type | Required | Description | |---|---|---|---| | `id` | `integer` | yes | Product number in this shop's catalogue. Stable until the product is removed from the shop. | | `title` | `string` | yes | | | `maker` | `string \| null` | no | Whoever the shop credits as the maker of the product. | | `line` | `string \| null` | no | Series, collection or product line, when the shop names one. | | `url` | `string \| null` | no | Product page on the shop's own site. | | `handle` | `string \| null` | no | The platform's product handle (Shopify handle, WooCommerce slug), when known. | | `external_id` | `string \| null` | no | The shop platform's own product id, for mapping back to your system. | | `price` | `Money \| null` | no | Current price; `null` when the shop does not publish one. | | `availability` | `"in_stock" \| "preorder" \| "out_of_stock"` | yes | | | `ready_at` | `string \| null` | no | For `preorder`: the shop's promised ship date (ISO 8601). | | `image_url` | `string \| null` | no | | | `thumb_url` | `string \| null` | no | A smaller image, when the shop has one. | | `description` | `string \| null` | no | | | `images` | `string[]` | no | The shop's gallery, in the shop's order. | ### ProductPage **ProductPage** | Field | Type | Required | Description | |---|---|---|---| | `object` | `"list"` | no | | | `data` | `Product[]` | yes | | | `has_more` | `boolean` | yes | | | `next_cursor` | `string \| null` | no | Pass as `cursor` to get the next page. `null` on the last page. | ### Recommendation **Recommendation** | Field | Type | Required | Description | |---|---|---|---| | `product` | `Product` | yes | | | `why` | `string \| null` | no | One sentence on why this fits the question. Written by an AI model when `why_source` is `model`; a fixed catalogue template when it is `catalogue`. | | `why_source` | `"model" \| "catalogue" \| null` | no | Who wrote `why`. `null` when there is no `why`. | ### ScopeLimit **ScopeLimit** | Field | Type | Required | Description | |---|---|---|---| | `per_minute` | `integer` | yes | | ### Shelf **Shelf** | Field | Type | Required | Description | |---|---|---|---| | `title` | `string` | yes | | | `total` | `integer` | yes | How many products the shelf holds in the shop — not how many are shown in `items`. | | `items` | `Product[]` | yes | | ### SimilarList **SimilarList** | Field | Type | Required | Description | |---|---|---|---| | `object` | `"list"` | no | | | `data` | `Product[]` | yes | | | `product_id` | `integer` | yes | | ### SuggestionList **SuggestionList** | Field | Type | Required | Description | |---|---|---|---| | `object` | `"list"` | no | | | `query` | `string` | yes | | | `data` | `Product[]` | yes | | ## Errors Every error has the same shape and a `request_id` (also in the `X-Request-Id` header): quote it when you write to us. Branch on `code`, never on `message`, whose wording can change. Errors · HTTP 422: ```json { "error": { "type": "invalid_request_error", "code": "invalid_request", "message": "Invalid request. limit: Input should be less than or equal to 100", "param": "limit", "request_id": "req_9f2b000000000004" } } ``` **Error codes** | Code | HTTP | What it means and what to do | Retry | |---|---|---|---| | `invalid_api_key` | `401` | The key is missing, malformed, unknown or revoked. All four look the same on purpose. Send `Authorization: Bearer ` or create a new key. | fix the request | | `ip_not_allowed` | `403` | The key has an IP allow-list and this address is not on it. Call from a listed network or change the list in the cabinet. | fix the request | | `origin_not_allowed` | `403` | The call comes from a browser page whose origin is not on the key's origin list. Add the exact origin (scheme, host, port) in the cabinet. | fix the request | | `insufficient_scope` | `403` | The key does not have the scope this endpoint needs. Scopes cannot be widened: create a new key with the scope. | fix the request | | `api_disabled` | `403` | The developer API is not switched on for this shop. Open the Developers tab in the cabinet, or contact us if you do not see it. | fix the request | | `subscription_inactive` | `402` | The shop's subscription is not active, so the API is paused. It resumes by itself when the subscription is active again; the keys stay valid. | fix the request | | `plan_not_entitled` | `403` | The shop's plan does not include the developer API. It is available on plans that include it. | fix the request | | `rate_limited` | `429` | The per-minute limit for this key and scope is used up. Wait for `Retry-After` seconds and retry; spread calls out or cache results. | later | | `daily_quota_exceeded` | `429` | The daily cap on answers for this key is reached (UTC day). Retry after `Retry-After` seconds, i.e. after the day rolls over. | later | | `too_many_failed_attempts` | `429` | Too many invalid keys came from this address. Wait for `Retry-After` seconds, and check which key your code sends. | later | | `invalid_request` | `422` | A parameter or body field is missing or out of range. `param` names the first offender; fix the request, retrying will not help. | fix the request | | `invalid_cursor` | `422` | The cursor was altered or does not belong to this list. Pass `next_cursor` exactly as received, or start again without a cursor. | fix the request | | `product_not_found` | `404` | There is no such product in this shop. Another shop's product id answers the same: a key reads only its own shop. | fix the request | | `idempotency_key_reused` | `422` | This `Idempotency-Key` was already used with a different body. Use a new key for a different question. | fix the request | | `idempotency_in_progress` | `409` | A request with the same `Idempotency-Key` is still being processed. Wait a moment and send the same request again to get its result. | later | | `request_too_large` | `413` | The request body is larger than the limit. Send a smaller body. | fix the request | | `answer_unavailable` | `502` | The answer could not be produced right now. Retry shortly with the same `Idempotency-Key`. | later | | `internal_error` | `500` | Something went wrong on our side. Retry with a pause; if it persists, send us the `request_id`. | later | | `not_found` | `404` | No such endpoint. Check the path against the reference; paths start with the version, `/api/v1`. | fix the request | | `method_not_allowed` | `405` | The path exists but not for this HTTP method. The `Allow` header lists the allowed ones. | fix the request | `type` groups codes: `authentication_error`, `permission_error`, `invalid_request_error`, `not_found_error`, `conflict_error`, `rate_limit_error`, `billing_error`, `api_error`. New codes can appear within `v1`; treat an unknown `code` by its HTTP status. ## Pagination Lists use cursors. Pass `next_cursor` from a response as `cursor` in the next request until `next_cursor` is `null`. `limit` defaults to 20 and is at most 100. A page may hold fewer items than `limit` while `has_more` is `true`, so follow `next_cursor` rather than counting. Treat a cursor as opaque: an altered one gives `422` `invalid_cursor`. ## Caching and ETag `GET` responses carry an `ETag`. Send it back as `If-None-Match`; if nothing changed you get `304 Not Modified` with no body, which is faster and lighter than downloading the list again. Cache catalogue reads on your side and revalidate this way. ## Idempotency and retries `POST /answers` accepts an `Idempotency-Key` header (1 to 64 characters: letters, digits and `. _ : -`). Repeating the same body within 24 hours returns the first answer without a second model call and without using quota; the replay carries `Idempotent-Replayed: true`. The same key with a different body is `422` `idempotency_key_reused`; while the first request still runs you get `409` `idempotency_in_progress`. Create a fresh UUID for each user question and reuse it only for retries of that very question. Request bodies are limited to 16384 bytes (`413` `request_too_large`). ## Browsers and CORS A web page can call the API only from an origin listed on the key (scheme, host and port, for example `https://shop.example`); the API sends CORS headers only to listed origins. Anything in a page's code is public, so a key used there can be copied by anyone who opens the page. So for browser use give the key only the `suggest` and `similar` scopes and an origin list, and keep `answer` keys on your server. ## Analytics The `analytics:read` scope opens your shop's own figures: the same totals, daily series, top questions and assistant sales as the cabinet, computed by the same code. Give it only to keys that stay on your server. Only people are counted: bots, our own checks, calls made with API keys and conversations removed by the operator are not. `people` counts different visitors by the mark in their browser — someone back three days later is one person, the same person on phone and laptop is two; `sessions` last about a day; funnel steps are sessions. The shop's total turnover is not part of the API. A window is either `since`/`until` (ISO 8601; without an offset — UTC) or `first_day`/`last_day`, both days included, cut in `timezone` (default: the shop's time zone, or UTC). A comparison (`compare`) is returned only when the shop's history covers the comparison window; otherwise `previous` is `null`. ## AI-generated text In an answer, `say` and every `why` are written by an AI model, and the response says so in its `ai` object: `generated`, the JSON paths in `fields`, and a `notice` text. If you show these texts to your users, tell your users that they are AI-generated; `notice` is the minimum wording we ask integrations to pass on. Product names, prices, stock and links never come from the model. When `miss` is `true` the shop has nothing that fits: show that, and do not invent a product. ## Versioning The version is part of the path: `/v1`. Within `v1` we make only backward-compatible changes: new endpoints, new optional parameters, new response fields and new values of enumerations (for example new `intent` values). Write clients that ignore unknown fields and fall back sensibly on unknown enum values. A breaking change would only ever ship under a new version path. ## For machines and AI assistants The same reference in machine-readable form. Import the OpenAPI document into Postman, Insomnia or a code generator. - [OpenAPI 3.1 document (JSON)](https://assistant.onesite.agency/api/v1/openapi.json) - [llms.txt: short index for AI assistants](https://findbywords.com/developers/llms.txt) - [llms-full.txt: the complete reference as Markdown](https://findbywords.com/developers/llms-full.txt)