# API Routers FastAPI-роутеры сервиса `contract_check.api`. Все пути — префикс `/api/v1` (кроме инфра-эндпоинтов `/healthz`, `/readyz`, `/metrics`). Роутеры регистрируются в приложении через `include_router`; теги соответствуют атрибуту `tags=` каждого `APIRouter`. ## Содержание - [Аутентификация](#аутентификация) - [health](#health) - [metrics](#metrics) - [auth](#auth) - [me](#me) - [billing](#billing) - [webhooks](#webhooks) - [pay page](#pay-page) - [documents](#documents) - [reports](#reports) - [b2b](#b2b) - [Коды ошибок](#коды-ошибок) --- ## Аутентификация Три независимые схемы, реализованные в [`api/deps.py`](../deps.py): | Зависимость | Заголовок | Кому выдаётся | Где используется | | ------------------- | ---------------------------------- | -------------------------------------- | ---------------------------------------- | | `AuthDep` | `Authorization: Bearer `| Сервисный токен (адаптеры bot/web/cli) | `/auth/telegram/*` — вход от имени бота | | `CurrentUserDep` | `Authorization: Bearer ` | JWT пользователя (Telegram-логин) | `/me`, `/documents`, `/reports`, `/b2b/keys*` | | `ApiKeyAuthDep` | `X-API-Key: ` | B2B-ключ внешнего клиента | `/analyze`, `/b2b/reports/*`, `/b2b/usage` | - Сервисный токен и B2B-ключ хешируются (`hash_token`, `hash_api_key`), в БД хранится только хеш; raw-значение возвращается один раз при создании. - `ApiKeyAuthDep` дополнительно применяет token-bucket rate limit и месячный квоты (`429` при превышении). - `CurrentUserDep` проверяет, что пользователь ещё есть в таблице `users` (stateless JWT + defence-in-depth). --- ## health Файл: [`health.py`](health.py). Тег: `health`. Без аутентификации. ### `GET /healthz` Liveness-проба. Возвращает `200` всегда. ```json { "status": "ok" } ``` ### `GET /readyz` Readiness-проба. Выполняет `SELECT 1` в БД. ```json { "status": "ok" } ``` Если БД недоступна: ```json { "status": "not_ready", "reason": "db: " } ``` --- ## metrics Файл: [`metrics.py`](metrics.py). Тег: `metrics`. Без аутентификации. ### `GET /metrics` Exposition-эндпоинт Prometheus. Тело — текст в формате `text/plain; version=0.0.4` (`prometheus_client.generate_latest`). --- ## auth Файлы: [`auth/`](auth/) — `telegram.py`, `password.py`, `passkeys.py`, `magic_links.py`, `support.py`. Тег: `auth`. Три источника идентичности сходятся к одному JWT: ### `POST /api/v1/auth/telegram/bot` Бот (aiogram) обменивает проверенный `telegram_id` на JWT пользователя. **Auth:** `AuthDep` (сервисный токен бота). Тело — `TelegramBotAuthRequest`: | Поле | Тип | Условие | Описание | | ------------ | ---- | ------------ | ------------------------------------- | | `telegram_id`| int | `> 0` | Verified Telegram user id из aiogram | Ответ `200` — `AuthResponse` (см. ниже). ### `POST /api/v1/auth/telegram/web` Callback Telegram Login Widget. **Auth:** нет (проверяется подпись Telegram). Тело — `TelegramWebAuthRequest`: | Поле | Тип | Условие | | ----------- | --------- | ------- | | `id` | int | `> 0` | | `first_name`| str? | — | | `last_name` | str? | — | | `username` | str? | — | | `photo_url` | str? | — | | `auth_date` | int | — | | `hash` | str | — | `401` при ошибке проверки подписи. Ответ `200` — `AuthResponse`. ### `POST /api/v1/auth/telegram/miniapp` Mini App initData. **Auth:** нет (проверяется подпись ` initData`). Тело — `TelegramMiniAppAuthRequest`: | Поле | Тип | Описание | | ---------- | --- | ------------------------------------------ | | `init_data`| str | Raw initData query string из `Telegram.WebApp` | `401` при ошибке. Ответ `200` — `AuthResponse`. #### `AuthResponse` ```json { "access_token": "", "token_type": "bearer", "expires_in": 3600, "user_id": "uuid", "telegram_id": 123456789 } ``` `expires_in` = `jwt_access_ttl_minutes * 60` из настроек. ### `GET /api/v1/auth/me` Интроспекция Bearer-JWT. **Auth:** `Authorization: Bearer ` в заголовке. Ответ `200` — `TokenIntrospectResponse`: | Поле | Тип | | ------------ | --- | | `sub` | str (UUID) | | `telegram_id`| int | | `type` | str | | `exp` | int (unix) | `401` — отсутствует/невалиден. ### `GET /api/v1/auth/me/permissions` Заглушка под будущее RBAC. ```json { "permissions": ["upload", "read_reports", "read_me"] } ``` --- ### WebUI auth (email + password) Шесть эндпоинтов для SPA-фронта. Возвращают **JWT-пару**: короткий access (default 24h) + длинный refresh (default 30d, ttl в Redis). Refresh-token поддерживает отзыв через logout; access невалидируется только истечением TTL. | Метод | Путь | Auth | Описание | | ----- | --------------------------- | ------ | --------------------------------------- | | POST | `/api/v1/auth/register` | — | Регистрация → 201 + JWT pair | | POST | `/api/v1/auth/login` | — | Вход → 200 + JWT pair | | POST | `/api/v1/auth/logout` | — | Отзыв refresh-токена (в теле) | | GET | `/api/v1/auth/me` | Bearer | Текущий пользователь (расширенный ответ)| | POST | `/api/v1/auth/forgot-password` | — | Запрос сброса (202, всегда одинаковый ответ) | | POST | `/api/v1/auth/reset-password` | — | Сброс пароля по токену из письма | #### `POST /api/v1/auth/register` Тело — `RegisterRequest`: | Поле | Тип | Условие | | ---------- | ------- | --------------- | | `email` | EmailStr | валидный email | | `password` | str | `8..128` символов | `409` если email занят. `201` — `TokenPairResponse`. #### `POST /api/v1/auth/login` Тело — `LoginRequest` (те же поля). `401` при неверных кредах. #### `TokenPairResponse` ```json { "access_token": "", "refresh_token": "", "token_type": "bearer", "expires_in": 86400, "user": { "id": "uuid", "email": "user@example.com", "telegram_id": null, "credits_left": 0, "is_active": true, "created_at": "2026-08-12T..." } } ``` #### `POST /api/v1/auth/logout` Тело — `LogoutRequest`: | Поле | Тип | | --------------- | --- | | `refresh_token` | str | Идемпотентен. Access-токен остаётся валидным до истечения своего TTL (см. `JWT_ACCESS_TTL_MINUTES`); refresh уничтожается в Redis сразу. #### `POST /api/v1/auth/forgot-password` Тело — `ForgotPasswordRequest` (`email`). Всегда отвечает `202 OkResponse` с телом `{"ok": true, "detail": "if the email exists, a reset link was sent"}` — чтобы не раскрывать, какие адреса зарегистрированы. API: 1. Генерирует `secrets.token_urlsafe(32)`, хранит только SHA-256 от него в `users.password_reset_token_hash` с TTL = `PASSWORD_RESET_TTL_MINUTES`. 2. Публикует `NotificationMessage(kind=password_reset)` в очередь `notify.q` (RabbitMQ). 3. `worker-notify` достаёт сообщение и шлёт письмо через SMTP (`SMTP_HOST`/`SMTP_PORT`/`SMTP_USE_TLS`/`SMTP_FROM`). При пустом `SMTP_HOST` (dev) тело письма пишется в лог. #### `POST /api/v1/auth/reset-password` Тело — `ResetPasswordRequest`: | Поле | Тип | Условие | | ---------- | --- | --------------- | | `token` | str | из письма | | `password` | str | `8..128` символов | `400` при невалидном/просроченном токене. Успех: пароль перезаписывается (argon2id), `password_reset_token_hash` сбрасывается, **все активные refresh-токены этого пользователя отзываются** в Redis (принудительный re-login на всех устройствах). #### `GET /api/v1/auth/me` Расширение прежнего introspect-эндпоинта: теперь читает Bearer access JWT, ищет пользователя в БД и возвращает полный профиль. Старые поля (`sub`, `telegram_id`, `type`, `exp`) сохранены для совместимости; добавлены `email`, `credits_left`, `is_active`, `created_at`. ### Passkeys (WebAuthn) + magic-link Passwordless-методы веб-аутентификации. Оба выдают **один access JWT** (без refresh): `passkeyAuthenticationResponseSchema` / `magicLinkVerifyResponseSchema` на фронте. | Метод | Путь | Auth | Описание | | ----- | ---------------------------------------- | ------ | -------------------------------------------- | | POST | `/api/v1/auth/passkeys/register/start` | Bearer | PublicKeyCredentialCreationOptions + challenge в Redis | | POST | `/api/v1/auth/passkeys/register/finish` | Bearer | Проверка attestation → 201, ключ сохранён | | POST | `/api/v1/auth/passkeys/authenticate/start` | — | PublicKeyCredentialRequestOptions (discoverable) | | POST | `/api/v1/auth/passkeys/authenticate/finish` | — | Проверка assertion → access JWT | | GET | `/api/v1/auth/passkeys` | Bearer | Список пасскеев пользователя | | DEL | `/api/v1/auth/passkeys/{id}` | Bearer | Удалить пасскей (только свой) | | POST | `/api/v1/auth/magic-link/request` | — | Одноразовая ссылка входа на email | | POST | `/api/v1/auth/magic-link/verify` | — | Обмен токена из письма на access JWT | Детали: - Ceremonies — по два шага (start/finish). Challenge живёт в Redis `PASSKEY_CHALLENGE_TTL_SECONDS` (default 120 c), одноразовый. Регистрация: challenge ключуется `user_id`; аутентификация — самим challenge (клиент возвращает его в `finish`-запросе). - `authenticatorSelection`: `residentKey=required`, `userVerification=required` → discoverable-креды, `allowCredentials` пустой; пользователь определяется на finish по `credential.id` из БД. - RP-параметры: `PASSKEY_RP_ID` / `PASSKEY_RP_NAME` / `PASSKEY_RP_ORIGINS` (comma-separated). Флаги `PASSKEY_ENABLED`, `MAGIC_LINK_ENABLED` включают/выключают группы эндпоинтов (404 при выключении). - Хранение: таблица `passkey_credentials` (credential_id/public_key — base64url, sign_count — защита от клонирования, ротация на каждом входе). - Magic-link повторяет паттерн forgot-password: SHA-256 хеш токена + TTL (`MAGIC_LINK_TTL_MINUTES`, default 15) в `users`, письмо через `notify.q` (`kind=magic_link`), ссылка `{WEB_APP_BASE_URL}/magic-link?token=...`. Ответ request всегда одинаковый (не раскрывает зарегистрированные email), verify одноразовый — хеш сбрасывается сразу. #### `POST /api/v1/auth/passkeys/register/start` Тело (опционально): `{ "device_name": "YubiKey 5" }`. Ответ — `PasskeyRegistrationOptions` (camelCase, base64url-строки; соответствует zod-схеме фронта): `challenge`, `rp {id, name, origin}`, `user {id, name, displayName}`, `pubKeyCredParams[]`, `timeout`, `attestation="none"`, `excludeCredentials[]`, `authenticatorSelection {residentKey, userVerification}`. #### `POST /api/v1/auth/passkeys/register/finish` Тело: `{ "credential": {...result of navigator.credentials.create()...}, "device_name"? }` (base64url). Ответ `201` — `{ "verified": true, "credentialId": "" }`. `400` — нет/просрочен challenge, битый attestation; `409` — credential уже зарегистрирован. #### `POST /api/v1/auth/passkeys/authenticate/start|finish` start — без тела, ответ `PasskeyAuthenticationOptions`: `challenge`, `timeout`, `rpId`, `allowCredentials=[]`, `userVerification`. finish — тело `{ "challenge": "<из start>", "credential": {...result of navigator.credentials.get()...} }`; ответ — access JWT (см. ниже). `400` — неизвестный/просроченный challenge или битая подпись; `401` — неизвестный credential; `403` — аккаунт отключён. #### `GET /api/v1/auth/passkeys` / `DELETE /api/v1/auth/passkeys/{id}` Список — массив `{ id, credentialID, credentialPublicKey, counter, userId, deviceName, createdAt }`, новые первыми. Удаление чужого/несуществующего — `404`, успех — `204 No Content` (пустое тело). #### `POST /api/v1/auth/magic-link/request` / `verify` request — тело `{ "email" }`, всегда `200 { success, message, expires_in }` (секунды). verify — тело `{ "token" }`, ответ: ```json { "access_token": "", "token_type": "bearer", "expires_in": 86400, "user_id": "uuid", "email": "user@example.com" } ``` `400` — невалидный/просроченный токен (протухшие сбрасываются сразу). --- ## me Файл: [`me.py`](me.py). Тег: `me`. **Auth:** `CurrentUserDep` (user JWT). ### `GET /api/v1/me` Профиль пользователя и баланс кредитов. ```json { "telegram_id": 123456789, "credits_left": 7 } ``` ### `GET /api/v1/me/profile` / `PATCH /api/v1/me/profile` Пассивные пользовательские настройки (миграция 0011; строка создаётся лениво на первом чтении, отсутствующие поля = дефолты). НИКОГДА не попадает в LLM-промпты. PATCH — частичное обновление любого поля: ```json { "language": "ru", "timezone": "Europe/Minsk", "notif_prefs": { "report_ready": true, "security": true, "marketing": false }, "dashboard_prefs": { "severity_filter": "all", "per_page": 10, "density": "comfortable" } } ``` `422` — неизвестный язык (`ru|be|en`), не-IANA таймзона, неизвестные ключи prefs. Ответ обоих методов — полное `UserProfile`. ### `GET /api/v1/me/overview` Дашборд одной строкой: ```json { "docs": { "total": 12, "done": 10, "failed": 1, "in_progress": 1 }, "credits_left": 7, "plan": { "code": "pro", "name": "Pro", "quota": 20, "quota_used": 3, "period_end": "..." }, "billing_hold": false } ``` `plan = null` без активной подписки; `billing_hold=true` — загрузки отвечают `402`. --- ## billing Файл: [`billing.py`](billing.py). Тег: `billing`. **Auth:** `CurrentUserDep`, кроме каталога. Все суммы — копейки (int). `YOOKASSA_ENABLED=false` ⇒ мутации отвечают `503`, `GET /billing/plans` читается. | Метод | Путь | Описание | | ----- | ---- | -------- | | GET | `/api/v1/billing/plans` | каталог активных тарифов + `price_per_doc_kopecks` | | POST | `/api/v1/billing/checkout` | топ-ап или подписка → `201 {invoice_id, confirmation_url}` | | GET | `/api/v1/billing/invoices` | свои счета (`limit`/`offset`) | | GET | `/api/v1/billing/invoices/{id}` | свой счёт (`404` чужой) | | GET | `/api/v1/billing/subscription` | текущая подписка (active/past_due) | | POST | `/api/v1/billing/subscriptions/autorenew` | `{enabled: bool}` на активную подписку | | POST | `/api/v1/billing/refund` | авто-возврат по правилу D10 | ### `POST /api/v1/billing/checkout` Тело — один из двух вариантов: ```json { "kind": "topup", "credits": 20 } // пакеты 1/5/20 × PRICE_PER_DOC_KOPECKS { "kind": "subscription", "plan_code": "pro" } ``` Создаёт `pending`-счёт + платёж ЮKassa (Idempotence-Key = invoice uuid). `409` — уже есть активная подписка; `422` — неизвестный пакет/тариф; `502` — провайдер недоступен (счёт отменяется). ### `POST /api/v1/billing/refund` Тело `{ "invoice_id": "uuid" }`. Считает возврат: полный при возрасте ≤ `REFUND_WINDOW_DAYS` (14) И использовании ≤ 20% купленного, иначе пропорциональный `max(0, amount − used × price_per_doc)`. Исполняет провайдер-возврат, клавбэк оставшихся активов, при уходе баланса в минус — `billing_hold`. Ответ: ```json { "invoice_id": "uuid", "refunded_kopecks": 99500, "kind": "proportional", "reason": "used 15 of 20", "billing_hold": false } ``` `404` чужой/несуществующий счёт; `409` — уже возвращен / не возвращаем (zero). --- ## webhooks Файл: [`webhooks.py`](webhooks.py). Тег: `webhooks`. ### `POST /api/v1/webhooks/yookassa` **Auth:** Basic (`shopId:secretKey`, сверка constant-time). Уведомления ЮKassa `payment.*` / `refund.*`. Push-payload НЕ доверяем: платёж всегда перезапрашивается через REST, статус применяется идемпотентным стейт-машина (`core/billing/fulfillment.py` — общий с worker-billing). Всегда `200` (`ignored`/`unknown_invoice`/`disabled` в теле), чтобы ЮKassa не ретраила. --- ## pay page Файл: [`../billing/pay_page.py`](../billing/pay_page.py). Тег: `billing`. ### `GET /pay/{invoice_id}?token=…` Server-rendered HTML-статус счёта после редиректа с ЮKassa. `token` — короткоживущий HS256 JWT (`BILLING_RETURN_JWT_SECRET`, иначе `JWT_SECRET`; TTL `BILLING_RETURN_TOKEN_TTL_MINUTES`). `403` — нет/просрочен/чужой токен. Показывает статус (pending/succeeded/cancelled) и сумму; для pending — кнопку на `confirmation_url`. --- ## documents Файл: [`documents.py`](documents.py). Тег: `documents`. **Auth:** `CurrentUserDep`. ### `POST /api/v1/documents` Загрузить документ на анализ. Резервирует кредит, кладёт файл в MinIO, создаёт строки `documents` + `jobs`, публикует `DocumentUploaded` в RabbitMQ. Запрос — `multipart/form-data`: | Поле | Тип | Описание | | ----- | ----------- | ------------------------------------- | | `file`| UploadFile | Расширения: `.pdf`, `.docx`, `.rtf`, `.txt`, `.csv`, `.png`, `.jpg`, `.jpeg`, `.tif`, `.tiff` (см. `core/extraction/formats.py`) | Ответ `202` (результат [`upload_and_enqueue`](../services.py)): ```json { "document_id": "uuid", "correlation_id": "uuid", "credits_left": 6 } ``` Ошибки: - `400` — нет имени / неподдерживаемый формат / пустой файл; - `402` — нет кредитов (`no credits available`); - `500` — сбой MinIO/БД/RabbitMQ (кредит возвращается). --- ## reports Файл: [`reports.py`](reports.py). Тег: `reports`. **Auth:** `CurrentUserDep`. ### `GET /api/v1/reports/{document_id}` Опрос статуса/результата анализа. Документ обязан принадлежать аутентифицированному пользователю. Параметр пути: `document_id` — UUID. Пока анализ не готов (`status != "done"`): ```json { "document_id": "uuid", "status": "queued | extracting | analyzing | failed", "stage": "" } ``` Готовый отчёт (`status == "done"`): ```json { "document_id": "uuid", "status": "done", "filename": "contract.pdf", "markdown": "", "findings": { ... }, "model_used": "", "prompt_tokens": 1234, "eval_tokens": 5678, "latency_ms": 91011 } ``` `404` — отчёт не найден (или чужой). ### `GET /api/v1/reports/{document_id}/events` SSE-стрим статусов анализа (`text/event-stream`). Аутентификация и проверка владельца — до открытия стрима (`401`/`404` как обычные HTTP ошибки). Альтернатива опросу `GET /reports/{document_id}` для web-SPA: сервер сам поллит БД (`SSE_POLL_INTERVAL_SECONDS`) и шлёт событие при смене статуса. **Auth:** `Authorization: Bearer ` **или** query-параметр `?access_token=` — нативный `EventSource` не умеет ставить заголовки, поэтому для web-SPA: ```js new EventSource(`/api/v1/reports/${docId}/events?access_token=${jwt}`) ``` Нюанс: query-строка может попасть в access-логи прокси — передавать только короткоживущие access-токены (не refresh). Каждый кадр данных (безымянное событие → `onmessage` в `EventSource`) несёт payload контракта web-клиента `analysisResultSchema` (camelCase; см. [`api/schemas/analysis_result.py`](../schemas/analysis_result.py)): ```json { "id": "uuid", "fileName": "contract.pdf", "fileType": "application/pdf", "fileSize": 12345, "status": "pending | processing | completed | failed", "issues": [ { "id": "penalties-0", "severity": "critical | warning | info", "category": "penalties", "title": "Неустойки / штрафы", "description": "<риск>\n\nРекомендация: <рекомендация>", "fragment": "<цитата> (п. )", "lineNumber": null } ], "summary": "Критичных: 1, предупреждений: 1, замечаний: 0", "riskScore": 6, "createdAt": "2026-09-02T17:35:34.018348+00:00", "completedAt": "2026-09-02T17:36:10.104222+00:00" } ``` Маппинг: | Поле | Источник | | ---- | -------- | | `status` | `queued → pending`; `extracting/prescreening/ocr/analyzing → processing`; `done/manual_review → completed`; `failed → failed` | | `issues` | `reports.content_json.findings` (severity: `high→critical`, `medium→warning`, `low→info`) | | `riskScore` | вес находок: critical=4, warning=2, info=1, cap 10 (не хранится, вычисляется) | | `summary` | строка с количеством находок (не хранится, вычисляется) | | `completedAt` | `reports.created_at`; `null`, пока нет отчёта | События стрима: - безымянные кадры — снимок при подключении, затем по одному на смену статуса; кадр с `status: completed | failed` терминальный, стрим закрывается (клиенту нужно вызвать `es.close()`, иначе `EventSource` переподключится); - `timeout` — стрим жил дольше `SSE_MAX_STREAM_SECONDS`; клиент переподключается (`EventSource` делает это сам) или падает на опрос; - `error` — документ исчез посреди стрима. Между кадрами без изменений шлются комментарии `: keep-alive`, чтобы прокси не рвали соединение. Заголовок `X-Accel-Buffering: no` отключает буферизацию nginx. `404` — отчёт не найден (или чужой). --- ## b2b Файл: [`b2b.py`](b2b.py). Тег: `b2b`. Анализ-эндпоинты используют **`X-API-Key`** (`ApiKeyAuthDep`). Эндпоинты управления ключами используют **user JWT** (`CurrentUserDep`), т.к. вызываются внутренними адаптерами от имени пользователя (`telegram_id`). ### `GET /api/v1/b2b/profile` / `PUT /api/v1/b2b/profile` Настройки профиля владельца ключа — те же поля, что у `PATCH /api/v1/me/profile` (язык, таймзона, prefs). **Auth:** `X-API-Key`. PUT заменяет переданные поля целиком (не partial). Биллинг-эндпоинты партнёрам не открываются (D4). ### `POST /api/v1/analyze` Загрузить документ под B2B-ключом. **Auth:** `X-API-Key`. Запрос — `multipart/form-data` (`file`), как в `POST /api/v1/documents`. Резервирует кредит у владельца ключа, пишет `api_key_requests`, инкрементит `api_keys.monthly_used`. Логирование usage — best-effort (не валит загрузку при сбое записи). Ответ `202` — как у `POST /api/v1/documents`. ### `GET /api/v1/b2b/reports/{document_id}` Опрос отчёта, scoped к владельцу ключа. **Auth:** `X-API-Key`. Параметр пути: `document_id` — UUID. Формат ответа — идентичен `GET /api/v1/reports/{document_id}`. `404` — чужой/несуществующий. ### `GET /api/v1/b2b/usage` Текущее использование ключа. **Auth:** `X-API-Key`. ```json { "api_key_id": "uuid", "rate_limit_rps": 3, "monthly_quota": 1000, "monthly_used": 17, "requests_this_month": 17, "resets_at": "2026-09-01T00:00:00+00:00" } ``` ### `POST /api/v1/b2b/keys` Создать новый B2B-ключ. **Auth:** `CurrentUserDep`. Тело — `CreateApiKeyRequest`: | Поле | Тип | Default | Описание | | ---------------- | ----- | ------- | ------------------------------ | | `name` | str | — | Метка ключа | | `rate_limit_rps` | int? | `3` | Должно быть `> 0` | | `monthly_quota` | int? | `null` | Без лимита, если не задан | Ответ `201` — **raw-ключ возвращается только один раз**: ```json { "api_key": "sk_b2b_...", "id": "uuid", "name": "prod-webhooks", "rate_limit_rps": 3, "monthly_quota": null, "monthly_used": 0, "revoked": false, "created_at": "2026-08-12T..." } ``` `400` — `rate_limit_rps` не положителен; `500` — сбой БД. ### `GET /api/v1/b2b/keys` Список ключей пользователя. **Auth:** `CurrentUserDep`. Ответ — массив объектов формы `ApiKeyResponse`: | Поле | Тип | | ---------------- | -------- | | `id` | UUID | | `name` | str | | `rate_limit_rps` | int | | `monthly_quota` | int? | | `monthly_used` | int | | `revoked` | bool | | `created_at` | ISO-str | Сортировка — `created_at DESC`. Raw-ключи **не** возвращаются. ### `POST /api/v1/b2b/keys/{key_id}/revoke` Отозвать ключ. **Auth:** `CurrentUserDep`. Параметр пути: `key_id` — UUID. ```json { "id": "uuid", "revoked": true } ``` `404` — ключ не найден, чужой или уже отозван. ### `GET /api/v1/b2b/keys/{key_id}/usage` Помесячное использование конкретного ключа. **Auth:** `CurrentUserDep`. ```json { "key_id": "uuid", "name": "prod-webhooks", "rate_limit_rps": 3, "monthly_quota": 1000, "monthly_used": 17, "resets_at": "2026-09-01T...", "monthly_requests": [ { "month": "2026-08-01T...", "requests": 17 } ] } ``` `404` — ключ не найден или чужой. --- ## Коды ошибок Общий формат ошибки FastAPI: ```json { "detail": "<сообщение>" } ``` | Код | Когда | | --- | ----------------------------------------------------- | | 400 | Неверный запрос (bad format / пустой файл / bad arg) | | 401 | Нет/невалиден токен или API-ключ | | 402 | `no credits available` (при резерве кредита) | | 404 | Ресурс не найден или чужой | | 429 | Rate limit / месячный квот B2B-ключа исчерпан | | 500 | Сбой инфраструктуры (MinIO/PG/RabbitMQ) | При `429` от rate limiter добавляется заголовок `Retry-After`.