If you've been curious about prediction markets, Polymarket is hard to ignore. It's become one of the most talked-about platforms for betting on real-world events: elections, crypto prices, sports, you name it. Behind the scenes, Polymarket exposes a powerful Data API that gives you access to positions, trades, activity, and more. This post walks through how to interact with the API using Ruby, from basic queries to production-ready patterns.
What Is the Polymarket Data API?
The Polymarket Data API is a read-only REST API that provides access to user and market data. It's separate from the CLOB (Central Limit Order Book) API, which handles order placement and cancellation. Think of the Data API as your window into what's happening on the platform: who's holding what, historical trades, and user activity.
The base URL is https://data-api.polymarket.com, and most endpoints are public, meaning you don't need authentication for basic queries. This makes it perfect for building dashboards, analytics tools, or just satisfying your curiosity about whale activity.
Important Concepts Before We Start
Before diving into code, let's clarify some Polymarket-specific terminology that trips up newcomers:
Condition ID vs Token ID: A Condition ID identifies a market or question (e.g., "Will Bitcoin hit $100k by December?"). Each market has two Token IDs representing the Yes and No outcome tokens. When querying positions, you'll see both.
Proxy Wallet vs EOA: Polymarket users interact through a proxy wallet (a Gnosis Safe). Your EOA (Externally Owned Account) controls the proxy, but the proxy wallet actually holds your positions. The Data API accepts either address in the user parameter, but responses return the proxyWallet field.
Negative Risk Markets: Polymarket has two market types. Standard markets and "negative risk" markets behave differently under the hood. The API response includes a negativeRisk boolean flag. For most use cases you can ignore this, but it matters if you're building trading tools.
Core Endpoints You Should Know
Here are the most commonly used endpoints:
- GET /positions: Current positions for a user, including size, average price, P&L, and whether tokens are redeemable or mergeable
- GET /trades: Trade history filtered by user or market
- GET /activity: On-chain actions like merges, splits, and redemptions
- GET /holders: Top holders for specific markets
- GET /value: Total value of a user's open positions
- GET /closed-positions: Historical positions that have been closed or resolved
- GET /leaderboard: Trader rankings based on performance
Note: For market metadata like titles, descriptions, and resolution rules, you'll need the Gamma API at https://gamma-api.polymarket.com. The Data API focuses on user and position data.
Let's build something useful with these.
Building a Production-Ready HTTP Client
For anything beyond quick scripts, you'll want a proper client class with error handling, retries, and connection management. Here's a solid starting point:
require 'uri'
require 'net/http'
require 'json'
class PolymarketDataClient
BASE_URL = 'https://data-api.polymarket.com'.freeze
DEFAULT_TIMEOUT = 10
MAX_RETRIES = 3
class ApiError < StandardError
attr_reader :status_code, :response_body
def initialize(message, status_code: nil, response_body: nil)
@status_code = status_code
@response_body = response_body
super(message)
end
end
class RateLimitError < ApiError; end
class NotFoundError < ApiError; end
class ValidationError < ApiError; end
def initialize(timeout: DEFAULT_TIMEOUT)
@timeout = timeout
@http = nil
end
def get(endpoint, params = {})
with_retry do
uri = URI("#{BASE_URL}#{endpoint}")
uri.query = URI.encode_www_form(params.compact) if params.any?
request = Net::HTTP::Get.new(uri)
request['Accept'] = 'application/json'
request['User-Agent'] = 'PolymarketRubyClient/1.0'
response = connection(uri).request(request)
handle_response(response)
end
end
private
def connection(uri)
return @http if @http&.started?
@http = Net::HTTP.new(uri.host, uri.port)
@http.use_ssl = true
@http.read_timeout = @timeout
@http.open_timeout = @timeout
@http.start
@http
end
def handle_response(response)
case response.code.to_i
when 200..299
JSON.parse(response.body)
when 400
raise ValidationError.new("Bad request: #{response.body}", status_code: 400, response_body: response.body)
when 404
raise NotFoundError.new("Resource not found", status_code: 404, response_body: response.body)
when 429
raise RateLimitError.new("Rate limit exceeded", status_code: 429, response_body: response.body)
else
raise ApiError.new("Request failed: #{response.code}", status_code: response.code.to_i, response_body: response.body)
end
end
def with_retry(max_attempts: MAX_RETRIES)
attempts = 0
begin
yield
rescue RateLimitError, Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET => e
attempts += 1
raise if attempts >= max_attempts
sleep_time = 2 ** attempts
warn "Request failed (#{e.class}), retrying in #{sleep_time}s (attempt #{attempts}/#{max_attempts})"
sleep(sleep_time)
retry
end
end
end
This gives us proper error handling, connection reuse, and exponential backoff for transient failures. The custom exceptions make it easier to handle different failure modes in your application code.
Fetching User Positions
The /positions endpoint is probably where you'll spend most of your time. It returns everything about a user's current holdings:
class PolymarketDataClient
def positions(wallet_address, options = {})
raise ValidationError.new("wallet_address is required") if wallet_address.nil? || wallet_address.empty?
params = {
user: wallet_address,
limit: options.fetch(:limit, 100),
offset: options.fetch(:offset, 0),
sizeThreshold: options.fetch(:size_threshold, 0),
redeemable: options.fetch(:redeemable, false),
mergeable: options.fetch(:mergeable, false),
sortBy: options.fetch(:sort_by, 'TOKENS'),
sortDirection: options.fetch(:sort_direction, 'DESC')
}
get('/positions', params)
end
end
Notice the use of fetch with defaults instead of ||. This matters because options[:redeemable] || false will always return false even when the caller explicitly passes redeemable: false. The fetch approach preserves the caller's intent.
The response includes fields you'll actually care about:
client = PolymarketDataClient.new
wallet = '0x56687bf447db6ffa42ffe2204a05edaa20f55839'
positions = client.positions(wallet, size_threshold: 1)
positions.each do |pos|
puts "Market: #{pos['title']}"
puts " Condition ID: #{pos['conditionId']}"
puts " Outcome: #{pos['outcome']} (index: #{pos['outcomeIndex']})"
puts " Size: #{pos['size']} shares"
puts " Avg Price: $#{pos['avgPrice']}"
puts " Current Price: $#{pos['curPrice']}"
puts " Initial Value: $#{pos['initialValue']}"
puts " Current Value: $#{pos['currentValue']}"
puts " Cash P&L: $#{pos['cashPnl']} (#{pos['percentPnl']}%)"
puts " Realized P&L: $#{pos['realizedPnl']}"
puts " Redeemable: #{pos['redeemable']}"
puts " Negative Risk: #{pos['negativeRisk']}"
puts " Proxy Wallet: #{pos['proxyWallet']}"
puts "---"
end
The redeemable flag is important. When a market resolves, winning positions become redeemable for USDC. You can filter specifically for these:
redeemable_positions = client.positions(wallet, redeemable: true)
total_claimable = redeemable_positions.sum { |p| p['currentValue'].to_f }
puts "Total claimable: $#{total_claimable.round(2)}"
Advanced Trade Analysis
The /trades endpoint gives you execution history. Combined with some Ruby, you can build useful analytics:
class PolymarketDataClient
def trades(options = {})
user = options[:user]
market = options[:market]
raise ValidationError.new("user or market is required") if user.nil? && market.nil?
params = {
user: user,
market: market,
limit: options.fetch(:limit, 100),
offset: options.fetch(:offset, 0),
before: options[:before],
after: options[:after]
}
get('/trades', params)
end
end
Here's how to analyze trading patterns:
def analyze_trading_activity(wallet_address, days_back: 30)
client = PolymarketDataClient.new
after_timestamp = (Time.now - days_back * 24 * 60 * 60).to_i
all_trades = []
offset = 0
loop do
batch = client.trades(user: wallet_address, after: after_timestamp, limit: 100, offset: offset)
break if batch.empty?
all_trades.concat(batch)
offset += 100
break if batch.size < 100
sleep(0.1) # Be nice to the API
end
buys = all_trades.select { |t| t['side'] == 'BUY' }
sells = all_trades.select { |t| t['side'] == 'SELL' }
total_bought = buys.sum { |t| t['price'].to_f * t['size'].to_f }
total_sold = sells.sum { |t| t['price'].to_f * t['size'].to_f }
{
total_trades: all_trades.size,
buy_count: buys.size,
sell_count: sells.size,
total_volume_bought: total_bought.round(2),
total_volume_sold: total_sold.round(2),
net_flow: (total_sold - total_bought).round(2),
unique_markets: all_trades.map { |t| t['market'] }.uniq.size,
first_trade: all_trades.last&.dig('matchTime'),
last_trade: all_trades.first&.dig('matchTime')
}
end
stats = analyze_trading_activity('0x56687bf447db6ffa42ffe2204a05edaa20f55839', days_back: 7)
puts "Trades in last 7 days: #{stats[:total_trades]}"
puts "Net flow: $#{stats[:net_flow]}"
Tracking On-Chain Activity
The /activity endpoint captures actions that happen on-chain but aren't traditional trades: splits, merges, and redemptions. This is useful for understanding the full picture of a user's interactions:
class PolymarketDataClient
def activity(wallet_address, options = {})
raise ValidationError.new("wallet_address is required") if wallet_address.nil? || wallet_address.empty?
params = {
user: wallet_address,
limit: options.fetch(:limit, 50),
offset: options.fetch(:offset, 0)
}
get('/activity', params)
end
end
activities = client.activity(wallet)
activities.each do |act|
case act['type']
when 'SPLIT'
puts "Split: Converted USDC into outcome tokens for #{act['title']}"
when 'MERGE'
puts "Merge: Converted outcome tokens back to USDC for #{act['title']}"
when 'REDEEM'
puts "Redeem: Claimed winnings from #{act['title']}"
end
end
Finding the Whales
Want to know who the big players are on a specific market? The /holders endpoint shows top token holders by Condition ID:
class PolymarketDataClient
def holders(condition_id, options = {})
raise ValidationError.new("condition_id is required") if condition_id.nil? || condition_id.empty?
params = {
market: condition_id,
limit: options.fetch(:limit, 100),
offset: options.fetch(:offset, 0)
}
get('/holders', params)
end
end
# Find top holders for a specific market
condition_id = '0xdd22472e552920b8438158ea7238bfadfa4f736aa4cee91a6b86c39ead110917'
top_holders = client.holders(condition_id, limit: 20)
top_holders.each_with_index do |holder, i|
puts "#{i + 1}. #{holder['proxyWallet']}"
puts " Position: #{holder['outcome']} - #{holder['size']} shares ($#{holder['value']})"
end
This is great for sentiment analysis. If you see large wallets loading up on one side of a market, that's signal worth paying attention to.
Leaderboard and Performance Tracking
The /leaderboard endpoint ranks traders by performance:
class PolymarketDataClient
def leaderboard(options = {})
params = {
limit: options.fetch(:limit, 100),
offset: options.fetch(:offset, 0)
}
get('/leaderboard', params)
end
end
leaders = client.leaderboard(limit: 10)
leaders.each_with_index do |trader, i|
address = trader['address']
truncated = "#{address[0..6]}...#{address[-4..]}"
puts "#{i + 1}. #{truncated}"
puts " P&L: $#{trader['pnl']}"
puts " Volume: $#{trader['volume']}"
puts " Markets traded: #{trader['marketsTraded']}"
end
Handling Pagination Properly
Most endpoints cap results at 500 per request. Here's a generic pagination helper that works with the varying method signatures:
class PolymarketDataClient
def paginate_positions(wallet_address, options = {}, max_results: nil)
paginate_with_offset(max_results: max_results) do |offset, limit|
positions(wallet_address, options.merge(offset: offset, limit: limit))
end
end
def paginate_trades(options = {}, max_results: nil)
paginate_with_offset(max_results: max_results) do |offset, limit|
trades(options.merge(offset: offset, limit: limit))
end
end
def paginate_holders(condition_id, options = {}, max_results: nil)
paginate_with_offset(max_results: max_results) do |offset, limit|
holders(condition_id, options.merge(offset: offset, limit: limit))
end
end
private
def paginate_with_offset(max_results: nil, page_size: 100)
results = []
offset = 0
loop do
batch = yield(offset, page_size)
break if batch.empty?
results.concat(batch)
offset += page_size
break if batch.size < page_size
break if max_results && results.size >= max_results
sleep(0.05) # Rate limit courtesy
end
max_results ? results.first(max_results) : results
end
end
# Fetch all positions for a user
all_positions = client.paginate_positions(wallet, { size_threshold: 1 }, max_results: 1000)
Caching Strategies
For production use, you'll want to cache responses. Position data doesn't change every second, and hammering the API will get you rate limited:
class CachedPolymarketClient
def initialize(client, cache_store)
@client = client
@cache = cache_store
end
def positions(wallet_address, options = {})
cache_key = build_cache_key('positions', wallet_address, options)
ttl = 60 # 1 minute
cached = @cache.read(cache_key)
return cached if cached
result = @client.positions(wallet_address, options)
@cache.write(cache_key, result, expires_in: ttl)
result
end
def trades(options = {})
cache_key = build_cache_key('trades', nil, options)
ttl = 30 # 30 seconds for more volatile data
cached = @cache.read(cache_key)
return cached if cached
result = @client.trades(options)
@cache.write(cache_key, result, expires_in: ttl)
result
end
private
def build_cache_key(prefix, identifier, options)
normalized_options = options.sort.to_h.to_json
components = ['polymarket', prefix, identifier, Digest::MD5.hexdigest(normalized_options)].compact
components.join(':')
end
end
# With Rails cache
cached_client = CachedPolymarketClient.new(PolymarketDataClient.new, Rails.cache)
The cache key uses MD5 of the sorted options hash to avoid collision issues that can occur with Ruby's Hash#hash method (which isn't stable across processes).
Real-Time Data: When to Use WebSockets
The Data API is great for polling, but if you need real-time updates, Polymarket offers WebSocket connections. The WebSocket feed provides live order book updates, trade notifications, and price changes. For dashboards that need sub-second updates, consider using the WebSocket API instead of polling the REST endpoints. The REST API is better suited for batch operations, analytics, and cases where eventual consistency is acceptable.
Putting It All Together: A Portfolio Dashboard
Here's a practical example that combines multiple endpoints to build a portfolio summary:
class PortfolioService
def initialize(wallet_address)
@wallet = wallet_address
@client = PolymarketDataClient.new
end
def summary
positions = @client.positions(@wallet, size_threshold: 1)
redeemable = @client.positions(@wallet, redeemable: true)
recent_trades = @client.trades(user: @wallet, limit: 10)
open_value = positions.sum { |p| p['currentValue'].to_f }
total_pnl = positions.sum { |p| p['cashPnl'].to_f }
claimable = redeemable.sum { |p| p['currentValue'].to_f }
{
wallet: @wallet,
proxy_wallet: positions.first&.dig('proxyWallet'),
open_positions: positions.size,
total_open_value: open_value.round(2),
unrealized_pnl: total_pnl.round(2),
claimable_winnings: claimable.round(2),
positions_by_market: group_positions_by_market(positions),
recent_trades: format_trades(recent_trades)
}
end
private
def group_positions_by_market(positions)
positions.group_by { |p| p['conditionId'] }.transform_values do |market_positions|
{
title: market_positions.first['title'],
outcomes: market_positions.map { |p|
{
outcome: p['outcome'],
size: p['size'],
avg_price: p['avgPrice'],
current_value: p['currentValue']
}
}
}
end
end
def format_trades(trades)
trades.map do |t|
{
market: t['title'],
side: t['side'],
size: t['size'],
price: t['price'],
timestamp: t['matchTime']
}
end
end
end
service = PortfolioService.new('0x56687bf447db6ffa42ffe2204a05edaa20f55839')
summary = service.summary
puts JSON.pretty_generate(summary)
Address Validation
A quick note on Ethereum addresses: while the Polymarket API accepts lowercase addresses, it's good practice to validate the format before making requests:
def valid_eth_address?(address)
return false unless address.is_a?(String)
return false unless address.match?(/\A0x[a-fA-F0-9]{40}\z/)
true
end
Wrapping Up
The Polymarket Data API is straightforward to work with from Ruby. No API keys needed for most read operations, sensible defaults, and clean JSON responses. The patterns covered in this post should handle most use cases: building dashboards, analyzing whale activity, tracking positions, or researching market dynamics.
A few things to keep in mind as you build:
- Respect rate limits: Add small delays between paginated requests and implement exponential backoff for retries.
- Cache aggressively: Position data doesn't need to be real-time for most use cases. A 60-second TTL can dramatically reduce API calls.
- Handle errors gracefully: External APIs fail. Your code should expect and handle transient failures.
- Understand the data model: Know the difference between Condition IDs and Token IDs, and remember that users interact through proxy wallets.
- Use the right tool: REST for batch operations and analytics, WebSockets for real-time updates.
If you want to go deeper and actually place orders or manage positions programmatically, you'll need to work with the CLOB API. That requires cryptographic signatures, API credentials, and a good understanding of how Polymarket's order matching works. But that's a topic for another post.
Happy hacking!