# Тэрил — партнёрское API: вся документация # Тэрил — партнёрское API Сервис проектирует корпусную мебель **по переписке с человеком**: ваш клиент пишет словами, чего он хочет, а сервис отвечает по-русски и, когда данных хватило, собирает заказ — список панелей с материалами и кромкой, расход материалов и объёмный вид. Вам это нужно, если вы ведёте лиды мебельной компании и хотите отдавать в производство не текст переписки, а **готовый заказ**. У вашего клиента при этом нет ничего: ни программы, ни чертежа, ни личного кабинета. Всё, что от него требуется, — писать в чат так, как он говорил бы с продавцом. ## С чего начать 1. Получите **партнёрский ключ** у мебельной компании (он выдаётся на её учётную запись и начинается с `pk_`). 2. Проверьте связь: [быстрый старт](quickstart.md) — пять запросов curl, от разговора до картинки. 3. Поднимите приёмник вебхуков: [webhooks.md](webhooks.md). Всё это опубликовано на сайте: **[teril.ru/docs/api](https://teril.ru/docs/api)**. Те же тексты разметкой — по тем же адресам с `.md` (например `teril.ru/docs/api/turns.md`), оглавление для модели — [`/llms.txt`](https://teril.ru/llms.txt), вся документация одним файлом — [`/llms-full.txt`](https://teril.ru/llms-full.txt). ## Документы | Файл | О чём | |---|---| | [quickstart.md](quickstart.md) | Пять запросов от начала до картинки | | [reference.md](reference.md) | Все ручки, тела запросов и ответов | | [turns.md](turns.md) | Ход: жизненный цикл, статусы, картинки, деньги | | [pricing.md](pricing.md) | Монеты оптом: лестница, срок цены, счёт, акт | | [webhooks.md](webhooks.md) | Подпись, повторы, проверка на вашей стороне | | [errors.md](errors.md) | Коды отказов и что делать с каждым | | [bom.md](bom.md) | Расход материалов: что в нём есть и чего в нём нет | | [handoff.md](handoff.md) | Передача заказа в производство | | [examples/](examples/) | Рабочие примеры: curl, Node, Python | | [CHANGELOG.md](CHANGELOG.md) | Версия договора и её ограничения | ## Как это устроено за пять абзацев **Разговор** — это один лид. Вы заводите его один раз (`POST /v1/conversations`), называя свой `external_id`, и дальше шлёте в него просьбы клиента. Разговор помнит всё: и переписку, и собранный заказ. Он живёт 30 дней с последней просьбы. **Ход** — одна просьба. Он идёт минуты (модель думает, собирает заказ и проверяет его), поэтому `POST /turns` отвечает **202** сразу, а исход приходит вебхуком. По одному разговору идёт **один ход за раз**: следующая просьба человека почти всегда зависит от того, чем кончилась предыдущая. **Ответ клиенту — это поле `note`**, по-русски, две-три фразы. Всё остальное в ответе (заказ, находки проверки, токены) — данные для вашего кода. Клиенту их показывать нечего. **Ассистент не продаёт и не называет цен.** Прайса в сервисе нет ни в одном файле, и придумывать числа ему запрещено промптом: на вопрос о деньгах он скажет, что компания посчитает и вернётся. Расход материалов, который вы получаете, — это себестоимость в плите и кромке; вилку для клиента считаете вы. **Мебель уезжает в производство ручкой `/handoff`**: заказ ложится карточкой в кабинет рабочего места мебельной компании, и дальше его ведёт проектировщик в Базис-Мебельщике. В саму модель Базиса API не кладёт ничего — подробнее в [handoff.md](handoff.md). ## Авторизация ``` Authorization: Bearer pk_ваш_ключ Content-Type: application/json ``` Ключ даёт доступ **только** к этому API. Рабочие места мебельной компании, их очередь задач и их Базис им не открываются. Единственное, что работает без ключа, — `GET /v1/health` и подписанные ссылки на вид (`/r/`): последние открывает ваш клиент, и ключа у него быть не должно. ## Что считается в монетах Просьба словами стоит **1 монету**, просьба с картинкой — **8** (чертёж разбирает самая дорогая модель сервиса). Ход, который ничего не дал, — бесплатен. Подробности и единственное исключение — в [turns.md](turns.md). Монеты покупаются **объёмом**, и цена падает ступенями — от 14,85 ₽ (−10% к розничной) до 12 ₽ за монету. Рабочих мест вы не покупаете вовсе: ключ их не открывает. Лестница, срок цены и порядок оплаты — в [pricing.md](pricing.md). Остаток: `GET /v1/usage`. ## Версия Текущая версия договора — **1**, она же в ответе `GET /v1/health`. Поля в ответах могут ДОБАВЛЯТЬСЯ без смены версии, поэтому разбирайте JSON так, чтобы незнакомое поле не ломало ваш код. Удаление или переименование поля — это новая версия и отдельное предупреждение. --- # Быстрый старт Пять запросов: от «есть ключ» до картинки, которую можно послать клиенту. Подставьте свой адрес сервиса и свой ключ. ```bash BASE=https://teril.example.ru KEY=pk_ваш_ключ AUTH="Authorization: Bearer $KEY" JSON="Content-Type: application/json" ``` ## 1. Связь и остаток ```bash curl -s "$BASE/v1/health" # {"ok":true,"version":1,"model":"deepseek/deepseek-v4-pro-0813"} curl -s -H "$AUTH" "$BASE/v1/usage" # {"ok":true,"usage":{"coins_left":940,"coins_included":1000,"period_end":"…"}} ``` `/v1/health` отвечает без ключа — его и ставьте в мониторинг. ## 2. Завести разговор `external_id` — ваш id лида. По нему разговор и находится потом: наш `id` хранить не обязательно. ```bash curl -s -X POST -H "$AUTH" -H "$JSON" "$BASE/v1/conversations" -d '{ "external_id": "lead-7781", "webhook_url": "https://ваш-сервис/hooks/teril" }' ``` ```json { "ok": true, "conversation": { "id": "3f2b…", "external_id": "lead-7781", "order_id": "lead-lead-7781", "turn": 0, "running_turn": null, "has_project": false, "expires_at": "2026-10-18T…" } } ``` Тот же `external_id` второй раз вернёт **200** и тот же разговор — это не ошибка, а вернувшийся лид. Заводить разговор при каждом сообщении клиента безопасно. ## 3. Отправить просьбу клиента ```bash CONV=3f2b… curl -s -X POST -H "$AUTH" -H "$JSON" \ -H "Idempotency-Key: msg-991" \ "$BASE/v1/conversations/$CONV/turns" -d '{ "text": "Кухня вдоль одной стены 3 метра. Мойка, посудомойка 60, варочная, духовка. Сверху навесные шкафы." }' ``` ```json { "ok": true, "turn": { "id": "b41c…", "state": "queued" } } ``` **202, а не 200.** Ход идёт минуты. `Idempotency-Key` — id сообщения у вас: повтор запроса с тем же ключом вернёт тот же ход и не потратит денег второй раз. ## 4. Дождаться исхода Правильный путь — вебхук (см. [webhooks.md](webhooks.md)). Запасной — опрос: ```bash TURN=b41c… curl -s -H "$AUTH" "$BASE/v1/turns/$TURN" ``` ```json { "ok": true, "turn": { "id": "b41c…", "state": "done", "status": "built", "result": { "status": "built", "note": "Собрал кухню 3 метра: тумба под мойку 800, посудомойка 600, ящики, духовка в пенале. Сверху четыре навесных шкафа.", "applied": 9, "turn": 1, "order_id": "lead-lead-7781", "usage": { "model": "…", "attempts": 1, "cost_usd": 0.0231, "ms": 41200, "coins": 1 } } } } ``` Клиенту показывайте `note`. Всё остальное — вам. Если `status` — `question`, ассистент чего-то не знает про комнату и спросил: в `note` лежит вопрос, его и перешлите клиенту. Полная таблица статусов — в [turns.md](turns.md). ## 5. Показать картинку ```bash curl -s -H "$AUTH" "$BASE/v1/conversations/$CONV/render?format=html&link=1" # {"ok":true,"url":"https://teril.example.ru/r/eyJ…","expires_at":"…"} ``` Эту ссылку можно слать клиенту прямо в чат: она живёт сутки и ключа не требует. Страница самодостаточна — чертёж крутится, наружу не ходит ничего. Просить её можно после ЛЮБОГО хода, а не только после `built`: вид принадлежит разговору, и `question` его не отменяет — картинка после вопроса та же, что была. Условие «если статус built» в вашем коде означает, что клиент, ответивший на вопрос ассистента, остался без картинки вовсе. Нужна картинка внутрь своей вёрстки — возьмите `format=svg` без `link`: ```bash curl -s -H "$AUTH" "$BASE/v1/conversations/$CONV/render?format=svg" > kitchen.svg # Сплошными панелями: корпус и фасад красятся порознь (`clear` — как было). curl -s -H "$AUTH" "$BASE/v1/conversations/$CONV/render?carcass=oak&facade=white" > kitchen.svg ``` ## 6. Отдать в производство ```bash curl -s -X POST -H "$AUTH" -H "$JSON" \ "$BASE/v1/conversations/$CONV/handoff" -d '{"seat": "Цех на Ленина"}' # {"ok":true,"handoff":{"seat_id":5,"seat_title":"Цех на Ленина","order_id":"lead-lead-7781","turn":3}} ``` Заказ появится карточкой в кабинете рабочего места. Что именно это значит и чего НЕ значит — [handoff.md](handoff.md). ## Дальше - весь набор ручек — [reference.md](reference.md); - готовый клиент на Node — [examples/client.mjs](examples/client.mjs); - проверка подписи вебхука — [examples/verify.mjs](examples/verify.mjs). --- # Справочник ручек Все ответы — JSON. Удачный ответ несёт `"ok": true`, отказ — `"ok": false` и объект `error` (см. [errors.md](errors.md)). Авторизация на всех ручках, кроме `GET /v1/health` и `GET /r/{token}`: ``` Authorization: Bearer pk_… ``` --- ## `GET /v1/health` Без ключа. Ставьте в мониторинг. ```json { "ok": true, "version": 1, "model": "deepseek/deepseek-v4-pro-0813" } ``` `model` — какой моделью сервис сейчас работает. Меняется без предупреждения: это наш выбор по цене за сошедшуюся просьбу, а не часть договора. --- ## `GET /v1/usage` Остаток монет учётной записи, на которую выдан ключ. ```json { "ok": true, "usage": { "plan": "seat", "status": "active", "coins_left": 940, "coins_used": 60, "coins_included": 1000, "extra_coins": 0, "period_end": "2026-10-01T00:00:00.000Z", "cabinet": "https://teril.example.ru/cabinet" } } ``` `status` — `trial`, `active`, `past_due` или `canceled`. Монеты добирает мебельная компания в своём кабинете; вы можете только увидеть остаток. --- ## `POST /v1/conversations` Завести разговор — или получить уже заведённый. ```json { "external_id": "lead-7781", "order_id": "кухня-иванов", "webhook_url": "https://ваш-сервис/hooks/teril", "meta": { "источник": "сайт", "менеджер": 12 } } ``` | Поле | Обяз. | Что это | |---|---|---| | `external_id` | нет | Ваш id лида. По нему разговор находится повторно | | `order_id` | нет | Имя заказа. Не задано — `lead-` | | `webhook_url` | нет | Куда слать исход. Не задан — общий адрес партнёра | | `meta` | нет | Любой ваш JSON. Мы его храним и не читаем | **201** — завели новый. **200** — вернули тот же по `external_id`. ```json { "ok": true, "conversation": { "id": "3f2b…", "external_id": "lead-7781", "order_id": "кухня-иванов", "turn": 0, "running_turn": null, "has_project": false, "expires_at": "2026-10-18T09:11:00.000Z" } } ``` `running_turn` — id идущего хода или `null`. `has_project` — собрано ли уже что-нибудь. --- ## `GET /v1/conversations/{id}` Тот же объект `conversation`. Разговор чужого партнёра не находится вовсе (`404`). --- ## `DELETE /v1/conversations/{id}` **204**, без тела. Удаляются и переписка, и картинки, и история ходов. Необратимо. Делайте, когда лид закрыт: сам разговор и так умрёт через 30 дней после последней просьбы. --- ## `POST /v1/conversations/{id}/turns` Просьба клиента. Отвечает **202**. ```json { "text": "Кухня 3 метра вдоль окна, мойка под окном", "images": [{ "mime": "image/png", "b64": "iVBORw0KGgo…", "name": "план.png" }] } ``` | Поле | Обяз. | Что это | |---|---|---| | `text` | **да** | Слова клиента как есть, по-русски | | `images` | нет | До двух картинок, каждая ≤ 5 МБ. `mime`: `image/png`, `image/jpeg`, `image/webp`, `image/gif`. `name` можно слать — он принимается и никуда не идёт | Заголовок `Idempotency-Key` (рекомендуется): повтор с тем же ключом вернёт **тот же** ход и не купит второго вопроса модели. Без него повтор — это второй ход и вторые деньги. ```json { "ok": true, "turn": { "id": "b41c…", "state": "queued" } } ``` **409 `conflict_busy`** — по этому разговору уже идёт ход. Дождитесь исхода или остановите его. --- ## `GET /v1/turns/{id}` Состояние и исход хода. Запасной путь к тому, что приходит вебхуком. ```json { "ok": true, "turn": { "id": "b41c…", "state": "done", "status": "built", "result": { "…": "см. turns.md" } } } ``` Поля `status`, `result` и `error` появляются по мере того, как им есть что сказать: у идущего хода их нет вовсе, у неудачного вместо `result` приходит `error`. Не полагайтесь на то, что поле present-but-null — его просто нет. `state`: `queued` → `running` → `done` | `stopped` | `failed`. Опрашивать имеет смысл раз в 5–10 секунд и не дольше десяти минут. --- ## `POST /v1/turns/{id}/stop` Оборвать идущий ход: клиент передумал, дописал, ушёл. ```json { "ok": true, "turn_id": "b41c…", "state": "running" } ``` **409** — ход уже кончился либо идёт на другом узле сервиса и остановить его нечем. Второй случай честно говорит об этом текстом: мы не притворяемся, что остановили. Остановленный ход **не списывает монет**. --- ## `GET /v1/conversations/{id}/project` Документ заказа — то, из чего собирается мебель. Его читает проектировщик и наши же инструменты; вам он нужен, если вы храните состояние у себя или показываете состав словами. ```json { "ok": true, "project": { "id": "кухня-иванов", "kind": "kitchen", "ceiling": 2700, "room": { "walls": [ { "id": "задняя", "turn": "right", "length": 3000 } ] }, "runs": [ { "id": "задняя-низ-1", "wall": "задняя", "tier": "base", "countertop": true, "plinth": true, "nodes": [ { "id": "мойка", "at": "module", "code": "sink-straight", "w": 800 } ] } ], "items": [], "obstacles": [] } } ``` **404**, пока в разговоре ничего не собрано. Документ большой и подробный; разбирать его целиком не нужно. Практически полезны `runs[].nodes[]` — состав по порядку, у каждого узла `id`, `code` (модуль каталога) и `w` (ширина в миллиметрах). --- ## `GET /v1/conversations/{id}/bom` Расход материалов. Подробно — [bom.md](bom.md). ```json { "ok": true, "order_id": "кухня-иванов", "turn": 3, "bom": { "sheets": [ { "material": "ldsp16", "name": "ЛДСП 16 мм", "parts": 78, "area_mm2": 15253450 } ], "linear": [], "edges": [ { "edge": "pvc1carcass", "name": "Кромка корпуса", "length_mm": 70164 } ], "pieces": [ { "article": "LEG-ADJ-100", "name": "Опора регулируемая 100", "qty": 22 } ] } } ``` --- ## `GET /v1/conversations/{id}/render` Объёмный вид заказа. **Вид принадлежит РАЗГОВОРУ, а не ходу.** Он доступен после ЛЮБОГО хода, пока в разговоре что-то собрано, и просить его можно когда угодно — хоть перед первым ходом дня. Статус последнего хода на это не влияет вовсе: `question` и `rejected` ничего не разобрали, и картинка после них ровно та же, что была. Не собрано ещё ничего — **404 `not_found`**. | Параметр | Значения | По умолчанию | |---|---|---| | `format` | `svg`, `html` | `svg` | | `view` | `front`, `iso` | `front` | | `carcass` | выкрас из палитры | `clear` | | `facade` | выкрас из палитры | `clear` | | `link` | `1` | нет | Без `link` — тело: `image/svg+xml` или `text/html`. С `link=1` — подписанная ссылка вместо тела: ```json { "ok": true, "url": "https://teril.example.ru/r/eyJ…", "expires_at": "2026-09-19T…" } ``` `carcass` и `facade` красят вид СПЛОШНЫМИ ПАНЕЛЯМИ — порознь корпус и фасад, потому что порознь их и называют («белый верх, дуб низ»). Выкрасы: `clear`, `white`, `milk`, `sand`, `oak`, `walnut`, `wenge`, `grey`, `graphite`, `black`, `olive`, `blue`. `clear` — не цвет, а его отсутствие: вид остаётся чертежом, залитым бумагой и обведённым краской, и это же по умолчанию. Неизвестное имя — **400 `invalid_request`** со списком: опечатка в цвете иначе уехала бы клиенту картинкой не того тона, и узнали бы вы об этом от него. Цвет здесь ВРЁТ НАРОЧНО и ничего не решает: декор выбирают по выкрасу у поставщика, а не по экрану телефона, — поэтому имена родовые, а не артикулы. Геометрия при этом та же самая, из которой выйдут детали. На странице (`format=html`) палитра идёт строкой кнопок под видом: клиент перебирает цвета сам, не ходя к вам за новой ссылкой. Выбранное в запросе — то, что он увидит первым; там, где скриптов нет вовсе (предпросмотр в мессенджере), останется оно же. Подписанная ссылка несёт выкрасы в себе: параметром их клиенту не дать — подпись покрывает только тело. `view=iso` показывает и маркеры техники (место под холодильник, трубу, котёл); `front` — только мебель. Клиенту обычно нужен `front`. `format=png` отвечает **501 `png_unsupported`**: ради картинки, которая получается из SVG, пришлось бы держать браузер в рантайме. Нужен растр — сконвертируйте SVG у себя. --- ## `GET /r/{token}` Страница вида по подписанной ссылке. **Без ключа** — её открывает ваш клиент. Страница самодостаточна: чертёж внутри неё, наружу она не ходит ни за чем. Живёт сутки; просроченная и подделанная отвечают одинаково — `404`. Содержимое адресуется парой «разговор + номер хода», поэтому ссылка неизменна: после следующего хода запросите новую. --- ## `POST /v1/conversations/{id}/handoff` Передать заказ на рабочее место мебельной компании. ```json { "seat": "Цех на Ленина", "order_id": "кухня-иванов-2" } ``` `seat` — id места или его название из кабинета компании. `order_id` — имя, под которым заказ ляжет на место; не задано — имя заказа разговора. ```json { "ok": true, "handoff": { "seat_id": 5, "seat_title": "Цех на Ленина", "order_id": "кухня-иванов-2", "turn": 3 } } ``` **403** — такого места у этой учётной записи нет либо оно отозвано. **400** — в разговоре ещё ничего не собрано. Что эта ручка делает и чего не делает — [handoff.md](handoff.md). --- # Ход: одна просьба клиента Ход — это единица работы сервиса. Внутри него ассистент читает разговор, пишет изменение заказа, собирает его геометрией и проверяет; не сошлось — переспрашивает себя до трёх раз. Поэтому ход идёт **десятки секунд, иногда минуты**, и отвечает сервис заявкой, а не результатом. ``` POST /v1/conversations/{id}/turns → 202 {turn:{id, state:"queued"}} │ queued → running → done | stopped | failed │ вебхук turn.completed / .stopped / .failed ``` ## Один ход на разговор Пока идёт ход, второй запрос по тому же разговору отвечает **409 `conflict_busy`**. Это не очередь и не защита от нагрузки: следующая просьба человека почти всегда зависит от того, чем кончилась предыдущая, и собирать их параллельно значило бы собирать по устаревшему заказу. Если клиент дописал, пока ход идёт, — у вас два честных пути: дождаться исхода и послать дописанное следующим ходом либо оборвать текущий (`POST /v1/turns/{id}/stop`) и послать просьбу целиком заново. Остановленный ход денег не стоит. ## Идемпотентность Заголовок `Idempotency-Key` — ваш id сообщения: ``` Idempotency-Key: msg-991 ``` Повтор с тем же ключом возвращает **тот же** ход. Без заголовка повтор — это второй ход и вторые деньги. Ключ уникален в пределах разговора. ## Исход ```json { "status": "built", "note": "Собрал кухню 3 метра: тумба под мойку 800, посудомойка 600, ящики. Сверху четыре навесных шкафа.", "applied": 9, "turn": 1, "order_id": "lead-7781", "findings": [ { "code": "GEO-GAP", "severity": "warning", "path": "низ.шкаф2", "msg": "…", "fixHint": "…" } ], "why": "Ряд не сошёлся: мебели на 925 мм больше, чем стены…", "usage": { "model": "…", "attempts": 1, "prompt_tokens": 12400, "completion_tokens": 900, "cost_usd": 0.0231, "ms": 41200, "coins": 1 } } ``` | Поле | Что это | |---|---| | `status` | Чем кончилось — таблица ниже | | `note` | **Ответ клиенту**, по-русски. Единственное, что ему показывают | | `applied` | Сколько узлов заказа изменилось. `0` — заказ не тронут | | `turn` | Номер хода в разговоре | | `findings` | Находки проверки: `code`, `severity` (`error`/`warning`), `path`, `msg`, `fixHint?`. Есть не всегда | | `why` | Почему не вышло — по-русски, одной фразой. Только у неудачных ходов | | `usage` | Чем обошёлся ход. `coins` — сколько монет он стоит | ### Статусы | `status` | Что случилось | Что делать | |---|---|---| | `built` | Мебель собрана, заказ изменился | Показать `note` и обновить картинку | | `parsed` | Приложенный чертёж прочитан вслух; мебель НЕ собрана | Показать `note` — это разбор. Клиент поправляет словами или говорит «собирай» | | `question` | Ассистент ответил словами: спросил недостающее или отказался | Показать `note` и ждать ответа клиента. Картинка остаётся прежней | | `rejected` | Просьба спорит с нормой производства; переспрашивать нечем | Показать `note` и `why`. Решение за человеком: другое число или другой состав | | `failed` | Ход не состоялся: модель не поняла, оборвалась связь | Клиенту ничего не показывать. Предложить сказать иначе | `question` — **нормальный** исход, а не ошибка. Ассистенту велено спрашивать то, чего он не может знать: вдоль какой стены мебель, какой длины стена, какая техника встраивается, что мешает в комнате. Спрашивает он по одному-два пункта за ход, а не анкетой. Наоборот, то, что решается правилами (наполнение тумбы, сторона открывания, высота полки), он **не спрашивает**, а собирает по умолчанию и называет это строкой в `note`. **Картинку не отменяет ни один статус.** Вид принадлежит разговору, а не ходу: пока в разговоре что-то собрано, `GET .../render` отдаёт его после ЛЮБОГО хода. `built` значит «картинка изменилась, возьмите новую», а не «только теперь она есть»: после `question` и `rejected` в силе прежняя, и ссылку на неё можно выписать заново хоть тем же запросом. ## Картинки К просьбе прикладывается до **двух** картинок, каждая не больше 5 МБ: ```json { "text": "собери по этому плану", "images": [{ "mime": "image/png", "b64": "iVBORw0…" }] } ``` Форматы: `png`, `jpeg`, `webp`, `gif`. **Чертёж сперва читается вслух.** На картинку с составом (модули вдоль стены, в порядке и с размерами) ассистент отвечает не мебелью, а разбором по-русски: что стоит у какой стены, в каком порядке, на скольких миллиметрах, какие числа он взял не с чертежа и чего не разобрал. Статус такого хода — `parsed`, заказ при этом не меняется. Клиент правит разбор словами («вторая стена 3400», «бутылочницы нет») или говорит «собирай» — и следующий ход собирает всё разом. Двух картинок хватает **по сроку хода**: чертёж разбирает медленная модель, и ход с картинкой идёт три-пять минут. **Мелкая картинка** (меньше 600 пикселей по короткой стороне) не отбивается, но читается плохо: швы в пару пикселей превращают семь створок в пять, и шкаф по такому разбору собирается без единой ошибки. Есть крупнее — шлите крупнее. **Картинка помнится ровно один следующий ход.** Нужна снова — приложите снова. ## Деньги | Что | Сколько | |---|---| | Просьба словами | 1 монета | | Просьба с картинкой | 8 монет | | `question`, `rejected`, `failed`, остановленный ход | **0** | Списывается ход, который дал работу: `built` и `parsed`. **Единственное исключение** — ход с картинкой: он списывает свои восемь монет всегда, когда ассистент ответил, даже если в заказе не изменилось ничего. Работа там сделана разбором чертежа, и делает её самая дорогая модель сервиса. Ход с картинкой, который не состоялся вовсе (модель не ответила, связь оборвалась), не списывается и здесь. Число картинок на цену не влияет: дорог разбор, а не изображение. Не хватает монет — `402 quota_exceeded` ещё до вопроса модели. В `details` лежит `reason`: `out_of_coins`, `period_over`, `canceled`, `no_subscription`. Почём монета и как она покупается объёмом — в [pricing.md](pricing.md). ## Сроки Ход живёт до **четырёх минут**; не уложился — `failed`. Разговор живёт **30 дней** с последней просьбы и продлевается каждым ходом. Если сервис перезапустили посреди хода, ход придёт `failed` вебхуком, а разговор освободится — «вечно занятых» разговоров не бывает. --- # Деньги: монеты оптом Вы покупаете **монеты**, а не рабочие места: ключ `pk_` не открывает ни окна Базис-Мебельщика, ни очереди его задач, и платить за них не за что. Просьба словами стоит **1 монету**, просьба с картинкой — **8**. Ход, который ничего не дал, бесплатен; единственное исключение — разбор чертежа, см. [turns.md](turns.md). ## Лестница Цена монеты падает ступенями по объёму покупок **за 12 месяцев, включая текущую**: | Объём за 12 месяцев | Цена монеты | Скидка | Пример покупки | |---|---|---|---| | 4 000 — 14 000 монет | 14,85 ₽ | −10% | 4 000 — 59 400 ₽ | | 15 000 — 39 000 монет | 13,5 ₽ | −18% | 15 000 — 202 500 ₽ | | от 40 000 монет | 12 ₽ | −27% | 40 000 — 480 000 ₽ | Скидка есть уже на первой покупке; меньше 4 000 монет оптом не продаётся, покупка кратна тысяче. Объёмы ниже берутся розничными пакетами — у них своя цена и бессрочность. Ступень применяется **к текущей покупке**; прошлые не пересчитываются. Взяли 15 000, через месяц ещё 25 000 — вторая покупка идёт по 12 ₽, потому что объём за год стал 40 000. Действующая лестница — на [teril.ru/api](https://teril.ru/api); там же она и считается тем же кодом, каким выставляется счёт. ## Цена держится 365 дней, монеты не сгорают У каждой покупки своя цена и свой срок. По истечении года неизрасходованный остаток **пересчитывается** по действующей цене той же ступени так, что **рублёвая стоимость остатка сохраняется**: подорожала монета — монет стало меньше, денег столько же; цена не менялась — не меняется ничего. Ваши деньги при этом остаются вашими: неизрасходованный остаток возвращается по заявлению в любой момент. Тратятся монеты **старшей покупкой вперёд** — той, у которой срок цены ближе. ## Оплата * до 300 000 ₽ — картой из кабинета; * выше — **счётом** на расчётный счёт: 23 000 монет и больше картой не проходят, то есть верхняя ступень идёт счётом всегда; * предоплата 100%, НДС нет (упрощённая система); * израсходованное закрывается **актом за календарный месяц**, до 5 числа следующего. ## Остаток и отказ ``` GET /v1/usage ``` Когда монеты кончаются, ход не начинается вовсе: `402 quota_exceeded`, в `details.reason` — `out_of_coins`. Мы пишем на почту учётной записи, когда остаток падает ниже десятой части вашего годового объёма, — но следить за остатком в `/v1/usage` дешевле, чем узнавать о нём из отказа, который увидел ваш клиент. ## Условия [Приложение к оферте для партнёрского API](https://teril.ru/legal/api) — там же про поручение на обработку данных ваших клиентов (ч. 3 ст. 6 152-ФЗ), возврат и ответственность. --- # Вебхуки Исход хода приходит к вам сам. Опрос (`GET /v1/turns/{id}`) остаётся, но это запасной путь: ход идёт минуты, и спрашивать о нём каждую секунду — значит греть сеть ради ответа, который придёт один раз. Адрес берётся у разговора (`webhook_url` при заведении), а если он не задан — у партнёра. Адрес разговора перебивает общий: так у вас могут жить разные приёмники для разных контуров. ## Что приходит ``` POST https://ваш-сервис/hooks/teril Content-Type: application/json X-Teril-Event: turn.completed X-Teril-Signature: t=1758182400,v1=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 ``` ```json { "id": "4e1a…", "event": "turn.completed", "at": "2026-09-18T07:20:00.000Z", "turn_id": "b41c…", "conversation_id": "3f2b…", "external_id": "lead-7781", "state": "done", "result": { "status": "built", "note": "Собрал кухню 3 метра…", "applied": 9, "turn": 1, "order_id": "lead-7781", "usage": { "coins": 1, "cost_usd": 0.0231, "ms": 41200 } } } ``` События: | `event` | Когда | |---|---| | `turn.completed` | Ход кончился. Что именно вышло — в `result.status` | | `turn.stopped` | Ход оборван вашим `POST /v1/turns/{id}/stop` | | `turn.failed` | Ход не состоялся: связь, срок, перезапуск сервиса | У неудачного хода вместо `result` приходит `error`: ```json { "id": "…", "event": "turn.failed", "turn_id": "…", "state": "failed", "error": { "code": "internal", "message": "Ход оборвался: сервис был перезапущен." } } ``` **`turn.completed` — это не всегда «собрано».** Ассистент мог законно ответить словами (`status: "question"`). Решение принимайте по `result.status`, а не по имени события. ## Подпись ``` X-Teril-Signature: t=,v1= ``` Подписывается строка `".<тело запроса как есть>"`, ключ — секрет вашего партнёрского профиля. Проверяйте **до** разбора JSON и по сырому телу: любая перекодировка ломает подпись. Проверять надо обе вещи: 1. подпись сходится с вашей, посчитанной тем же способом; 2. `t` не старше пяти минут. Время внутри подписи не для красоты: без него перехваченный запрос можно переслать вам через сутки с той же подписью, и отличить его от повторной доставки будет нечем. Сравнивайте подписи функцией, **постоянной по времени** (`crypto.timingSafeEqual`, `hmac.compare_digest`). Побайтовое сравнение через `===` отдаёт секрет посимвольно тому, кто умеет мерить время ответа. Готовые примеры: [examples/verify.mjs](examples/verify.mjs) (Node) и [examples/verify.py](examples/verify.py) (Python). ## Как отвечать Отвечайте **2xx как можно быстрее**: мы ждём «принял», а не «обработал». Поставьте событие в свою очередь и отвечайте сразу. Ждём ответа 10 секунд. Любой не-2xx и любой обрыв — повод повторить. ## Повторы Повтор идёт через **10 с, 1 мин, 5 мин, 30 мин, 2 ч, 6 ч**, после чего мы замолкаем. Приёмник, не отвечающий шесть часов, чинится руками, а исход всё это время достаётся опросом `GET /v1/turns/{id}`. **Доставка at-least-once.** Одно и то же событие может прийти дважды: ответ потерялся, ваш сервис перезапустился, прокси оборвал соединение. Поле `id` события стабильно — отсеивайте повтор по нему. ## Чего вебхук не гарантирует Исход и запись о нём пишутся одной транзакцией, поэтому **исхода без записи не бывает**: `GET /v1/turns/{id}` всегда покажет правду. Обратное возможно: если сервис упал ровно между списанием и постановкой уведомления в очередь, вебхук не придёт вовсе. Поэтому держите защитный опрос для ходов, по которым за десять минут ничего не пришло. Порядок доставки не гарантируется. По одному разговору идёт один ход за раз, так что перепутать исходы двух ходов одного лида нельзя, но события разных лидов приходят как придутся. --- # Отказы Все отказы одной формы. Решение принимайте по `code`; `message` написан по-русски для человека, который будет читать ваш журнал, и может меняться. ```json { "ok": false, "error": { "code": "quota_exceeded", "message": "Монеты на этот период кончились (израсходовано 1000 из 1000)…", "details": { "reason": "out_of_coins" } } } ``` `details` есть не у всех отказов. | Код | HTTP | Что случилось | Что делать | |---|---|---|---| | `unauthorized` | 401 | Нет ключа, ключ неверен или отозван | Не повторять. Проверить ключ у мебельной компании | | `forbidden` | 403 | Рабочее место в `/handoff` не принадлежит этой учётной записи | Не повторять. Уточнить название места | | `not_found` | 404 | Нет такого разговора, хода или ссылки; либо в разговоре ещё ничего не собрано | Не повторять вслепую | | `invalid_request` | 400 | Тело не по договору: пустой `text`, битый `b64`, больше двух картинок | Не повторять. Чинить запрос | | `conflict_busy` | 409 | По разговору уже идёт ход; либо ход, который просят остановить, уже кончился | Дождаться вебхука. Повторять просьбу не надо | | `quota_exceeded` | 402 | Кончились монеты или подписка | Не повторять. Сообщить мебельной компании | | `payload_too_large` | 413 | Картинка больше 5 МБ | Пережать картинку | | `rate_limited` | 429 | Слишком много ходов идёт разом | Повторить через минуту | | `png_unsupported` | 501 | Запрошен `format=png` | Взять `svg` и сконвертировать у себя | | `internal` | 500 | Беда на нашей стороне | Повторить один раз через минуту; не прошло — написать нам | ## Повторять или нет Повторять имеет смысл **только** `rate_limited`, `internal` и сетевые обрывы. Всё остальное повтором не лечится: 401 не станет 200 от третьей попытки, а 409 означает, что работа уже идёт. Повторяя `POST /turns` после обрыва сети, **обязательно** шлите тот же `Idempotency-Key`: иначе вы купите второй ход за те же деньги. ## Подробности у `quota_exceeded` ```json "details": { "reason": "out_of_coins" } ``` | `reason` | Что это значит | |---|---| | `out_of_coins` | Монеты периода кончились; можно добрать пакетом | | `period_over` | Период кончился, подписка не продлена | | `canceled` | Подписка отменена | | `no_subscription` | У учётной записи нет подписки вовсе | Всё это чинится в кабинете мебельной компании, а не вами: вы можете только показать, что работа встала, и кому об этом сказать. ## Отказ — не то же, что неудачный ход Отказ приходит **синхронно**, кодом HTTP, и означает, что ход не начался. Неудачный ход приходит **вебхуком** со `status: "failed"` и означает, что работа шла, но не сошлась. Деньги в первом случае не тратятся вовсе, во втором — тоже не тратятся, но время потрачено. --- # Расход материалов `GET /v1/conversations/{id}/bom` Считается из **той же геометрии**, из которой выходят детали заказа, поэтому разойтись со сборкой не может: площадь берётся с габарита детали, кромка — с торцов, которым назначена лента, фурнитура — со списка изделий. ```json { "ok": true, "order_id": "кухня-иванов", "turn": 3, "bom": { "sheets": [ { "material": "ldsp16", "name": "ЛДСП 16 мм", "parts": 78, "area_mm2": 15253450 }, { "material": "mdf16", "name": "МДФ 16 мм", "parts": 18, "area_mm2": 4822264 } ], "linear": [ { "key": "alu-frame", "name": "Профиль рамки", "parts": 8, "length_mm": 9600 } ], "edges": [ { "edge": "pvc1carcass", "name": "Кромка корпуса", "length_mm": 70164 } ], "pieces": [ { "article": "LEG-ADJ-100", "name": "Опора регулируемая 100", "qty": 22 }, { "article": "RUN-BALL-500", "name": "Направляющая шариковая 500", "qty": 5 } ] } } ``` | Раздел | Единица | Что там | |---|---|---| | `sheets` | мм² (`area_mm2`) | Листовое: ЛДСП, МДФ, ХДФ. `parts` — сколько деталей | | `linear` | мм (`length_mm`) | Погонное: профиль рамки, труба штанги, лента подсветки. `parts` — сколько отрезков | | `edges` | мм (`length_mm`) | Кромка по видам ленты | | `pieces` | штуки (`qty`) | Покупные изделия: опоры, направляющие, газлифты, фланцы | ## Листовое считается ПЛОЩАДЬЮ, а не листами Листов в ответе нет, и это отказ, а не недоделка. Число листов зависит от формата плиты, а формат у каждого цеха свой: «нужно 6 листов» читалось бы как утверждение про вашу плиту, хотя посчитано по чужой. Площадь же ни от чьего формата не зависит и делится на любой лист вами. Настоящий раскрой с картами, поворотом детали и остатками считает Базис на стороне мебельной компании. ## Чего в расходе нет вовсе **Цен.** Прайса нет ни в одном файле сервиса. То, что вы получаете, — это себестоимость в материалах; наценку, работу и монтаж считаете вы у себя. Показывать этот расход клиенту нельзя. **Крепежа и петель.** Сколько конфирматов, минификсов и петель встанет, решает схема присадки конкретного цеха, и считает их Базис по тому, что реально стоит в модели. Наша цифра рядом была бы вторым, другим ответом на тот же вопрос. **Столешницы в погонных метрах поставщика.** Столешница считается как деталь своей площадью; во сколько кусков её резать из чьей заготовки — вопрос снабжения, а не геометрии. ## Как этим пользоваться Для вилки «от и до» в чате обычно хватает двух чисел: ``` площадь плиты = Σ sheets[].area_mm2 / 1e6 → м² длина кромки = Σ edges[].length_mm / 1000 → м ``` Плюс `pieces` штуками — по ним видно, сколько ящиков и подъёмников в кухне, то есть где на самом деле лежат деньги. Ответ считается на каждый запрос заново (это доли секунды) и всегда соответствует последнему ходу: `turn` в ответе говорит, какому именно. --- # Передача заказа в производство ``` POST /v1/conversations/{id}/handoff { "seat": "Цех на Ленина" } ``` Ради этого всё и затевалось: лид приходит в производство **собранным заказом**, а не текстом переписки, который менеджер пересказывает конструктору. ## Что происходит на самом деле Заказ разговора ложится строкой на **рабочее место** мебельной компании и появляется карточкой в её кабинете — ровно так же, как появляется заказ после удачного хода самого проектировщика. Проектировщик открывает карточку, видит состав, чертёж и расход и продолжает работу своими средствами. ```json { "ok": true, "handoff": { "seat_id": 5, "seat_title": "Цех на Ленина", "order_id": "кухня-иванов", "turn": 3 } } ``` ## Чего эта ручка НЕ делает **Она не кладёт мебель в модель Базис-Мебельщика.** Класть туда может только агент, запущенный на машине проектировщика при открытом Базисе; сервер сам в чужую программу ничего не записывает и записать не может. Говорим об этом прямо, чтобы вы не строили на этом обещание клиенту: `/handoff` кладёт заказ проектировщику **на стол**, а собирает его в модели он — своим ходом, когда сядет за работу. ## `seat` — это место, а не человек `seat` — id рабочего места или его название, как оно записано в кабинете мебельной компании («Цех на Ленина», «Замерщик», «Иван»). Спросите список у компании один раз и держите его у себя в настройках. Место чужой учётной записи или отозванное отвечает **403**. Это защита по построению: список мест берётся из учётной записи, на которую выдан ваш ключ, и места из него просто не существует для вашего запроса. ## Повторная передача Передавать один и тот же разговор можно сколько угодно: строка на паре «место + заказ» одна, и каждая передача её обновляет. Разговор, у которого после передачи было ещё три хода, передайте заново — проектировщик увидит свежий состав. Хотите держать на месте два варианта — передайте второй раз с другим `order_id`: ```json { "seat": "Цех на Ленина", "order_id": "кухня-иванов-вариант-2" } ``` ## Когда передавать Разумная граница — когда клиент сказал «да, примерно так»: состав собран, техника названа, стены известны. Ходы после передачи ничему не мешают — просто передайте ещё раз. Передать разговор, в котором ничего не собрано, нельзя: **400**. --- # Примеры | Файл | Что это | |---|---| | [curl.sh](curl.sh) | Весь круг на curl и jq: разговор → просьба → исход → расход → картинка | | [client.mjs](client.mjs) | Клиент на Node 18+ без зависимостей: все ручки, ожидание исхода, ответ клиенту | | [verify.mjs](verify.mjs) | Приёмник вебхуков на Node: проверка подписи, отсев повторов | | [verify.py](verify.py) | То же на Python: проверка подписи и пример для Flask | Все примеры берут адрес и ключ из окружения: ```bash export TERIL_BASE=https://teril.example.ru export TERIL_KEY=pk_ваш_ключ export TERIL_WEBHOOK_SECRET=секрет_подписи # только приёмнику ``` Ключ и секрет подписи выдаёт мебельная компания. Секрет подписи — не то же, что ключ: ключом вы ходите к нам, секретом мы подписываем то, что приходит к вам. --- # Изменения договора Версия договора приходит в `GET /v1/health` полем `version`. Поля в ответах могут **добавляться** без смены версии — разбирайте JSON так, чтобы незнакомое поле не ломало ваш код. Удаление поля, переименование или смена смысла — это новая версия, и о ней предупреждают заранее. ## 1 — первая версия Разговоры, асинхронные ходы с вебхуками, картинки в просьбе, документ заказа, расход материалов, вид (SVG и HTML) с подписанными ссылками, передача заказа на рабочее место. Известные ограничения этой версии: - **PNG не отдаётся** (`501 png_unsupported`) — только SVG и HTML; - **остановить можно не всякий ход**: если он идёт на другом узле сервиса, `POST /v1/turns/{id}/stop` честно отвечает `409`, а не притворяется; - **порядок вебхуков не гарантирован** между разными разговорами; - **ключи партнёров заводятся вручную** мебельной компанией: своего кабинета у партнёра пока нет.