# MAMONT — оптовый API похвал CS2. Инструкция для ИИ-агента

Вставь это целиком своему кодовому агенту. Документ самодостаточен: в нём есть все
контракты, все ограничения и чек-лист приёмки.

---

## Задача

Встроить в СУЩЕСТВУЮЩИЙ код магазина покупку похвал (commends) CS2 через API MAMONT:
создать заказ, отдать покупателю адрес сервера, дождаться терминального статуса,
закрыть заказ у себя.

## Что спросить у владельца, если этого нет в конфиге

1. `MAMONT_API_KEY` — ключ вида `cs2_...`, берётся в кабинете https://mamont.club/ru/seller.
2. `MAMONT_WEBHOOK_SECRET` — только если будешь принимать вебхуки (не обязательно, см. ниже).

Не придумывай значения и не клади их в репозиторий — только переменные окружения.

---

## Модель, которую нельзя нарушить

Это не советы, это свойства сервиса. Код, который им противоречит, работать не будет.

1. **Цель ОБЯЗАНА присутствовать на игровом сервере всю доставку — САМА или через доступ.**
   Похвала засчитывается CS2 только между игроками одного матча. Два способа:
   а) **сам заходит** — заказ возвращает `connect`, покупатель подключается и стоит;
   б) **даёт доступ к аккаунту** — тогда присутствие обеспечиваем мы, покупателю на сервер
   заходить не нужно. Доступ выдаётся ОТДЕЛЬНЫМ шагом (см. §1а), НИКОГДА не в теле заказа.
2. **На первый заход есть 15 минут.** Не зашёл — заказ уходит в `expired`, деньги не
   списываются, заказ надо создать заново.
3. **Вышел посреди доставки — есть 15 минут вернуться.** Доставка встаёт на паузу
   (`waiting_for_target`) и продолжается сама, как только цель снова на сервере. Не вернулся за
   15 минут — заказ закрывается тем, что успело доехать (`partial`), и доставленное платно.
   Предупреди покупателя ДО того, как он свернёт игру: alt-tab не страшен, выход из игры — да.
4. **Скорость ставит Valve: около 20 похвал на профиль за 5 минут.** Значит 100 ≈ 25 минут,
   1000 ≈ 4 часа 10 минут. Ускорить нельзя ничем. Планируй таймауты, UI и ожидания
   пользователя исходя из этого.
5. **Опрос статуса — базовая интеграция.** Вебхук это добавка, которая молчит, когда твой
   обработчик лежит. Никогда не строй выдачу только на вебхуке.
6. **Списывается по факту доставленного**, по одному лайку за доставленную похвалу. Недовоз
   стоит ровно столько, сколько доехало.
7. **`types` не влияет на цену.** `count: 100, types: "ftl"` стоит столько же, сколько
   `types: "f"`, но поднимает покупателю три счётчика вместо одного. Продавай "ftl".
8. **Заказ с разным числом на счётчик стоит МАКСИМУМ, а не сумму.** Один коменд поднимает
   несколько счётчиков сразу, поэтому `300 / 300 / 1000` — это 1000 похвал, а не 1600. Против
   этого же максимума проверяются и остаток пула, и твой баланс.

---

## База и авторизация

- Базовый URL: `https://api.mamont.club`
- Заголовок: `Authorization: Bearer <MAMONT_API_KEY>`
- Тела и ответы — JSON, UTF-8.
- Ключ действует, пока его не отозвали в кабинете. Отозванный даёт `401`.

---

## 0. Ссылка покупателя → SteamID64

`GET /steam/resolve?input=<строка>`

Покупатель присылает в чат что угодно — чаще всего `https://steamcommunity.com/id/Mike21692/`, где
SteamID64 физически НЕТ: кастомное имя разворачивает только Steam Web API. Скорми строку как есть.

```http
GET https://api.mamont.club/steam/resolve?input=%D0%B2%D0%BE%D1%82%20https%3A%2F%2Fsteamcommunity.com%2Fid%2FMike21692%2F
Authorization: Bearer <MAMONT_API_KEY>
```

**Успех `200`:**

