TrofeyАяй → Вебхуки
Аяй

Вебхуки: знахідки у твій сервер

Єдина частина цієї документації, яка про платний продукт і про твій акаунт, а не про відкриті дані.

← Усе про відкриті дані

Коли потрібні не числа, а знахідки

Усе вище — відкрите й безособове: ціни, які й так стоять на сторінках, без ключа й без акаунта. Вебхук — інша річ: персональна й платна. Це той самий алерт, який акаунт уже отримує в 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-Id
ідентифікатор події, він же event_id у тілі
X-Trofey-Timestamp
час відправлення, unix-секунди
X-Trofey-Delivery-Attempt
номер спроби, від 1
X-Trofey-Signature
sha256= і 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 стабільний і унікальний — дедуплікуй по ньому. Бери його з тіла, а не із заголовка: підписані лише тіло і час.

Питання і відповіді

Що буде, якщо мій сервер не відповість?

Повторимо чотири рази — через 30, 60, 120 і 240 секунд. Якщо не вийде й тоді, лист стає мертвим. Після трьох мертвих доставку на адресу вимикаємо й пишемо про це в Telegram — знахідки йдуть туди, нічого не губиться.

Як переконатись, що запит справді від вас?

Кожна доставка підписана: заголовок несе HMAC-SHA256 від «часова мітка.тіло» на твоєму секреті. Перевіряй підпис до того, як читати тіло — готовий код є на цій сторінці.

Чи може та сама знахідка прийти двічі?

Так. Доставка «щонайменше один раз», тож повтор можливий після збою мережі. Заголовок із ідентифікатором події — ключ, за яким такий повтор відсіюється на твоєму боці.