Когда нужны не числа, а находки
Всё выше — открытое и безличное: цены, которые и так стоят на страницах, без ключа и без аккаунта. Вебхук — другая вещь: персональная и платная. Это тот же алерт, который аккаунт уже получает в Telegram, отправленный POST-ом на его собственный адрес. Те же находки, тот же аккаунт, те же оплаченные позиции — меняется только то, куда прилетает.
Отсюда главное ограничение, и оно намеренное: вебхук не даёт доступа ни к чему новому. Нет живой позиции — нет алертов вообще, значит вебхуку нечего отправлять. Бесплатного обхода платной доставки здесь нет.
Включается в кабинете: адрес (только https), канал доставки — Telegram, вебхук или оба, и секрет, который можно показать ещё раз или сменить. Сохранение адреса — ещё не включение: мы шлём на него событие ping, и доставка включается только после ответа 2xx. Иначе первым тестом интеграции стала бы первая настоящая находка — а она живёт минуты.
Что прилетает
POST, Content-Type: application/json, тело — UTF-8 JSON с отсортированными ключами и без пробелов. Это те же факты, что на карточке, и не больше: контактов продавца тут нет — мы их не храним. Событие ping приезжает на тот же адрес и без listing, так что различай события по полю event.
{ "event": "alert", "event_id": "148217", "item": { "battery_pct": 92, "bucket_key": "iphone-13|128|used|clean", "category": "phones", "condition": "used", "lock_status": "clean", "model_key": "iphone-13", "ram_gb": null, "storage_gb": 128 }, "listing": { "city": "Київ", "currency": "UAH", "photo_url": "https://.../image.jpg", "posted_at": "2026-09-18T07:06:00Z", "price_uah": 10000, "title": "iPhone 13 128GB", "url": "https://www.olx.ua/d/uk/obyavlenie/..." }, "market": { "delta_pct": 23.1, "delta_uah": 3000, "median_uah": 13000 }, "reason": "new", "sent_at": "2026-09-18T07:11:04.812345Z", "silent": false}
bucket_key есть, но разбирать его строкой не надо: все его измерения лежат отдельными полями в item. Время — ISO-8601 в UTC, и у секунд может быть дробная часть, так что разбирай его ISO-парсером, а не своим шаблоном на секунды.
Заголовки
POST /your/endpoint HTTP/1.1Content-Type: application/jsonUser-Agent: Trofey-Webhook/1X-Trofey-Event-Id: 148217X-Trofey-Timestamp: 1758179464X-Trofey-Delivery-Attempt: 1X-Trofey-Signature: sha256=8f1c...e2
X-Trofey-Event-Idevent_id в телеX-Trofey-TimestampX-Trofey-Delivery-Attempt1X-Trofey-Signaturesha256= и hex: HMAC-SHA256 над строкой из времени, точки и телаПодпись
Подписываются именно те байты, что приехали. Возьми сырое тело до любого парсинга, составь строку {timestamp}.{body}, посчитай HMAC-SHA256 своим секретом и сравни с X-Trofey-Signature в постоянном времени. И отбрось запрос, если время далеко от «сейчас» (разумно — 5 минут): время входит в подписанную строку именно для этого, ведь без проверки времени подсмотренную доставку можно повторять вечно.
Готовый приёмник — examples/webhooks/: Python, Node и PHP, без единой зависимости. Вот часть, которая принимает решение; мы гоняем именно её против настоящих подписанных доставок:
import hashlib, hmac, timeCLOCK_SKEW_SEC = 300def verify(body: bytes, timestamp: str, signature: str, secret: str) -> str | None: """None when the delivery is genuine and fresh; otherwise why it is not. `body` must be the bytes as they ARRIVED: json.loads() then json.dumps() produces different bytes and a signature that can never match.""" if not timestamp or not signature: return "missing signature headers" try: sent_at = int(timestamp) except ValueError: return "unreadable timestamp" if abs(time.time() - sent_at) > CLOCK_SKEW_SEC: return "stale timestamp" mine = hmac.new(secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256) if not hmac.compare_digest(f"sha256={mine.hexdigest()}", signature): return "bad signature" return None
Секрет выводится, а не хранится: в базе лежит только номер версии, поэтому показать его второй раз можно всегда, а ротация — это +1 к версии, после которой старые подписи сразу перестают быть действительными.
Доставка
Доставка — как минимум раз, а не ровно раз. Процесс, умерший между POST-ом и отметкой в очереди, повторит это одно событие, поэтому event_id стабилен и уникален — дедуплицируй по нему. Бери его из тела, а не из заголовка: подписаны только тело и время.
- Отвечай 2xx раньше, чем работать: мы ждём до 10 секунд и читаем не больше 64 КБ тела. Вызов чужой системы внутри запроса — это путь «медленно → таймаут → повтор → выключенный канал».
- Неудача (не-2xx, таймаут, сеть) — лестница повторов 30, 60, 120, 240 секунд; после пятой попытки строка становится dead-letter.
- Три dead-letter за сутки — и канал выключается: находки возвращаются в Telegram, и туда же приходит сообщение, почему. Молчащий вебхук читался бы как «находок не было».
- Чтобы включить обратно — почини адрес и отправь
pingещё раз. Успешныйpingснимает блокировку. - Тишина — не всегда поломка: день, за который мы не берём денег, не порождает событий ни в одном канале.
Вопросы и ответы
Что будет, если мой сервер не ответит?
Повторим четыре раза — через 30, 60, 120 и 240 секунд. Если не выйдет и тогда, письмо становится мёртвым. После трёх мёртвых доставку на адрес выключаем и пишем об этом в Telegram — находки идут туда, ничего не теряется.
Как убедиться, что запрос действительно от вас?
Каждая доставка подписана: заголовок несёт HMAC-SHA256 от «временная метка.тело» на твоём секрете. Проверяй подпись до того, как читать тело — готовый код есть на этой странице.
Может ли одна находка прийти дважды?
Да. Доставка «как минимум один раз», так что повтор возможен после сбоя сети. Заголовок с идентификатором события — ключ, по которому такой повтор отсеивается на твоей стороне.