К содержанию
EVIR WEBHOOKS · V1

Получайте события EVIR на свой сервер

EVIR отправляет JSON на ваш HTTPS-адрес: результаты и отписки. Это отдельный адрес от Telegram webhook бота.

Это уведомления о событиях, а не общая проверка карточек. Для кнопки «Проверить» используйте deliveries/check; выбор форматов и код — в конструкторе интеграции.

Подключение и контракт
Подключение за три шага

Выберите язык примера ниже. Команды — для Bash; в панели хостинга задайте те же переменные окружения.

  1. 1
    Запустите обработчик

    Сохраните пример ниже как webhook.py и запустите проверку адреса:

    pip install fastapi uvicorn
    EVIR_WEBHOOK_BOOTSTRAP=true uvicorn webhook:app --host 0.0.0.0 --port 3000
    Как сделать адрес доступным по HTTPS

    Опубликуйте /webhooks/evir через reverse proxy (например, Caddy или Nginx) на HTTPS:443. Он должен передавать запросы на локальный порт обработчика. Можно использовать HTTPS-адрес вашей платформы хостинга.

  2. 2
    Подключите адрес в EVIR

    В карточке бота откройте «Вебхуки», вставьте адрес, выберите события и нажмите «Проверить и подключить». Сохраните показанный секрет whsec_: повторно посмотреть его нельзя.

  3. 3
    Включите подпись и отправьте тест

    Замените whsec_... своим секретом и перезапустите обработчик:

    EVIR_WEBHOOK_BOOTSTRAP=false EVIR_WEBHOOK_SECRETS='whsec_...' uvicorn webhook:app --host 0.0.0.0 --port 3000

    Нажмите «Отправить тест». Ожидаемый результат: HTTP 204 и одна запись webhook.test в SQLite. Бизнес-обработку queued-записей добавьте в свою фоновую задачу.

Обработчик с сохранением событий · PythonОткройте файл и нажмите «Копировать»
webhook.pyFastAPI + SQLite
import asyncio
import base64
import binascii
import hashlib
import hmac
import json
import os
import re
import sqlite3
import time

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, Response

# 1. Настройки
# Берём секрет из переменной сервера — не вставляйте whsec_ прямо в код.
app = FastAPI()
bootstrap = os.getenv("EVIR_WEBHOOK_BOOTSTRAP") == "true"
database_path = os.getenv("EVIR_WEBHOOK_DB", "evir-webhooks.sqlite")
secrets = [
    value.strip()
    for value in os.getenv("EVIR_WEBHOOK_SECRETS", "").split(",")
    if value.strip().startswith("whsec_")
]

if not bootstrap and not secrets:
    raise RuntimeError("EVIR webhook signing secret is missing")


# 2. Надёжное хранение
# SQLite запоминает event.id до ответа EVIR, поэтому повторная доставка не запустит действие дважды.
def initialize_database():
    with sqlite3.connect(database_path) as database:
        database.execute("PRAGMA journal_mode = WAL")
        database.execute("PRAGMA synchronous = FULL")
        database.execute("PRAGMA busy_timeout = 5000")
        database.execute("""CREATE TABLE IF NOT EXISTS evir_webhook_events (
            id TEXT PRIMARY KEY,
            project_id TEXT NOT NULL,
            event_type TEXT NOT NULL,
            body_json TEXT NOT NULL,
            received_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
            status TEXT NOT NULL DEFAULT 'queued',
            processed_at TEXT
        )""")


def persist_once(event_id: str, project_id: str, event_type: str, raw_body: bytes):
    with sqlite3.connect(database_path) as database:
        database.execute(
            """INSERT OR IGNORE INTO evir_webhook_events
               (id, project_id, event_type, body_json) VALUES (?, ?, ?, ?)""",
            (event_id, project_id, event_type, raw_body.decode("utf-8")),
        )


initialize_database()

accepted_event_types = {
    "op.completed",
    "op.unsubscribed",
    "transition.opened",
    "impression.completed",
    "impression.served",
    "webhook.test",
}


def parse_object(raw_body: bytes):
    try:
        value = json.loads(raw_body)
    except (json.JSONDecodeError, UnicodeDecodeError):
        return None
    return value if isinstance(value, dict) else None


def event_project_id(value):
    project = value.get("project") if isinstance(value, dict) else None
    project_id = project.get("id") if isinstance(project, dict) else None
    if not isinstance(project_id, str) or re.fullmatch(r"[A-Za-z0-9_-]{1,96}", project_id) is None:
        return None
    return project_id


def verification_challenge(value):
    if not isinstance(value, dict) or set(value) != {
        "type", "challenge", "project", "createdAt"
    }:
        return None
    project = value.get("project")
    challenge = value.get("challenge")
    if value.get("type") != "webhook.endpoint.verification":
        return None
    if not isinstance(challenge, str) or re.fullmatch(r"[A-Za-z0-9_-]{32}", challenge) is None:
        return None
    if not isinstance(value.get("createdAt"), str) or len(value["createdAt"]) > 64:
        return None
    if not isinstance(project, dict) or set(project) != {"id"}:
        return None
    if event_project_id(value) is None:
        return None
    return challenge