```json
{ "steamId64": "76561198963526227", "source": "vanity", "vanity": "Mike21692" }
```

`source` — откуда взялся id: `raw` (17 цифр были прямо в строке), `profile_url` (ссылка
`/profiles/<id>`), `vanity` (развернули через Steam). На результат не влияет, полезно для логов.

Принимается: голые 17 цифр; `/profiles/<id>` и `/id/<имя>` — со схемой и без, со слешем и без, с
любыми query-параметрами; голое кастомное имя; и ссылка ВНУТРИ текста («вот мой профиль <ссылка>
спасибо») — писать парсер у себя не нужно.

Запрос **ничего не создаёт и не списывает**, `Idempotency-Key` не нужен, ключ — обычный.

### Отказы

| Код | Тело | Что делать |
|---|---|---|
| `400` | `{"error":"invalid_input"}` | В строке нет ничего похожего на профиль Steam. Попроси прислать ссылку ещё раз |
| `404` | `{"error":"vanity_not_found"}` | Steam говорит, такого имени нет. **Покупателю есть что проверить** — опечатка или профиль переименован |
| `429` | `{"error":"rate_limited","retryAfter":60}` | Слишком часто; лимит считается по твоему ключу |
| `502` | `{"error":"steam_unavailable"}` | Мы не смогли спросить Steam. **Покупателю говорить нечего** — повтори запрос сам |

**`404` и `502` различаются намеренно, не схлопывай их в один текст для человека:** в первом
случае виновата ссылка, во втором — мы, и просить покупателя «проверить ссылку» значит говорить ему
неправду.

⚠️ **Ссылка не на `steamcommunity.com` — это `400`, даже если внутри неё есть 17 цифр.** Страница
трекера или лота с чужим SteamID64 не должна становиться целью заказа: такой «профиль» принадлежит
не тому, кто прислал ссылку.

⚠️ **Кастомное имя — это не постоянный адрес профиля.** Владелец может его сменить, и освободившееся
имя займёт кто-то другой; наш кэш это учитывает и живёт сутки. Храни у себя РЕЗУЛЬТАТ (SteamID64),
а не соответствие имени — id не меняется никогда.

## 1. Создать заказ

`POST /orders`

```http
POST https://api.mamont.club/orders
Authorization: Bearer <MAMONT_API_KEY>
Content-Type: application/json
Idempotency-Key: <твой уникальный id заказа>

{
  "targetSteamId": "76561199181697547",
  "count": 100,
  "types": "ftl"
}
```

Распределение задаётся ОДНОЙ из двух форм. Обе в одном запросе — `400`
(`{"error":"invalid request","reason":"both_forms"}`): угадывать, какая из них главная, хуже
отказа, потому что коменды необратимы.

**Форма 1 — `count` + `types`** (как было): одно число на все счётчики из `types`.

**Форма 2 — `deltaF` / `deltaT` / `deltaL`**: сколько добавить каждому счётчику по
отдельности. Ровно для случая «покупатель хочет 1000 на лидера и по 300 на остальные»:

```json
{ "targetSteamId": "76561199181697547", "deltaF": 300, "deltaT": 300, "deltaL": 1000 }
```

Такой заказ стоит **1000** похвал (максимум из трёх), а не 1600. Пакет «1000», проданный
покупателю, покрывает его целиком — проверяй у себя ту же формулу: `max(F, T, L) ≤ номинал`.

Поля:

| Поле | Тип | Правило |
|---|---|---|
| `targetSteamId` | string | SteamID64: ровно 17 цифр, начинается на `76561` |
| `count` | integer | Форма 1, вместе с `types`. ≥ 1, сколько похвал доставить |
| `types` | string | Форма 1, вместе с `count`. Непустое подмножество `ftl` в этом порядке: `f` friendly, `t` teacher, `l` leader. Валидны: `f`, `t`, `l`, `ft`, `fl`, `tl`, `ftl`. `"tf"` или `"ff"` — ошибка |
| `deltaF` / `deltaT` / `deltaL` | integer | Форма 2. Целые ≥ 0, хотя бы одно > 0; не указанные считаются нулём. Цена — `max` из трёх |

