TrofeyAyay → Webhooks
Ayay

Webhooks: finds delivered to your server

The one part of this documentation that is about the paid product and your own account, rather than about the open data.

← All about the open data

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-Id
the event id, the same value as event_id in the body
X-Trofey-Timestamp
the moment we sent it, in unix seconds
X-Trofey-Delivery-Attempt
the attempt number, starting at 1
X-Trofey-Signature
sha256= and hex: HMAC-SHA256 over the timestamp, a dot and the body

The 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.

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.