def decode_base64url(value: str) -> bytes:
    return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))


@app.post("/webhooks/evir")
async def evir_webhook(request: Request):
    # 3. Читаем исходное тело
    # Подпись считается по этим bytes. Нельзя сначала разобрать JSON и собрать его заново.
    raw_body_buffer = bytearray()
    async for chunk in request.stream():
        if len(raw_body_buffer) + len(chunk) > 64 * 1024:
            return Response(status_code=413)
        raw_body_buffer.extend(chunk)
    raw_body = bytes(raw_body_buffer)

    # 4. Проверяем адрес
    # Временно включите bootstrap перед подключением или изменением вебхука и сразу выключите после.
    if bootstrap and len(raw_body) <= 2_048:
        challenge = verification_challenge(parse_object(raw_body))
        if challenge is not None:
            return JSONResponse({"challenge": challenge})

    if not secrets:
        return Response(status_code=503)

    event_id = request.headers.get("X-EVIR-Webhook-Id", "")
    timestamp = request.headers.get("X-EVIR-Webhook-Timestamp", "")
    signature = request.headers.get("X-EVIR-Webhook-Signature", "")
    match = re.fullmatch(r"v1=([A-Za-z0-9_-]+)", signature)
    if not event_id or not timestamp.isdigit() or match is None:
        return Response(status_code=401)

    # 5. Проверяем подпись
    # При неверной подписи или времени запрос отклоняется и событие не сохраняется.
    try:
        timestamp_seconds = int(timestamp)
        actual = decode_base64url(match.group(1))
    except (binascii.Error, ValueError, TypeError):
        return Response(status_code=401)
    if abs(int(time.time()) - timestamp_seconds) > 300:
        return Response(status_code=401)

    signed = f"{event_id}.{timestamp}.".encode() + raw_body
    valid = False
    for secret in secrets:
        expected = hmac.new(secret.encode(), signed, hashlib.sha256).digest()
        current = hmac.compare_digest(actual, expected)
        valid = current or valid
    if not valid:
        return Response(status_code=401)

    event = parse_object(raw_body)
    if event is None:
        return Response(status_code=400)
    if event.get("type") == "webhook.endpoint.verification":
        challenge = verification_challenge(event)
        return Response(status_code=400) if challenge is None else JSONResponse({"challenge": challenge})
    project_id = event_project_id(event)
    if (event.get("id") != event_id or event.get("apiVersion") != "v1"
            or project_id is None
            or event.get("type") not in accepted_event_types):
        return Response(status_code=400)

    # 6. Сохраняем один раз
    # Сначала надёжно записываем событие, только потом быстро отвечаем HTTP 204.
    try:
        await asyncio.to_thread(persist_once, event_id, project_id, event["type"], raw_body)
    except (sqlite3.Error, UnicodeDecodeError):
        return Response(status_code=503)

    # 7. Этот обработчик только принимает и сохраняет событие.
    # Фоновая задача обрабатывает строки со status = 'queued'.
    return Response(status_code=204)

Обработчик проверяет подпись, сохраняет event.id со статусом queued и отвечает 204. Фоновая задача выбирает queued-записи, выполняет ваше действие и после успеха ставит processed; при ошибке оставляет queued для повтора.

События и поляКогда приходят · полный JSON · значения полей
op.completedОП завершён

Подтверждена новая подписка, совпавшая заявка или настоящий /start целевого бота.

op.unsubscribedОтписка после ОП

Ранее подтверждённый получатель покинул канал или заблокировал managed-бота. Прошлое начисление не отменяется.

transition.openedПереход подтверждён

Пользователь авторизованно открыл назначение через EVIR. Это не подтверждение подписки.

impression.completedДействие из карточки

Подтверждена новая подписка или запуск бота из рекламной карточки. Начисление выполнено.

impression.servedПоказ по прежним условиям

Только ранее созданные показы с оплатой отправки. Новые действия приходят как impression.completed.

webhook.testТестовая доставка

Кнопка «Отправить тест» проверяет приём и подпись. Начислений не создаёт.

Выберите событие, чтобы посмотреть полный JSON.

event.jsonop.completed
{
  "id": "evt_4f3a0b7c8d9e1029384756abcdef0123",
  "sequence": "184",
  "type": "op.completed",
  "apiVersion": "v1",
  "createdAt": "2026-08-20T16:45:12.351Z",
  "project": { "id": "project_demo" },
  "data": {
    "deliveryId": "dlv_...",
    "product": "op",
    "transport": "managed",
    "recipientRef": "rcp_...",
    "occurredAt": "2026-08-20T16:45:11.000Z",
    "proof": { "type": "membership", "occurredAt": "2026-08-20T16:45:11.000Z" },
    "settlement": {
      "publisherAmountMinor": 150,
      "currency": "RUB",
      "settledAt": "2026-08-20T16:45:12.351Z"
    }
  }
}

Общие поля