`Idempotency-Key` НЕ обязателен, но ставь его всегда: с ним повтор запроса (ретрай сети,
двойной клик) вернёт ИСХОДНЫЙ заказ, а не создаст второй платный.

**Успех `201`:**

```json
{
  "id": "5f1c1e6e-1c1e-4f1c-8f1c-1e6e1c1e6e1c",
  "status": "queued",
  "connect": "cs1.example.com:27015",
  "deltaF": 300, "deltaT": 300, "deltaL": 1000
}
```

`deltaF`/`deltaT`/`deltaL` возвращаются всегда — в том числе на заказ формы 1, где они
равны `count` по каждому счётчику из `types`. Показывай их покупателю: это то, что
заказано на самом деле.

**`200`** — тот же ответ на повтор с тем же `Idempotency-Key` (заказ уже существует).

`connect` — адрес, который надо отдать покупателю. В CS2 он подключается командой
`connect <адрес>` в консоли. **Отдельно вызывать `/rentals` не нужно:** заказ арендует
сервер сам и возвращает адрес (два исключения — §3).

### Отказы

| Код | Тело | Что делать |
|---|---|---|
| `400` | `{"error":"over_capacity","maxLikes":N}` | На этот профиль больше `N` уже не доставить никогда (пул исчерпан на эту цель). Предложи `N` или другой профиль |
| `400` | ошибки валидации | Чини запрос: SteamID64, `count`, `types`, дельты |
| `400` | `{"error":"invalid request","reason":"both_forms"}` | В теле обе формы распределения. Оставь одну |
| `401` | `{"error":"unauthorized"}` | Ключ неверен или отозван |
| `402` | `{"error":"insufficient_balance","balance":B,"required":R}` | Не хватает баланса. Пополнить в кабинете |
| `402` | `{"error":"no_seller_wallet"}` | Подключение не активно — писать нам |
| `503` | `{"error":"maintenance"\|"no_capacity","retryAfter":сек}` | Флот занят или на обслуживании. Повтори позже, учитывая `Retry-After` |

Баланс НЕ резервируется при создании: проверка на `402` нужна, чтобы отказать сразу,
а списывается всё равно только доставленное.

### Два публичных запроса, которые дают ответ ДО оплаты

Оба без ключа и без авторизации — зови их из своего бэкенда, пока покупатель ещё выбирает.

- `GET https://api.mamont.club/profile/{steamId}` → `limits.maxLikes` — сколько ещё можно доставить на ЭТОТ
  профиль. Проверяй здесь, а не лови `400 over_capacity` после того, как покупатель заплатил.
  Там же косметика профиля и текущие счётчики похвал (`gc`), если хочешь показать «было/стало».
  Лимит запросов — 30/мин с адреса, кешируй.
- `GET https://api.mamont.club/status` → `{ "delivery": { "available": bool, "reason": "...", "etaSeconds": N } }` —
  идут ли работы на флоте. `available: false` значит, что заказ сейчас получит `503`; покажи это
  на витрине вместо того, чтобы брать деньги и падать на создании.

---

## 1а. Присутствие цели без захода на сервер (доступ к аккаунту)

Опция для массовых заказов, когда покупатель не хочет заходить сам. Доступ выдаётся
ОТДЕЛЬНЫМ запросом — креды НИКОГДА не кладутся в тело `POST /orders` (там идемпотентность,
логи, чужая БД).

Два режима:

- **`qr`** — покупатель подтверждает вход в мобильном Steam, мы получаем только refresh-токен.
  Пароль и maFile нам не передаются, покупатель отзывает доступ сам в настройках Steam. Аккаунт,
  которым сканируют QR, ДОЛЖЕН быть тем же профилем, что в `targetSteamId` — иначе грант падает.
