Understanding the Polymarket Data API with Ruby

Emmanuel Hayford

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:

  1. Respect rate limits: Add small delays between paginated requests and implement exponential backoff for retries.
  2. Cache aggressively: Position data doesn't need to be real-time for most use cases. A 60-second TTL can dramatically reduce API calls.
  3. Handle errors gracefully: External APIs fail. Your code should expect and handle transient failures.
  4. Understand the data model: Know the difference between Condition IDs and Token IDs, and remember that users interact through proxy wallets.
  5. 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!

Share