Вебхук `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идентификатор события, стабильный для пары «запрос + тип»
typebilling.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.

Что дальше