- **`password`** — ты передаёшь логин/пароль. Требует `consent: true` — этим ты подтверждаешь,
  что согласие клиента получено. Мы входим в аккаунт, **храним только refresh-токен** (не пароль),
  и стираем его сразу после того, как подключили цель.
  Если у аккаунта включён Steam Guard, ответ на `POST /presence-grants` скажет, что делать дальше:
  - `needsGuard: "email_code"` — Valve отправила код на почту (`detail` = домен почты);
  - `needsGuard: "device_code"` — нужен код из мобильного приложения-аутентификатора;
  - `needsConfirmation: "device_confirmation"|"email_confirmation"` — клиент подтверждает вход в
    приложении Steam / по ссылке из письма, код вводить не нужно.
  Код (для `*_code`) отправь так:
  ```http
  POST https://api.mamont.club/presence-grants/{id}/guard-code
  Authorization: Bearer <MAMONT_API_KEY>
  { "code": "K4B7X" }
     -> 200 { "ok": true }     # 400 invalid_code — неверный/просроченный код
  ```
  Если у аккаунта Guard на телефоне и ты знаешь его `shared_secret`, передай его в
  `credentials.sharedSecret` — код посчитаем сами, шаг с вводом не понадобится.

```http
POST https://api.mamont.club/presence-grants
Authorization: Bearer <MAMONT_API_KEY>
Content-Type: application/json

{ "targetSteamId": "76561199181697547", "mode": "qr" }
   -> 201 { "id": "...", "mode": "qr", "qrUrl": "steammobile://..." }   # покажи QR/ссылку покупателю

{ "targetSteamId": "76561199181697547", "mode": "password",
  "credentials": { "login": "...", "password": "...", "sharedSecret": "..." },
  "consent": true }
   -> 201 { "id": "...", "mode": "password" }
```

### Как следить за грантом

`GET /presence-grants/{id}` → `{ id, status, mode, attempts, ready, error }`.

| `status` | `ready` | Что это |
|---|---|---|
| `pending` | `false` | вход ещё идёт: ждём скан QR, код Guard или подтверждение в приложении |
| `pending` | `true` | **доступ получен** — создавай заказ |
| `failed` | `false` | не получилось, причина в `error` — грант мёртв, опрос прекращай |
| `spent` | `false` | доступ уже использован: цель внедрена, секрет стёрт. Это НОРМА, а не ошибка |

Опрашивай раз в 2–3 секунды и прекращай на `ready: true` ИЛИ на `status: "failed"` — по одному
`ready` цикл никогда не закончится, если покупатель просто не отсканировал.

Классы в `error`: `wrong_account` (вошли не тем профилем — самая частая ошибка `qr`, сканировать
надо ИМЕННО аккаунтом цели), `qr_timeout`, `invalid_credentials`, `credentials_required`.

Грант живёт **30 минут** с создания; после этого он `failed`, и нужен новый.

При `ready` создавай заказ на ТОТ ЖЕ `targetSteamId` как обычно (`POST /orders`) — присутствие
подхватится автоматически, `connect` покупателю отдавать не нужно.

### Отказы `POST /presence-grants`

| Код | Тело | Что делать |
|---|---|---|
| `400` | `{"error":"invalid_credentials"}` | Логин/пароль не приняты Steam. Самый частый отказ `password` — покажи покупателю понятный текст |
| `400` | `{"error":"credentials_required"}` | `mode: "password"` без `credentials` |
| `400` | `{"error":"consent_required"}` | `mode: "password"` без `consent: true` |
| `409` | `{"error":"grant_exists"}` | На эту цель уже есть живой грант — используй его `id`, второй не создавай |
| `503` | `{"error":"unavailable"}` | Режим доступа временно выключен целиком. Откатывайся на обычный заход по `connect` |

Отдельного кода «QR выключен» НЕТ: и `qr`, и `password` выключаются одним `503 unavailable`.

Если доступ получить не удалось, заказ на эту цель просто ждёт присутствия и уходит в `expired` —
**денег это не стоит**.

---

## 1б. Погасить ваучер за покупателя

Нужно, только если вы закупаете у нас ссылки `https://mamont.club/r#cs2k_…` и перепродаёте их как товар.
Штатно покупатель открывает ссылку сам. Этот запрос — для случая, когда он **не может**: сайт у него
не грузится, VPN не помогает, другой браузер тоже. Раньше это чинил человек, и покупатель ждал часами.

