EVIR
EVIR WEBHOOKS · V1

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

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

Подключение и контрактНастройка → приём → обработка
Подключение за три шагаАдрес, секрет подписи и тест

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

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

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

    pip install fastapi uvicorn
    EVIR_WEBHOOK_BOOTSTRAP=true uvicorn webhook:app --host 0.0.0.0 --port 3000

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

  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
Установить$pip install fastapi uvicorn
Запустить$uvicorn webhook:app --host 0.0.0.0 --port 3000
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.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 для повтора.

Во внешних запросах фоновой задачи используйте event.id как ключ идемпотентности: после сбоя действие может повториться до записи processed. Для изменений в своей БД сохраняйте результат и processed одной транзакцией.

EVIR_WEBHOOK_SECRETS хранит показанный один раз whsec_; при замене секрета здесь временно можно оставить оба значения. ID проекта обработчик берёт из уже проверенного подписанного события.

Что будет приходитьЧетыре выбираемых события и одно служебное тестовое
op.completedОП завершён

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

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

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

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

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

impression.servedПоказ подтверждён

EVIR получил подтверждение успешной отправки карточки через Telegram. Это не подтверждение прочтения.

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 — десятичная строка внутри проекта; type; apiVersion со значением v1; createdAt в ISO 8601 UTC; project с единственным полем id. Результаты и отписка также содержат в data поля deliveryId, product, transport, recipientRef и occurredAt. webhook.test содержит data: {}.

settlement есть у op.completed, transition.opened и impression.served: publisherAmountMinor — целое число минимальных единиц валюты, currency — строка, settledAt — ISO 8601 UTC. proof есть только у op.completed: membership, join_request или bot_start. reason есть только у op.unsubscribed: channel_left или bot_blocked. deliveryId и recipientRef — непрозрачные ссылки для корреляции.

Безопасность и доставкаПодпись, повторы и требования к адресу
  • Проверяйте подпись по исходному телу запроса до разбора JSON. Допустимое расхождение времени — не больше 5 минут.
  • EVIR может доставить событие повторно: сохраняйте event.id и не выполняйте одно действие дважды.
  • EVIR ждёт ответ до 5 секунд. Сначала надёжно запишите событие, быстро верните HTTP 2xx, а долгую обработку перенесите в очередь.
  • При сетевой ошибке или non-2xx EVIR делает до 9 повторов примерно за 53–72 часа для рабочих событий. Тест отправляется одной попыткой. Порядок доставки не гарантирован; сравнивайте sequence как целое число внутри одного project.id.
  • Перед изменением адреса или событий временно включите bootstrap: проверка новых настроек подписана новым секретом. После сохранения замените whsec_ и снова выключите bootstrap.
  • Адрес обработчика должен работать по HTTPS на порту 443, иметь публичное DNS-имя и не содержать query, fragment или HTTP-перенаправление.
  • Для нескольких подключений используйте отдельный адрес и секрет подписи для каждого. Поле project.id можно использовать для маршрутизации только после успешной проверки подписи.
  • Не встраивайте whsec_ в клиентский или публичный код и не пишите его в логи. Храните секрет только в переменных окружения сервера или менеджере секретов — в том числе для backend Telegram-бота.
HMAC-SHA256 · base64url · без paddingid.timestamp.rawBody