id
Строка. Ключ дедупликации события.
sequence
Десятичная строка, возрастающая внутри project.id. В JavaScript сравнивайте через BigInt.
type / apiVersion
Тип события из списка выше; версия всегда v1.
createdAt / project
Дата ISO 8601 UTC и объект с единственным полем id.
data
Данные события. Для webhook.test — пустой объект {}.

Поля data

deliveryId
Ссылка dlv_ связывает события одной выдачи. Это не deliveryId из next: не передавайте её в served, qualify или check.
recipientRef
Постоянная ссылка rcp_ на получателя внутри проекта. Это не Telegram ID; отправить сообщение по ней нельзя.
product / transport
Формат op, transition или impression; способ доставки managed или sdk.
occurredAt
Время результата или отписки, ISO 8601 UTC.
settlement
У трёх событий результата: publisherAmountMinor — целое число минимальных единиц currency; settledAt — дата расчёта ISO 8601 UTC.
proof
Только op.completed: type — membership, join_request или bot_start; occurredAt — время подтверждения.
reason
Только op.unsubscribed: channel_left или bot_blocked. Событие не отменяет прошлую выплату.
Подпись и проверка адресаТри заголовка · raw body · challenge

EVIR отправляет POST с Content-Type: application/json и тремя заголовками:

  • X-EVIR-Webhook-Id — ID события; совпадает с id в JSON для рабочих и тестовых событий. У проверки адреса отдельный ID verify_ и нет body.id.
  • X-EVIR-Webhook-Timestamp — время отправки попытки, целое число секунд Unix, не миллисекунды.
  • X-EVIR-Webhook-Signature — v1=…; HMAC-SHA256 от id.timestamp.rawBody, результат в base64url без padding. Ключ — вся строка whsec_, включая префикс.

Проверка входящего запроса

  • Проверяйте подпись по исходному телу запроса до разбора JSON. Допустимое расхождение времени — не больше 5 минут.
  • Сравнивайте HMAC за постоянное время. Для рабочих и тестовых событий затем проверьте project.id, совпадение body.id с заголовком и apiVersion: v1. Для проверки адреса — точную форму challenge ниже.
  • Используйте project.id для маршрутизации только после проверки подписи. Для разных подключений нужны отдельные адреса и секреты.

Подтверждение адреса

При подключении и сохранении новых настроек приходит отдельный запрос. Верните 2xx и только JSON с тем же challenge:

{
  "type": "webhook.endpoint.verification",
  "challenge": "uMmG3QNOF5_1z48gRfBm8HdB7wVqKpYx",
  "project": { "id": "project_demo" },
  "createdAt": "2026-08-23T12:00:00.000Z"
}

Ответ HTTP 200:

{ "challenge": "uMmG3QNOF5_1z48gRfBm8HdB7wVqKpYx" }

Новый секрет до сохранения ещё неизвестен. В режиме bootstrap пример принимает только строго проверенную форму challenge и не выполняет действий. Проверка уже сохранённого адреса использует текущий секрет.

Доставка и смена секретаПовторы · очередь · новые настройки

Приём события

  1. Надёжно сохраните event.id с уникальным индексом. Повтор события должен подтвердиться без повторного действия.
  2. Ответьте 2xx за 5 секунд. При ошибке записи верните non-2xx; долгую обработку выполняйте в фоновой очереди.
  3. Для внешнего действия используйте event.id как ключ идемпотентности; в своей БД выполняйте действие и отметку processed одной транзакцией.
Повторы
Рабочие события: at least once, до 9 повторов примерно за 53–72 часа после сетевой ошибки или non-2xx. webhook.test — одна попытка по кнопке.
Порядок
Не гарантирован. sequence сравнивается как целое только внутри project.id и не заменяет дедупликацию по id.
Хранение
SQLite — на постоянном диске. Несколько экземпляров обработчика должны использовать общую БД с уникальным event.id.

Изменение адреса или списка событий

  1. Временно включите EVIR_WEBHOOK_BOOTSTRAP=true: новые настройки проверяются с новым секретом.
  2. Сохраните настройки в EVIR и замените EVIR_WEBHOOK_SECRETS выданным whsec_. При переключении можно временно перечислить старый и новый секреты через запятую.
  3. Сразу выключите bootstrap и отправьте тест. Секрет храните только на сервере, не в клиентском коде или логах.
Если подключение не работаетАдрес · ответ сервера · подпись
INVALID_WEBHOOK_URL / UNSAFE_WEBHOOK_URL
Нужен HTTPS на порту 443 с публичным DNS-именем. Уберите query, fragment и логин/пароль; IP-адреса и redirects не поддерживаются.
WEBHOOK_VERIFICATION_UNAVAILABLE
EVIR не получил ответ. Проверьте DNS, TLS, доступность HTTPS и время ответа до 5 секунд.
WEBHOOK_VERIFICATION_FAILED
Верните 2xx и только {"challenge":"полученное значение"}. Перед новым подключением включите bootstrap.
WEBHOOK_TEST_FAILED
Проверьте статус ответа обработчика. При 401: секрет целиком с whsec_, точные bytes тела, timestamp в секундах и часы сервера. При 503: доступность БД.