```http
POST https://api.mamont.club/vouchers/redeem
Authorization: Bearer <MAMONT_API_KEY>
Content-Type: application/json
Idempotency-Key: <твой уникальный id заказа>

{
  "code": "https://mamont.club/r#cs2k_EXAMPLEonlyNotARealVoucherCode",
  "targetSteamId": "76561199181697547"
}
```

`code` — можно **ссылку целиком** (как она лежит у тебя на складе) или голый `cs2k_…`. Разбирать
её самому не надо.

`deltaF`/`deltaT`/`deltaL` — необязательные, те же, что у `POST /orders` (§1). Без них ваучер
гасится как раньше: весь номинал на все три счётчика. С ними действует то же правило цены —
`max(F, T, L)` не больше номинала ваучера, иначе `400 over_capacity` и **ваучер остаётся
непогашенным**. Недобранная разница не сгорает: см. «Что происходит с недовезённым остатком».

`Idempotency-Key` здесь **ОБЯЗАТЕЛЕН**, в отличие от `/orders`: запрос тратит одноразовый документ,
и без ключа потерянный ответ оставит тебя с погашенным ваучером и `409` вместо заказа. Без
заголовка — `400 idempotency_key_required`.

**Успех `201`** (та же форма, что у `/orders`, плюс `count`/`types`):

```json
{
  "id": "5f1c1e6e-…", "status": "queued", "connect": "cs1.example.com:27015",
  "count": 50, "types": "ftl", "deltaF": 50, "deltaT": 50, "deltaL": 50
}
```

**`200`** — повтор с тем же `Idempotency-Key`: тот же заказ, ваучер второй раз не гасился.

Что в этих полях:

- `count` — сколько похвал доставит заказ. Без распределения это номинал ваучера: сколько в нём
  лежит, знает эмитент, влиять на это нечем. С распределением — `max(F, T, L)`, а разница
  остаётся на твоём балансе.
- `types` — какие счётчики заказ поднимает. Без распределения всегда `ftl`: покупатель купил N
  похвал, и один коменд поднимает все три счётчика сразу; на цену это не влияет (§«Модель», п. 7).

Дальше — как у обычного заказа: `GET /orders/{id}` (§2) и те же вебхуки (§4).

### Отказы

| Код | Тело | Что делать |
|---|---|---|
| `400` | `{"error":"over_capacity","maxLikes":N}` | Заказ больше, чем можно доставить: либо цель уже не примет столько (`N` — остаток пула), либо распределение дороже номинала (`N` — номинал). **Ваучер ЦЕЛ** |
| `400` | `{"error":"idempotency_key_required"}` | Поставь заголовок |
| `400` | ошибки валидации | `code` не похож на код/ссылку, либо неверный SteamID64 |
| `401` | `{"error":"unauthorized"}` | Ключ неверен или отозван |
| `402` | `{"error":"no_seller_wallet"}` | Подключение не активно — писать нам |
| `403` | `{"error":"foreign_voucher"}` | Ваучер выпущен НЕ тебе (или это розничная ссылка). Ваучер цел, гасить нечего |
| `404` | `{"error":"invalid_code"}` | Такого кода нет — проверь, что скопировал целиком |
| `409` | `{"error":"already_redeemed","redeemedAt":"…","orderId":"…"}` | **Это норма, а не авария** — см. ниже |
| `429` | `{"error":"rate_limited"}` | Слишком часто; повтори позже |
| `503` | `{"error":"maintenance"\|"no_capacity","retryAfter":сек}` | Флот занят. **Ваучер ЦЕЛ** — повтори с тем же `Idempotency-Key` |

**`409 already_redeemed` — ровно тот случай, ради которого всё это и сделано:** покупатель открыл
ссылку сам, пока твой бот с ним разговаривал. Ничего делать не надо, всё в порядке. Отличай его от
`404 invalid_code`, где код неверен и нужен человек.
`orderId` в теле — заказ, который погасил этот ваучер; его можно опрашивать. `orderId: null`
значит, что ваучер погашен покупателем **на сайте** — там заказ ведёт он сам, и опрашивать нечего.

