When you want the finds, not the numbers
Everything above is open and anonymous: prices that already sit on public pages, no key and no account. A webhook is a different thing: personal and paid. It is the same alert the account already receives in Telegram, POSTed to an address it owns. Same finds, same account, same paid positions — only the destination changes.
Hence the main limit, and it is deliberate: a webhook unlocks nothing new. No live position means no alerts at all, so there is nothing for a webhook to carry. There is no free way around paid delivery here.
You turn it on in the cabinet: the address (https only), the delivery channel — Telegram, webhook or both — and a secret you can reveal again or rotate. Saving the address is not enabling it: we send a ping there first, and delivery starts only after a 2xx. Otherwise the first real find would be the first test of the integration — and a real find lives minutes.
What arrives
POST, Content-Type: application/json, body is UTF-8 JSON with sorted keys and no spaces. These are the same facts the card carries and no more: there are no seller contacts here — we do not store them. A ping event arrives at the same address with no listing, so tell events apart by the event field.
{ "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}
There is a bucket_key, but do not take it apart as a string: every axis inside it is already a separate field in item. Times are ISO-8601 in UTC and the seconds may carry a fraction, so parse them with an ISO parser rather than your own seconds pattern.
Headers
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 in the bodyX-Trofey-TimestampX-Trofey-Delivery-Attempt1X-Trofey-Signaturesha256= and hex: HMAC-SHA256 over the timestamp, a dot and the bodyThe signature
What is signed is exactly the bytes that arrived. Take the raw body before any parsing, build the string {timestamp}.{body}, compute HMAC-SHA256 with your secret and compare it to X-Trofey-Signature in constant time. And reject the request if the timestamp is far from now (five minutes is sensible): the timestamp is inside the signed string for exactly that reason — without a time check, a delivery someone recorded can be replayed forever.
A ready receiver lives in examples/webhooks/: Python, Node and PHP, with no dependencies at all. This is the part that decides, and it is the part we run against real signed deliveries:
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
The secret is derived, not stored: only a version number sits in the database. So we can always show it to you again, and rotating it is an increment after which old signatures stop being valid at once.
Delivery
Delivery is at least once, not exactly once. A process that dies between the POST and its own bookkeeping repeats that one event, so event_id is stable and unique — dedup on it. Take it from the body, not from the header: only the body and the timestamp are signed.
- Answer 2xx before you do the work: we wait up to 10 seconds and read at most 64 KB of the body. Calling another system inside the request is the road from «slow» to «timeout» to «retry» to «channel switched off».
- A failure (non-2xx, timeout, network) goes onto the retry ladder — 30, 60, 120, 240 seconds; after the fifth attempt the row is dead-lettered.
- Three dead letters in a day and the channel is switched off: finds go back to Telegram and a message explaining why goes there too. A silent webhook would read as «there were no finds».
- To switch it back on, fix the address and send a
pingagain. A successfulpinglifts the block. - Silence is not always a fault: a day we do not charge for produces no events in any channel.
Questions and answers
What happens if my server does not answer?
We retry four times — after 30, 60, 120 and 240 seconds. If that fails too, the delivery is dead. After three dead ones we switch the address off and say so in Telegram, where the finds keep arriving. Nothing is lost.
How do I know a request is really from you?
Every delivery is signed: the header carries an HMAC-SHA256 over «timestamp.body» with your secret. Verify it before you read the body — runnable code is on this page.
Can the same find arrive twice?
Yes. Delivery is at-least-once, so a network failure can produce a repeat. The event-id header is the key that lets you drop one on your side.