op.completedОП завершёнПодтверждена подписка, заявка или запуск целевого бота.
После подтверждённой подписки, перехода или показа EVIR отправит уведомление вашему серверу. Вебхук не отвечает пользователям и не забирает управление ботом.
BOT_IDЗапустите endpointЗадайте EVIR_WEBHOOK_PROJECT_ID и путь к SQLite. Перед каждым PUT временно включайте EVIR_WEBHOOK_BOOTSTRAP=true.
Добавьте HTTPS URLВыберите события и нажмите «Проверить и подключить» или «Проверить и сохранить».
Сохраните whsec_Добавьте однократно показанный secret в EVIR_WEBHOOK_SECRETS; при ротации временно оставьте старый.
Закройте bootstrapУстановите false, перезапустите endpoint и нажмите «Проверить снова».
npm i express better-sqlite3import { 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_ при ротации.
op.completedОП завершёнПодтверждена подписка, заявка или запуск целевого бота.
op.unsubscribedОтписка после ОПРанее подтверждённый получатель покинул канал или заблокировал managed-бота.
transition.openedПереход подтверждёнПользователь авторизованно открыл ссылку через EVIR.
impression.servedПоказ подтверждёнСпонсорская карточка была подтверждённо отправлена.
webhook.testТестовая доставкаПроверяет весь путь доставки тем же подписанным envelope и пустым data.
{
"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 и не отменяет прошлую выплату.
id.timestamp.rawBodyОтправьте эту инструкцию разработчикуНажмите «Поделиться» выше. В ссылке есть только ID бота — секретов и адреса вашего сервера в ней нет.
Получите HTTPS-адресРазработчик подготовит обработчик и пришлёт адрес вида https://example.com/webhooks/evir.
Вставьте адрес в EVIRОткройте «Вебхуки», вставьте адрес, отметьте нужные результаты и нажмите «Проверить и подключить».
Скопируйте секрет один разПередайте строку whsec_ разработчику отдельно, через безопасный канал. Не отправляйте её вместе с публичной ссылкой.
Проверьте ещё разКогда разработчик сохранит секрет на сервере, нажмите «Проверить снова». Статус «Вебхук работает» означает, что всё готово.
Что отправить разработчикуСсылку на эту документацию и отдельно — секрет whsec_. Ссылка никогда не содержит secret, endpoint, токен Telegram или ключ EVIR.
endpointHTTPS-адрес на сервере, куда EVIR отправляет уведомления.
eventУведомление о подтверждённом результате: подписке, переходе или показе.
secretПароль вебхука. Он доказывает серверу, что сообщение действительно пришло от EVIR.
bootstrapВременный режим только для первой проверки адреса. После подключения разработчик его выключает.