**Ваучер не гаснет ни на одном отказе выше.** `400 over_capacity` и `503` проверяются ДО погашения
специально: сжечь ваучер на заказ, который заведомо не пролезет, — это деньги списаны, лайки не
доставлены, вернуть нечего.

### Что происходит с недовезённым остатком

Номинал ваучера зачисляется **на твой баланс**, и заказ списывается с него по факту доставленного —
ровно как обычный `POST /orders`. Ваучер при этом уже не существует, и повторно погасить его
нельзя, но недовезённое никуда не делось: оно осталось на балансе. Как его досчитать и дозаказать —
§1в.

---

## 1в. Дозаказ остатка

Заказ может закрыться, не довезя всё. Это не сбой интеграции и не повод оформлять покупку заново:
деньги за недовезённое остаются у тебя, и остаток дозаказывается обычным `POST /orders`.

**Списывается только доставленное.** Никаких удержаний за неудачную попытку нет:

| Статус | Списано | Где деньги |
|---|---|---|
| `completed` | всё заказанное | доставлено полностью |
| `partial` | только доставленное | разница на твоём балансе |
| `expired` | ничего | вся сумма на твоём балансе |
| `failed` | ничего | вся сумма на твоём балансе |

Заказ, не доехавший вовсе (`expired`, `failed`), не стоил тебе ничего: весь его номинал
**остался на балансе** и доступен следующему заказу. Источник остатка роли не играет — ваучер
(§1б) и обычный заказ ведут себя одинаково.

### Посчитать недобор

`GET /orders/{id}` возвращает заказанное и доставленное по каждому счётчику отдельно. Вычитай
одно из другого — суммарного `sent` для этого мало, потому что недобрать может один счётчик из трёх:

```json
{
  "status": "partial", "count": 1000, "sent": 800,
  "deltaF": 1000, "deltaT": 1000, "deltaL": 1000,
  "deliveredF": 1000, "deliveredT": 800, "deliveredL": 800
}
```

Здесь friendly добран, teacher и leader недобрали по 200 — дозаказ `{"deltaT": 200, "deltaL": 200}`,
и стоить он будет 200, а не 400.

### Дозаказать

```http
POST https://api.mamont.club/orders
Authorization: Bearer <MAMONT_API_KEY>
Content-Type: application/json
Idempotency-Key: <НОВЫЙ id, не тот, что у исходного заказа>

{ "targetSteamId": "76561199181697547", "deltaT": 200, "deltaL": 200 }
```

**Ключ идемпотентности обязан быть новым.** С ключом исходного заказа в ответ придёт `200` и тот
самый старый заказ — дозаказ молча не создастся, а ты решишь, что создался.

**Покупателю снова нужно зайти на сервер.** Дозаказ — это полноценный заказ со своим окном
ожидания: он вернёт `connect`, и если покупатель не подключится, уйдёт в `expired` (деньги
снова останутся на балансе).

### Почему дозаказ может не пролезть

Один наш аккаунт хвалит конкретный профиль **один раз и навсегда** — повторно он для этой цели не
годится. Поэтому каждая доставка уменьшает остаток пула именно на эту цель, и дозаказ упирается уже
в него, а не в твой баланс:

```json
{"error":"over_capacity","maxLikes":150}
```

`maxLikes` — сколько эта цель ещё может принять. Закажи не больше этого числа; деньги при таком
отказе не списываются вовсе. Узнать потолок заранее, не создавая заказ, можно публичным запросом
из §1 («Два публичных запроса, которые дают ответ ДО оплаты») — там то же число приходит как
`limits.maxLikes`. Если остаток нужен покупателю целиком — напиши нам, пул пополняется.

---

## 2. Следить за заказом

`GET /orders/{id}` с тем же `Authorization`.

```json
{
  "id": "...", "status": "partial", "count": 1000, "sent": 500,
  "deltaF": 300, "deltaT": 300, "deltaL": 1000,
  "deliveredF": 300, "deliveredT": 300, "deliveredL": 500
}
```

