Polyrankdocs

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

EndpointWhat it does
GET /v1/webhooksList subscriptions
POST /v1/webhooksCreate — https:// URLs only (SSRF-guarded)
PUT /v1/webhooks/{id}Update URL / event types / active flag
DELETE /v1/webhooks/{id}Remove
POST /v1/webhooks/{id}/testSend 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.0
import { 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

TypeStatusPayload
polyrank.testliveSynthetic event from the /test endpoint
market.resolvedliveA Polymarket market resolved on-chain
signal.smart_moneyliveA smart-money-tagged wallet made a large trade
trader.fillliveA 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.

KeyTypeMatches
condition_idstring or string[]data.condition_id (case-insensitive)
trader_proxystring or string[]data.trader_proxy
signal_typesstring[]data.signal_type is in the list
tags_anystring[]data.smart_money_tags intersects the list
min_size_usdcnumberdata.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.

On this page