op.completedОП завершёнПодтверждена новая подписка, совпавшая заявка или настоящий /start целевого бота.
EVIREVIR отправляет JSON на ваш HTTPS-адрес: результаты и отписки. Это отдельный адрес от Telegram webhook бота.
Выберите язык примера ниже. Команды — для Bash; в панели хостинга задайте те же переменные окружения.
Сохраните пример как webhook.py, установите зависимости и включите проверку адреса:
pip install fastapi uvicornEVIR_WEBHOOK_BOOTSTRAP=true uvicorn webhook:app --host 0.0.0.0 --port 3000Опубликуйте /webhooks/evir через reverse proxy (например, Caddy или Nginx) на HTTPS:443. Он должен передавать запросы на локальный порт обработчика.
В карточке бота откройте «Вебхуки», вставьте адрес, выберите события и нажмите «Проверить и подключить». Сохраните показанный секрет whsec_: повторно посмотреть его нельзя.
Замените 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-записей добавьте в свою фоновую задачу.
pip install fastapi uvicornuvicorn webhook:app --host 0.0.0.0 --port 3000import 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.
{
"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 — непрозрачные ссылки для корреляции.
id.timestamp.rawBody