`sent` — сколько похвал уже доставлено, `deliveredF`/`deliveredT`/`deliveredL` — сколько из
них пришло на каждый счётчик. При недовозе это единственный способ сказать покупателю, чего именно
не хватило, и посчитать дозаказ: в примере выше friendly и teacher добраны полностью, а leader
недобрал 500 — дозаказывается `{"deltaL": 500}`.

Возможные `status`:

| Статус | Терминальный | Смысл |
|---|---|---|
| `queued` | нет | принят, ждём |
| `waiting_for_target` | нет | ждём покупателя на сервере (первый заход или он вышел) |
| `executing` | нет | идёт доставка |
| `verifying` | нет | проверяем результат |
| `completed` | да | доставлено полностью |
| `partial` | да | доставлено меньше заказанного — вернуть покупателю разницу |
| `failed` | да | не смогли доставить (наша сторона) |
| `expired` | да | покупатель не зашёл в отведённое окно, деньги не списаны |

**Как опрашивать:** раз в 15–30 секунд, пока статус не станет терминальным. Не чаще раза в
5 секунд.

**Дедлайн считай от `count`, а не константой** — короткий таймаут это самая частая ошибка в
такой интеграции:

```
deadlineMs = max(6ч, count / 20 * 5 минут * 2)
```

Двойной запас — на паузы, пока покупателя нет на сервере. Верхняя граница жёсткая: заказ живёт
не дольше **24 часов**, после чего закрывается сам. Заказ на 3000 похвал — это ~12–13 часов
доставки, и таймаут в шесть часов оборвёт твою сторону на середине живого заказа.

Заказ, у которого `status` терминальный, больше не изменится.

---

## 3. Аренда сервера — когда она всё-таки нужна

По умолчанию НЕ нужна: `POST /orders` арендует сервер сам и возвращает `connect`, а повторный
вызов на ту же цель отдаёт ту же аренду, а не вторую. Отдельные вызовы нужны в двух случаях.

- **Адрес нужен ДО заказа** (показать «куда заходить» на странице оплаты) — `POST /rentals`
  `{ "targetSteamId": "…" }` → `201 { id, state, connect }`. Заказ, созданный позже на ту же
  цель, переиспользует эту аренду.
- **Сервер оказался неподходящим** — `POST /rentals/{id}/switch` `{ "exclude": ["<serverId>"] }`
  переносит цель на другой сервер флота и возвращает новый `connect`. `409 not_pending`, если
  доставка уже началась.

Ещё есть `GET /rentals/{id}` (`state`, `expiresAt`, `graceUntil`) и `POST /rentals/{id}/cancel`
(`409`, если аренду уже нельзя отменить). Чужая аренда — `404`, не `403`.

Отказы те же, что у заказа: `503 maintenance|no_capacity` с `Retry-After`.

---

## 4. Вебхук (необязательно)

Адрес и секрет ставятся в кабинете https://mamont.club/ru/seller. Только `https`. Секрет
показывается один раз.

Мы шлём `POST` на твой адрес при терминальном статусе:

```json
{
  "event": "order.partial",
  "orderId": "...",
  "status": "partial",
  "targetSteamId": "76561199181697547",
  "ordered": 1000,
  "delivered": 500,
  "types": "ftl",
  "deltas": { "f": 300, "t": 300, "l": 1000 },
  "deliveredDeltas": { "f": 300, "t": 300, "l": 500 }
}
```

`deltas`/`deliveredDeltas` — то же самое, что `deltaF…`/`deliveredF…` в `GET /orders/{id}`.
`ordered`/`delivered`/`types` не изменились.

События: `order.completed`, `order.partial`, `order.failed`, `order.expired`. `order.expired` ≠ `order.failed`:
первое — не пришёл его покупатель, второе — не справились мы.

### Проверка подписи (обязательна)

Заголовки: `x-mamont-timestamp` (unix-секунды) и `x-mamont-signature`
(HMAC-SHA256, hex).

Подпись считается над строкой `${timestamp}.${тело}` — **по СЫРЫМ байтам тела**, до
парсинга JSON. Пересобранный `JSON.stringify(req.body)` даст другую подпись: порядок ключей
и экранирование не совпадут. В Express это значит `express.raw({type:"application/json"})`
или `verify`-колбэк, сохраняющий сырое тело.

