Документация по вебхукам

Спецификация формата исходящих HTTP-запросов от Sports Platform к вашему приёмнику и инструкция по проверке подписи.

1. Как работает доставка

При наступлении подписанного события (публикация новости, удаление или смена видимости прогноза и т. д.) платформа отправляет POST-запрос с JSON-телом на URL вашего приёмника.

2. HTTP-заголовки

ЗаголовокЗначениеНазначение
Content-Typeapplication/jsonТип тела
User-AgentSportsContentPlatform/1.0Идентификация отправителя
X-Webhook-Eventnews.published и т. д.Тип события (см. раздел 4)
X-Webhook-SignatureHMAC-SHA256(body, secret) в hexПодпись для верификации источника. Приходит, только если в админке задан секрет вебхука.
Секрет (X-Webhook-Signature) — собственный для каждого вебхука. Это не ключ сайта и не API-ключ заказчика. Секрет задаёт администратор Sports Platform в карточке вебхука (раздел «API и выдача данных» → «Webhook» → «Редактирование» → поле «Секрет»). Минимум 16 символов.
Если в карточке вебхука секрет не задан, заголовок X-Webhook-Signature просто не приходит — приёмник не сможет верифицировать источник. В таком случае попросите администратора задать секрет.

3. Формат тела

{
  "event": "news.published",
  "timestamp": "2026-04-29T14:33:00.123456+00:00",
  "data": { ... }
}

4. Типы событий

eventКогда отправляется
news.publishedНовость опубликована (вышла из модерации в публичную ленту)
news.rejectedНовость отклонена модератором
prediction.createdСоздан экспертный прогноз
prediction.deletedAI-прогноз эксперта удалён — нужно удалить и на стороне сайта
prediction.visibility_changedУ AI-прогноза эксперта изменилась публичная видимость (появился/скрылся на сайте)
prediction.result_calculatedРассчитан результат прогноза — выигран или проигран (поле result). Обновите статус прогноза на сайте
math_prediction.generatedРасчётный матпрогноз (Poisson/Elo). Сейчас наружу приостановлено.
match.updatedОбновлены данные матча
testТестовое событие, отправляется по нажатию «Тест» в админке

Подписка на события задаётся в карточке вебхука. Можно подписаться на * — тогда придут все события.

Важно про неймспейс. События prediction.deleted и prediction.visibility_changed относятся к AI-прогнозу эксперта (тот, что отдаётся в /api/v1/predictions). Событие math_prediction.generated — это отдельный расчётный матпрогноз (xG/Poisson/Elo); его отправка наружу сейчас приостановлена.

4.1 Тела событий (data)

Ниже — структура поля data для каждого события. Конверт (event, timestamp) — как в разделе 3.

prediction.created

Появился новый AI-прогноз (в т.ч. при ночной генерации). Прилетает push-ом сразу при создании. Полные данные (ставки/обоснование) можно подтянуть из /api/v1/predictions/feed или /by-match/{match_external_id}.

{
  "event": "prediction.created",
  "timestamp": "2026-06-01T03:00:00.000000+00:00",
  "data": {
    "id": 23571,
    "match_external_id": "16231216",
    "expert_id": 21,
    "ui_status": "ready_to_publish",
    "status": "ready",
    "created_at": "2026-06-01T03:00:00.000000+00:00",
    "updated_at": "2026-06-01T03:00:00.000000+00:00"
  }
}

prediction.deleted

Прогноз удалён у нас — удалите его и на сайте. Сопоставление — по match_external_id (id матча в api-sport) + expert_id, либо по нашему id.

{
  "event": "prediction.deleted",
  "timestamp": "2026-05-31T08:00:00.000000+00:00",
  "data": {
    "id": 19090,                  // наш id прогноза (Prediction.id)
    "match_external_id": "2461900", // id матча в api-sport (может быть null)
    "expert_id": 83,
    "deleted_at": "2026-05-31T08:00:00.000000+00:00"
  }
}

prediction.visibility_changed

Сменилась публичная видимость прогноза. Если ui_status стал непубличным (draft, below_threshold, not_evaluated) — скройте прогноз на сайте; если публичным (ready_to_publish, published, won, lost, verified) — покажите.

{
  "event": "prediction.visibility_changed",
  "timestamp": "2026-05-31T08:00:00.000000+00:00",
  "data": {
    "id": 19090,
    "match_external_id": "2461900",
    "expert_id": 83,
    "ui_status": "below_threshold", // см. список публичных статусов выше
    "status": "draft",              // сырой статус в БД
    "updated_at": "2026-05-31T08:00:00.000000+00:00"
  }
}

