EVIR
EVIR WEBHOOKS · V1

Получайте результаты бота на свой сервер

После подтверждённой подписки, перехода или показа EVIR отправит уведомление вашему серверу. Вебхук не отвечает пользователям и не забирает управление ботом.

Для разработчикаКод, подпись, события и требования к серверу
Технический быстрый стартПервая настройка и безопасная ротация
ID этого ботаBOT_ID
  1. 1

    Запустите endpointЗадайте EVIR_WEBHOOK_PROJECT_ID и путь к SQLite. Перед каждым PUT временно включайте EVIR_WEBHOOK_BOOTSTRAP=true.

  2. 2

    Добавьте HTTPS URLВыберите события и нажмите «Проверить и подключить» или «Проверить и сохранить».

  3. 3

    Сохраните whsec_Добавьте однократно показанный secret в EVIR_WEBHOOK_SECRETS; при ротации временно оставьте старый.

  4. 4

    Закройте bootstrapУстановите false, перезапустите endpoint и нажмите «Проверить снова».

Готовый обработчикПроверяет HMAC, привязывает проект и сохраняет событие один раз
webhook.mjsExpress 5 + SQLite
$npm i express better-sqlite3
import { createHmac, timingSafeEqual } from 'node:crypto'
import Database from 'better-sqlite3'
import express from 'express'

const app = express()
const bootstrap = process.env.EVIR_WEBHOOK_BOOTSTRAP === 'true'
const projectId = process.env.EVIR_WEBHOOK_PROJECT_ID ?? ''
const secrets = (process.env.EVIR_WEBHOOK_SECRETS ?? '')
  .split(',')
  .map((value) => value.trim())
  .filter((value) => value.startsWith('whsec_'))

if (!projectId) throw new Error('EVIR_WEBHOOK_PROJECT_ID is missing')
if (!bootstrap && secrets.length === 0) throw new Error('EVIR webhook signing secret is missing')

const database = new Database(process.env.EVIR_WEBHOOK_DB ?? 'evir-webhooks.sqlite')
database.pragma('journal_mode = WAL')
database.pragma('synchronous = FULL')
database.exec(`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,
  processed_at TEXT
)`)
const persistOnce = database.prepare(`
  INSERT OR IGNORE INTO evir_webhook_events (id, project_id, event_type, body_json)
  VALUES (?, ?, ?, ?)
`)

function parseObject(rawBody) {
  try {
    const value = JSON.parse(rawBody.toString('utf8'))
    return value && typeof value === 'object' && !Array.isArray(value) ? value : null
  } catch {
    return null
  }
}

function hasOnlyKeys(value, expected) {
  const actual = Object.keys(value).sort()
  const wanted = [...expected].sort()
  return actual.length === wanted.length
    && actual.every((key, index) => key === wanted[index])
}

function verificationChallenge(value) {
  if (!value || !hasOnlyKeys(value, ['type', 'challenge', 'project', 'createdAt'])
    || value.type !== 'webhook.endpoint.verification'
    || typeof value.challenge !== 'string'
    || !/^[A-Za-z0-9_-]{32}$/.test(value.challenge)
    || typeof value.createdAt !== 'string' || value.createdAt.length > 64
    || Number.isNaN(Date.parse(value.createdAt))
    || !value.project || typeof value.project !== 'object' || Array.isArray(value.project)
    || !hasOnlyKeys(value.project, ['id'])
    || value.project.id !== projectId) return null
  return value.challenge
}