```js
const crypto = require("node:crypto");

function verify(rawBody, headers, secret) {
  const ts = headers["x-mamont-timestamp"];
  const given = headers["x-mamont-signature"];
  if (!ts || !given) return false;
  // Свежесть: подпись не мешает переиграть перехваченный запрос позже.
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
  if (expected.length !== given.length) return false;
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(given));
}
```

### Ответ и ретраи

- Успех — любой `2xx`. Таймаут 10 секунд. Редиректы не следуются.
- До 8 попыток с нарастающей паузой (~1 мин, 5 мин, 25 мин, 2 часа, дальше раз в час).
- **`4xx` кроме `408` и `429` тратит весь бюджет попыток сразу** — это «понял и отказал».
  Нужен ретрай — отвечай `5xx` или `429`.
- Каждое событие приходит один раз на пару (заказ, событие), но обработчик всё равно
  делай идемпотентным по `orderId` + `event`.

---

## Чего НЕ делать

- Не строй выдачу только на вебхуке — опрос статуса обязателен.
- Не ставь таймаут заказа в минуты и не ставь его константой. Тысяча похвал — это часы.
- Не считай `partial` ошибкой: это успешная частичная доставка, вернуть надо разницу.
- Не проверяй подпись по пересобранному JSON.
- Не вызывай `/rentals` перед каждым заказом — `POST /orders` уже вернул `connect`; аренда
  нужна только в двух случаях из §3.
- Не жди у гранта доступа одного `ready` — на `status: "failed"` опрос надо прекращать.
- Не храни ключ и секрет в коде и не логируй их.
- Не жди, что `types` изменит цену: она зависит только от `count`, а у заказа с распределением —
  от `max(deltaF, deltaT, deltaL)`. Складывать три дельты нельзя: это завысит чек до трёх раз.
- Не считай `409 already_redeemed` ошибкой и не зови человека: покупатель просто открыл ссылку сам.
- Не выписывай заказ с баланса поверх уже выданной ссылки вместо `/vouchers/redeem` — заплатишь
  дважды, потому что ссылка останется рабочей.

---

## Чек-лист приёмки

Перед тем как сказать «готово», убедись:

- [ ] Ключ и секрет читаются из окружения, в репозитории их нет.
- [ ] На создании заказа ставится `Idempotency-Key`, и повтор не создаёт второй заказ.
- [ ] Покупателю показывается `connect` и объяснено, что нужно зайти в течение 15 минут
      и оставаться на сервере до конца.
- [ ] Покупателю сказано, что при выходе с сервера есть 15 минут вернуться, иначе заказ
      закроется как `partial` с тем, что успело доехать.
- [ ] Ссылка покупателя разворачивается через `/steam/resolve`, а `404` и `502` показываются
      человеку РАЗНЫМИ сообщениями (см. §0).
- [ ] Пользователю показана честная оценка времени (`count / 20 * 5` минут).
- [ ] Опрос статуса идёт до терминального, с паузой 15–30 с и дедлайном по формуле из §2
      (не константой), но не больше 24 часов.
- [ ] Потолок на профиль проверен заранее через `GET /profile/{steamId}`, а не пойман
      как `400 over_capacity` после оплаты.
- [ ] Все четыре терминальных исхода обработаны раздельно, `partial` возвращает разницу,
      `expired` не списывает с покупателя ничего.
- [ ] `402` и `503` показываются пользователю понятным текстом, а не «ошибка сервера».
- [ ] Если используются ваучеры (§1б): `Idempotency-Key` ставится всегда, `409 already_redeemed`
      обрабатывается как успех, а `403 foreign_voucher` и `404 invalid_code` разведены — первый
      значит «ваучер не наш», второй «код неверен».
- [ ] Если вебхук используется: подпись проверяется по сырому телу, проверяется свежесть
      таймстемпа, обработчик идемпотентен и отвечает `5xx` при своей внутренней ошибке.

Живая спека с примерами: https://api.mamont.club/reference
