Коли потрібні не числа, а знахідки
Усе вище — відкрите й безособове: ціни, які й так стоять на сторінках, без ключа й без акаунта. Вебхук — інша річ: персональна й платна. Це той самий алерт, який акаунт уже отримує в 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 від «часова мітка.тіло» на твоєму секреті. Перевіряй підпис до того, як читати тіло — готовий код є на цій сторінці.
Чи може та сама знахідка прийти двічі?
Так. Доставка «щонайменше один раз», тож повтор можливий після збою мережі. Заголовок із ідентифікатором події — ключ, за яким такий повтор відсіюється на твоєму боці.