prediction.result_calculated

Рассчитан итог прогноза: выигран или проиграл. Шлётся и при автоматической сверке (после завершения матча), и при ручной правке статуса в админке. Обновите статус прогноза у себя по полю result. По прогнозам без однозначного исхода (возврат / нет результата) событие не отправляется.

{
  "event": "prediction.result_calculated",
  "timestamp": "2026-06-08T14:23:45.123456+00:00",
  "data": {
    "id": 23571,
    "match_external_id": "16236853", // id матча в api-sport (может быть null)
    "expert_id": 21,
    "status": "won",                 // won | lost
    "ui_status": "won",
    "result": "won",                 // основной флаг результата: won | lost
    "final_score": "2-1",            // итоговый счёт матча
    "created_at": "2026-05-27T00:43:11.694000+00:00",
    "updated_at": "2026-06-08T14:23:45.000000+00:00"
  }
}
Сопоставляйте по паре (id, result) — повторная доставка одного и того же результата безопасна (обрабатывайте как no-op).
match_external_id может быть null, если у матча нет id в api-sport (например, ручной матч). Тогда сопоставляйте по нашему id прогноза.

5. Алгоритм проверки подписи

  1. Получить заголовок X-Webhook-Signature.
  2. Вычислить HMAC-SHA256(тело_запроса_как_байты, секрет) в hex-формате.
  3. Сравнить с заголовком через constant-time сравнение (чтобы не было утечки через timing-атаку).
  4. Если совпало — запрос пришёл от Sports Platform; если нет — отклонить (HTTP 403).
Подпись считается от сырого тела запроса (raw bytes до парсинга в JSON). Если фреймворк автоматически парсит JSON, нужно получить именно сырое тело — иначе пересериализация изменит байты и подпись не сойдётся.

Примеры на трёх языках

import hmac
import hashlib
from fastapi import FastAPI, Header, Request, HTTPException

WEBHOOK_SECRET = b"ваш-секрет-минимум-16-символов"
app = FastAPI()

@app.post("/webhook")
async def receive(request: Request, x_webhook_signature: str = Header(None)):
    body = await request.body()  # сырые байты!
    expected = hmac.new(WEBHOOK_SECRET, body, hashlib.sha256).hexdigest()
    if not x_webhook_signature or not hmac.compare_digest(expected, x_webhook_signature):
        raise HTTPException(403, "Invalid signature")
    payload = await request.json()
    # обработка payload["event"], payload["data"] ...
    return {"ok": True}
<?php
$secret = 'ваш-секрет-минимум-16-символов';
$body = file_get_contents('php://input');  // сырое тело
$received = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $body, $secret);

if (!hash_equals($expected, $received)) {
    http_response_code(403);
    exit('Invalid signature');
}

$payload = json_decode($body, true);
// $payload['event'], $payload['data'] ...
echo json_encode(['ok' => true]);
const express = require('express');
const crypto = require('crypto');

const SECRET = 'ваш-секрет-минимум-16-символов';
const app = express();

// raw body нужен ДО json-парсера
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const received = req.headers['x-webhook-signature'] || '';
  const expected = crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');

  // constant-time сравнение
  const a = Buffer.from(received, 'hex');
  const b = Buffer.from(expected, 'hex');
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(403).send('Invalid signature');
  }

  const payload = JSON.parse(req.body.toString('utf8'));
  // payload.event, payload.data ...
  res.json({ ok: true });
});

app.listen(3000);

6. Идемпотентность и ретраи

Платформа делает до 3 попыток доставки при ошибке. Это значит, что один и тот же event может прийти повторно. Чтобы не обрабатывать дубль — храните на своей стороне ключ дедупликации (например, event + data.id или data.match_id + data.date) и игнорируйте повторы.

Любой ответ HTTP 2xx считается успешной доставкой. Возвращайте 200 OK сразу после быстрой валидации — тяжёлую обработку запускайте в фоне, иначе платформа решит, что вы упали по таймауту, и пришлёт повтор.

7. Тестирование

  1. Зарегистрируйте URL приёмника и секрет в админке: «API и выдача данных» → «Webhook» → «Добавить вебхук».
  2. Нажмите кнопку «Тест» (иконка send) на нужном вебхуке — придёт событие event=test с подписью.
  3. Сверьте на своей стороне: подпись валидна, тело JSON парсится.
  4. Если ваш приёмник пока не готов — используйте webhook.site, чтобы посмотреть сырые заголовки и тело.