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 | coming soon | 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.
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"] }