Webhooks
HMAC-signed event deliveries to your HTTPS endpoint — Stripe-style signatures, exponential retries.
Event catalog status
market.resolved and signal.smart_money are live, alongside the
synthetic polyrank.test event. trader.fill is still coming soon (it
is high-volume; use the realtime WebSocket for per-fill
streaming today). Watch the changelog.
Manage subscriptions
Session-cookie auth; webhook quota is plan-gated (Free = 0 events →
403 plan_required, see Billing).
| Endpoint | What it does |
|---|---|
GET /v1/webhooks | List subscriptions |
POST /v1/webhooks | Create — https:// URLs only (SSRF-guarded) |
PUT /v1/webhooks/{id} | Update URL / event types / active flag |
DELETE /v1/webhooks/{id} | Remove |
POST /v1/webhooks/{id}/test | Send a synthetic polyrank.test event now |
Creating a subscription returns the signing secret (whsec_…) once —
store it like a password.
Verify signatures
Every delivery is signed Stripe-style:
X-Polyrank-Signature: t=1765391234,v1=hex(hmac_sha256(secret, "{t}.{raw_body}"))
X-Polyrank-Event-Id: 01JXYZ…
X-Polyrank-Event-Type: polyrank.test
User-Agent: Polyrank-Webhook/1.0import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(header: string, rawBody: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')));
const expected = createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
// Reject stale timestamps (replay defense)
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}import hashlib, hmac, time
def verify(header: str, raw_body: bytes, secret: str) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(","))
if abs(time.time() - int(parts["t"])) > 300:
return False
expected = hmac.new(
secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, parts["v1"])Always verify over the raw request body, before any JSON parsing.
Delivery & retries
- 10-second POST timeout; any 2xx counts as delivered.
- Failures retry with backoff: 5s → 30s → 5m → 1h → 6h → 24h (6 attempts max).
- Deduplicate by
X-Polyrank-Event-Id— retries reuse the id. - Deliveries count against your plan's monthly webhook-event quota.
Event types
| Type | Status | Payload |
|---|---|---|
polyrank.test | live | Synthetic event from the /test endpoint |
market.resolved | live | A Polymarket market resolved on-chain |
signal.smart_money | live | A smart-money-tagged wallet made a large trade |
trader.fill | live | A followed wallet's fill |
Every delivery body is the same envelope:
{ "type": "<event type>", "emitted_at": "<ISO-8601>", "data": { … } }market.resolved
Emitted within ~2 minutes of the on-chain ConditionResolution event, for
conditions that are Polymarket exchange markets.
{
"type": "market.resolved",
"emitted_at": "2026-06-11T18:04:12.512Z",
"data": {
"condition_id": "0x5f65…",
"question_id": "0x9c01…",
"title": "Will X happen by June 30?",
"slug": "will-x-happen-by-june-30",
"category": "Politics",
"market_kind": "binary",
"winner_outcome": "YES",
"winner_index": 0,
"outcome_slot_count": 2,
"resolved_time": "2026-06-11 18:02:55.000",
"tx_hash": "0xab12…"
}
}winner_outcome is YES / NO for binary markets, may be INVALID, and is
null with winner_index = -1 on ties/multi-outcome payouts. title and
slug can briefly be empty for very young markets whose metadata hasn't
synced yet.
trader.fill
Emitted in real time for every taker fill, filtered to your subscription.
{
"type": "trader.fill",
"emitted_at": "2026-07-22T09:14:02.104Z",
"data": {
"trader_proxy": "0x1234…",
"condition_id": "0x5f65…",
"size_usdc": 250.5,
"price": 0.72,
"token_id": "7134…",
"taker_side": "buy",
"exchange_version": "v2",
"exchange_kind": "binary",
"tx_hash": "0xab12…",
"log_index": "41"
}
}Always filter a trader.fill subscription. This event is fed from the live
fill tail — roughly 68 fills per second across all of Polymarket. A
subscription with an empty filter_json will receive all of them. Set
trader_proxy (the wallets you follow) and/or min_size_usdc.
condition_id is present only when the fill's token resolves to a known
condition, so a condition_id filter may not match every fill yet. Only the
taker side of each fill is emitted — a maker-heavy wallet is currently
invisible to this event.
signal.smart_money
Emitted in real time when a wallet carrying at least one positive
smart-money tag (whale, sharp, early_bird, contrarian) takes a fill
of $5,000+ notional.
{
"type": "signal.smart_money",
"emitted_at": "2026-06-11T18:04:12.512Z",
"data": {
"signal_type": "large_buy",
"trader_proxy": "0x1f4a…",
"smart_money_tags": ["whale", "sharp"],
"condition_id": "0x5f65…",
"title": "Will X happen by June 30?",
"outcome_side": "YES",
"price": 0.34,
"size_usdc": 12500.0,
"tx_hash": "0xab12…"
}
}signal_type is large_buy (taker bought the outcome token) or exit
(taker sold).
Per-subscription filters
filter_json (set at POST /v1/webhooks or PUT /v1/webhooks/{id})
narrows which events a subscription receives. All keys are optional and
ANDed; unknown keys are ignored.
| Key | Type | Matches |
|---|---|---|
condition_id | string or string[] | data.condition_id (case-insensitive) |
trader_proxy | string or string[] | data.trader_proxy |
signal_types | string[] | data.signal_type is in the list |
tags_any | string[] | data.smart_money_tags intersects the list |
min_size_usdc | number | data.size_usdc is at least this |
Example — only $25k+ buys from sharp wallets:
{ "min_size_usdc": 25000, "signal_types": ["large_buy"], "tags_any": ["sharp"] }Webhooks are not enough on their own — pair them with the pull feed
Webhook delivery is at-least-once while your receiver is reachable. Deliveries that exhaust their retries are not replayed. For anything where missing one event corrupts your state — above all settlement — treat the webhook as the fast path and reconcile against a pull feed:
market.resolved(push, seconds) +GET /v1/resolutions(pull, authoritative, cursor-based catch-up).
A copy bot that learns about resolutions only from webhooks will eventually miss one, and will then carry a resolved position forever on mismarked equity with the capital stuck in a slot that never frees. Persist a cursor and sweep.