app.post('/webhooks/evir', express.raw({ type: 'application/json', limit: '64kb' }), (req, res) => {
  const rawBody = req.body
  if (!Buffer.isBuffer(rawBody)) return res.sendStatus(400)

  // Enable before every PUT/reconfiguration, then disable immediately.
  if (bootstrap && rawBody.length <= 2_048) {
    const challenge = verificationChallenge(parseObject(rawBody))
    if (challenge !== null) return res.status(200).json({ challenge })
  }

  if (secrets.length === 0) return res.sendStatus(503)
  const id = req.get('X-EVIR-Webhook-Id') ?? ''
  const timestamp = req.get('X-EVIR-Webhook-Timestamp') ?? ''
  const match = /^v1=([A-Za-z0-9_-]+)$/.exec(
    req.get('X-EVIR-Webhook-Signature') ?? '',
  )
  const timestampSeconds = Number(timestamp)
  if (!id || !/^d+$/.test(timestamp) || !match
    || !Number.isSafeInteger(timestampSeconds)
    || Math.abs(Math.floor(Date.now() / 1000) - timestampSeconds) > 300) {
    return res.sendStatus(401)
  }

  const actual = Buffer.from(match[1], 'base64url')
  const signedPrefix = id + '.' + timestamp + '.'
  const valid = secrets.reduce((matched, secret) => {
    const expected = createHmac('sha256', secret)
      .update(signedPrefix, 'utf8').update(rawBody).digest()
    const current = actual.length === expected.length && timingSafeEqual(actual, expected)
    return matched || current
  }, false)
  if (!valid) return res.sendStatus(401)

  const event = parseObject(rawBody)
  if (!event) return res.sendStatus(400)
  if (event.type === 'webhook.endpoint.verification') {
    const challenge = verificationChallenge(event)
    return challenge === null ? res.sendStatus(400) : res.status(200).json({ challenge })
  }
  if (event.id !== id || event.project?.id !== projectId || typeof event.type !== 'string') {
    return res.sendStatus(400)
  }

  // Durable INSERT OR IGNORE records and deduplicates before the 2xx ACK.
  try {
    persistOnce.run(event.id, projectId, event.type, rawBody.toString('utf8'))
  } catch {
    return res.sendStatus(503)
  }
  return res.sendStatus(204)
})

app.listen(process.env.PORT ?? 3000)

EVIR_WEBHOOK_PROJECT_ID должен точно совпадать с ботом. EVIR_WEBHOOK_SECRETS может временно содержать несколько whsec_ при ротации.

События и JSONЧетыре типа в едином envelope v1
op.completedОП завершён

Подтверждена подписка, заявка или запуск целевого бота.

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

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

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

Пользователь авторизованно открыл ссылку через EVIR.

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

Спонсорская карточка была подтверждённо отправлена.

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

Проверяет весь путь доставки тем же подписанным envelope и пустым data.

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

deliveryId и recipientRef — непрозрачные ссылки проекта, не Telegram ID. Поле proof есть только у op.completed. op.unsubscribed присылает reason channel_left или bot_blocked и не отменяет прошлую выплату.

Доставка и безопасностьЧто важно учесть в продакшене
  • Проверяйте подпись по исходным bytes до JSON parsing и допускайте расхождение времени не больше 5 минут.
  • EVIR доставляет как минимум один раз: сохраняйте event.id и не выполняйте повторное действие.
  • Сначала надёжно запишите событие, быстро верните 2xx, а тяжёлую работу перенесите в очередь.
  • Один route и набор secret — для одного bot project. Не смешивайте ключи разных ботов в одном receiver.
  • Не храните whsec_ в клиентском коде, Telegram-боте, browser storage или логах.
Строка подписиid.timestamp.rawBody
Что сделать владельцу ботаПорядок подключения и безопасная передача секрета
  1. 1

    Отправьте эту инструкцию разработчикуНажмите «Поделиться» выше. В ссылке есть только ID бота — секретов и адреса вашего сервера в ней нет.

  2. 2

    Получите HTTPS-адресРазработчик подготовит обработчик и пришлёт адрес вида https://example.com/webhooks/evir.

  3. 3

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

  4. 4

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

  5. 5

    Проверьте ещё разКогда разработчик сохранит секрет на сервере, нажмите «Проверить снова». Статус «Вебхук работает» означает, что всё готово.

Что отправить разработчикуСсылку на эту документацию и отдельно — секрет whsec_. Ссылка никогда не содержит secret, endpoint, токен Telegram или ключ EVIR.

Основные терминыЧетыре термина, которые встретятся при подключении
endpoint

HTTPS-адрес на сервере, куда EVIR отправляет уведомления.

event

Уведомление о подтверждённом результате: подписке, переходе или показе.

secret

Пароль вебхука. Он доказывает серверу, что сообщение действительно пришло от EVIR.

bootstrap

Временный режим только для первой проверки адреса. После подключения разработчик его выключает.