Вебхук `billing.settled`
Событие с итоговой суммой запроса. Регистрируется на организацию, подписывается HMAC, повторы идут с нарастающей паузой.
Зачем
Заголовок X-Billing-Cost в ответе шлюза — предварительная сумма на момент ответа. У части поставщиков суммы нет в момент ответа: её сообщает отдельная запись о расходе, иногда через минуты. Пока суммы нет, заголовка X-Billing-Cost не будет вовсе, а X-Reconciliation-Status придёт со значением pending.
Источник истины по деньгам — это событие. Оно приходит:
- когда запрос финализирован и сумма известна;
- повторно, если после сверки с поставщиком сумма изменилась (тип
billing.adjusted, полеdelta_kopecks).
Регистрация
POST /api/organization/billing/webhooks с ключом кабинета sk-mgmt-…:
{ "url": "https://client.example/billing", "description": "prod" }Ответ 201 содержит адрес и секрет подписи. Секрет показывается один раз:
{
"webhook": {
"id": "bwh_…",
"url": "https://client.example/billing",
"enabled": true,
"description": "prod",
"failureCount": 0,
"disabledReason": "",
"createdAt": "2026-11-08T12:00:00Z"
},
"secret": "whsec_…"
}Адрес только https: тело подписано, но не зашифровано. На организацию — до пяти адресов. Повторная регистрация того же адреса заменяет секрет и обнуляет счётчик неудач.
| Метод | Путь | Что делает |
|---|---|---|
GET | /api/organization/billing/webhooks | список адресов без секретов |
POST | /api/organization/billing/webhooks | зарегистрировать адрес, вернуть секрет один раз |
DELETE | /api/organization/billing/webhooks/{id} | удалить адрес и очередь его доставок |
POST | /api/organization/billing/webhooks/{id}/rotate-secret | новый секрет, адрес снова включён |
GET | /api/organization/billing/webhooks/deliveries | последние попытки доставки |
Список и доставки доступны любому участнику организации; регистрация, удаление и ротация — только управляющему.
Событие
{
"id": "bwe_req_7f3a_billing.settled",
"type": "billing.settled",
"request_id": "req_7f3a",
"organization_id": "org_…",
"model": "deepseek-v4.1-flash",
"provider": "ru-openrouter",
"cost_kopecks": 1234,
"cost_rub": "12.34",
"delta_kopecks": 0,
"occurred_at": "2026-11-08T12:00:00Z"
}| Поле | Смысл |
|---|---|
id | идентификатор события, стабильный для пары «запрос + тип» |
type | billing.settled — первая сумма; billing.adjusted — правка |
request_id | тот же id, что в заголовке X-Request-Id ответа шлюза |
model, provider | модель и фактическая площадка |
cost_kopecks | итоговая сумма запроса, целые копейки |
cost_rub | та же сумма строкой, для логов и человека; для расчётов брать копейки |
delta_kopecks | изменение относительно ранее объявленной суммы; 0 при первом событии |
occurred_at | момент формирования события, RFC 3339 |
Подпись
Заголовок X-LLMMart-Signature:
X-LLMMart-Signature: t=1700000000,v1=5f0d…9cПодписано HMAC-SHA256(secret, "<t>.<сырое тело>"). Проверять надо сырое тело, до разбора JSON. Допустимое расхождение по времени — пять минут; старое событие не применяется даже с верной подписью.
Проверка на Python:
import hmac, hashlib
def verify(secret: str, header: str, raw: bytes, tolerance: int = 300) -> bool:
parts = dict(piece.split("=", 1) for piece in header.split(",") if "=" in piece)
stamp, signature = parts.get("t"), parts.get("v1")
if not stamp or not signature:
return False
expected = hmac.new(secret.encode(), f"{stamp}.".encode() + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)Проверка на Go:
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(stamp + "."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
ok := hmac.Equal([]byte(expected), []byte(signature))Дополнительные заголовки запроса: X-LLMMart-Event (тип события) и X-LLMMart-Event-Id (id события).
Повторы
Ответ 2xx — доставлено. Всё остальное — попытка не удалась, паузы: 2 с → 8 с → 30 с → 2 мин → 1 час, дальше раз в час. Каждая доставка хранится отдельно на адрес и событие, поэтому повтор одного запроса не создаёт вторую доставку.
После 30 неудачных попыток подряд адрес выключается: enabled: false, в disabledReason — причина, в failureCount — счётчик. Включить обратно — rotate-secret (он же выдаёт новый секрет) или повторная регистрация того же адреса.
Идемпотентность
Поле id события стабильно для пары «request_id + type». Обработчик обязан помнить применённые id: повторная доставка после сетевого сбоя приходит с тем же id и означает то же самое. billing.adjusted с тем же request_id — отдельное событие и отдельный id; применить надо delta_kopecks, а не cost_kopecks.