How to Track Polymarket UMA Disputes in Real Time with WebSockets

Track Polymarket UMA disputes as they happen. How the Optimistic Oracle works, what disputes look like in live data, and a Python WebSocket example.

A Polymarket market does not pay out when the game ends. It pays out when UMA's Optimistic Oracle settles it. Most of the time that is quiet and fast. But when someone disputes the proposed answer, the market does not resolve on time. Positions stay locked. The answer can flip.

If you run a bot, a dashboard or a trading desk, you want to know about a dispute in seconds, not hours later. This guide shows how.

Table of every UMA frame for the Polymarket market 'KBO: Doosan Bears vs. Samsung Lions', from our sample. Oct 6 at 4:05 PM ET, a proposal of yes arrives as a pending mempool frame and is confirmed 0.6 seconds later. At 6:06 PM a dispute arrives pending, then confirmed. At 7:13 PM a second proposal says no. On Oct 7 at 8:21 AM the market settles no. Tiles show a 0.6 second pending lead on the dispute, 103 of 151 disputes seen pending first, and the answer change from yes to no.

What you will learn

  • How a Polymarket market moves through UMA's Optimistic Oracle

  • What disputes looked like in our live sample (about 97 hours of live frames, Oct 6–19, 2026)

  • How to stream proposals, disputes and settlements over a WebSocket

  • How to handle the pending-then-confirmed pair, de-duplicate, and alert

What you will need

  • Python 3.10 or newer, and pip install websockets

  • A Bravado account on a paid plan, with a WebSockets API key that has the stream.read scope

  • BRAVADO_API_KEY and BRAVADO_API_SECRET set in your server environment

How does Polymarket resolve a market with UMA?

Polymarket uses UMA's Optimistic Oracle. "Optimistic" means an answer is accepted unless someone challenges it. The flow has five steps.

1. Proposal

When the event ends, anyone can propose the outcome. The proposer posts a bond, typically $750 according to Polymarket's docs.

2. Challenge window

The proposal is then open to challenge. Polymarket's docs give a 2-hour challenge period. If no one disputes, the proposal is accepted and the market resolves.

3. Dispute

Anyone can dispute within the window by posting a counter-bond, typically the same $750. Per Polymarket's docs, the first dispute starts a new proposal round. If the second proposal is also disputed, the question escalates to UMA's DVM.

4. DVM vote

The DVM is UMA's token-holder vote. Polymarket's docs describe a 24–48 hour debate period, then about 48 hours of voting.

5. Settlement

The final answer is written on-chain. Winning shares become redeemable at $1. Losing shares go to $0.

Diagram of the UMA Optimistic Oracle flow for Polymarket in five steps: propose with a bond, a 2-hour challenge period, dispute (the first dispute starts a new proposal round), and a DVM token-holder vote if the second proposal is disputed, and settlement. A table shows three paths: no dispute takes about 2 hours, one dispute adds a proposal round, two disputes take 4 to 6 days.

Path

Steps

Typical time (Polymarket docs)

No dispute

Propose → settle

~2 hours

One dispute

Propose → dispute → propose again → settle

One more proposal round

Two disputes

Propose → dispute → propose → dispute → DVM vote → settle

4–6 days

Why do UMA disputes matter to traders and bots?

A dispute changes three things.

  • Timing. The market will not settle at the end of the expected window. Capital stays locked.

  • Outcome risk. The next proposal can carry a different answer. In one market in our sample the answer flipped from yes to no.

  • Your data model. An event that has visibly ended is not the same as a settled market. Wait for the settled frame before treating a position as realized.

If you show balances, book PnL or quote prices near resolution, you need dispute events as they happen.

What did disputes look like in our sample?

We streamed Bravado's /uma/ws feed and kept every live frame. The sample is about 97 hours of live frames between Oct 6 and Oct 19, 2026. Every number below is from that sample. Treat the numbers as a snapshot, not a census.

1. Disputes are rare

In our sample

Count

Questions with a proposal

94,728

Questions with a dispute

151

Questions settled

91,497

Dispute frames (pending + confirmed)

260

That is about 0.16% of proposed questions. But one event skews it. On Oct 18, 90 markets on a single football match, FK Polissia vs. FK Dynamo Kyiv, were disputed within about 10 minutes. Leave that match out and 61 questions were disputed, about 0.06%, or roughly 1 in 1,550.

So disputes are rare, and they come in bursts. One bad proposal batch can put dozens of markets on hold at once.

2. Most disputes are on sports markets

Horizontal bar chart of 151 disputed Polymarket questions by category in our sample. Sports has 138, of which 90 came from one match, FK Polissia vs. FK Dynamo Kyiv on Oct 18. Other has 10, and Politics, Crypto and Weather have 1 each. Dispute shares range from 0.04% to 0.18% of proposed questions.

Category

Proposed questions

Disputed questions

Share disputed

Sports

82,764

138 (48 without the Polissia match)

0.17% (0.06%)

Weather

2,626

1

0.04%

Crypto

2,803

1

0.04%

Politics

894

1

0.11%

Other

5,641

10

0.18%

Sports dominates because sports dominates proposals. 87% of proposed questions in our sample were sports. Even without the Polissia match, 48 of the remaining 61 disputes were sports. Categories are our own grouping by market title, so treat the small rows as rough.

Real examples from our sample:

  • KBO: Doosan Bears vs. Samsung Lions. Proposed "yes", disputed, re-proposed "no", settled "no".

  • Paolini vs. Spasov: Match O/U 23.5 and O/U 22.5. Two lines on the same match were disputed together.

  • FK Polissia vs. FK Dynamo Kyiv. 90 markets on one match were disputed between 9:48 and 9:59 AM ET on Oct 18: exact scores, spreads, over/unders and "Will FK Polissia win on 2026-10-18?".

  • Soccer player props, such as "Wil Trapp: 1+ assists" and "Albert Rusnák: 1+ assists". A batch was disputed within minutes on Oct 11.

  • Will Party U win the second-most seats in the 2026 Israeli legislative election?

  • Saudi Arabia military action against Yemen on September 20? Disputed on Oct 19.

Disputes cluster. When one line on a match is wrong, the sibling lines often are too. Group alerts by match so one event does not send you 90 separate messages.

3. Disputes usually come early in the window

Dot plot on a log scale of minutes from confirmed proposal to confirmed dispute for 37 disputes in our sample. 27 fall within 2 hours, with a median of 19 minutes and 12 within 10 minutes. 10 fall between 2 and 11 hours, which most likely had a longer challenge window.

We measured the time from a confirmed proposal to a confirmed dispute where we saw both (37 disputes in our sample).

In our sample

Value

Disputes within 2 hours of the proposal

27

Median time to dispute (those 27)

about 19 minutes

Disputed within 10 minutes

12 of 27

The other 10 came more than 2 hours after the proposal.¹ So a 2-hour timer is not a safe "no dispute is coming" signal. Wait for the settled frame.

4. Most undisputed markets settle in about 2 hours, but not all

Histogram of minutes from confirmed proposal to settlement for 55,118 undisputed questions in our sample. Two groups: 25,926 settled in under 90 minutes with a median of 21 minutes, and 28,734 settled between 90 and 135 minutes with a median of 121 minutes, right after the 2-hour challenge period.

For 55,118 undisputed questions in our sample, we saw both the confirmed proposal and the settlement.

Settled after the proposal (in our sample)

Questions

Median

Under 90 minutes

25,926 (47%)

21 minutes

90–135 minutes

28,734 (52%)

121 minutes

Over 135 minutes

458 (1%)

n/a

There are two clear groups. One settles right after the 2-hour default. The other settles about 15–55 minutes after the proposal.² Do not hard-code 2 hours. Let the stream tell you.

5. The mempool gives you a head start

In our sample, many proposals and disputes arrived twice: first with "seen_pending": true and block: 0, then again with a real block once mined.

In our sample

Value

Disputes first seen as pending

103 of 151

Pending-to-confirmed lead (median)

about 1.2 seconds

Pending-to-confirmed lead (middle half)

0.6–1.7 seconds

A second is small. It matters if you need to pull quotes before a dispute is on-chain.

How do you stream UMA disputes with a WebSocket?

1. Know the frame

Every message on /uma/ws is a uma frame. Bravado's docs list these fields:

Field

Meaning

stage

proposed, disputed or settled

answer

yes, no, 50-50, too-early or other

question_id

UMA adapter question id, the stable key for a resolution

condition_id

CTF condition id, to join with positions and trades

proposer, disputer

Addresses

price_raw

Exact int256 price, hex

payout

Micro-USDC string, on settlement

request_ts

Oracle request time, unix seconds

title

Market question

block, tx, log_index

On-chain location. block is 0 while pending

seen_pending

true on mempool frames

Here is a real pending dispute frame from our sample (trimmed):

{"type": "uma", "stream": "uma:polymarket:*", "seq": 1075800,
 "stage": "disputed", "answer": "other", "seen_pending": true, "block": 0,
 "title": "Saudi Arabia military action against Yemen on September 20?",
 "question_id": "0xd5e458db…", "tx": "0x4f766e5f…"}
{"type": "uma", "stream": "uma:polymarket:*", "seq": 1075800,
 "stage": "disputed", "answer": "other", "seen_pending": true, "block": 0,
 "title": "Saudi Arabia military action against Yemen on September 20?",
 "question_id": "0xd5e458db…", "tx": "0x4f766e5f…"}
{"type": "uma", "stream": "uma:polymarket:*", "seq": 1075800,
 "stage": "disputed", "answer": "other", "seen_pending": true, "block": 0,
 "title": "Saudi Arabia military action against Yemen on September 20?",
 "question_id": "0xd5e458db…", "tx": "0x4f766e5f…"}
{"type": "uma", "stream": "uma:polymarket:*", "seq": 1075800,
 "stage": "disputed", "answer": "other", "seen_pending": true, "block": 0,
 "title": "Saudi Arabia military action against Yemen on September 20?",
 "question_id": "0xd5e458db…", "tx": "0x4f766e5f…"}

The confirmed frame followed 1.6 seconds later with the real block, the same tx, and "answer": "no". The answer on a pending frame can read other. Use the confirmed frame's answer.

2. Authenticate

The upgrade is signed with HMAC-SHA256. Sign timestamp, GET, /uma/ws and the SHA-256 of an empty body, joined by newlines. Send three headers: X-BRAVADO-API-KEY, X-BRAVADO-TIMESTAMP (unix ms) and X-BRAVADO-SIGNATURE. Make a new signature on every reconnect. Our listener sends no subscribe message; frames arrive after the signed upgrade.

3. Filter, pair, de-duplicate and alert

# pip install websockets
import asyncio, hashlib, hmac, json, os, time
import websockets

URL = "wss://partner-api.bravadotrade.com/uma/ws"
KEY = os.environ["BRAVADO_API_KEY"]
SECRET = os.environ["BRAVADO_API_SECRET"]

def sign_headers() -> dict:
    ts = str(int(time.time() * 1000))
    payload = "\n".join([ts, "GET", "/uma/ws", hashlib.sha256(b"").hexdigest()])
    sig = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()
    return {"X-BRAVADO-API-KEY": KEY, "X-BRAVADO-TIMESTAMP": ts, "X-BRAVADO-SIGNATURE": sig}

seen = set()     # (question_id, tx, pending?) already handled
alerted = {}     # question_id -> tx of the dispute we alerted on

def alert(text: str) -> None:
    print(text)  # swap for Slack, Telegram, PagerDuty...

def handle(f: dict) -> None:
    if f.get("type") == "error":
        alert(f"stream error: {f.get('code')} {f.get('message')}")
        return
    if f.get("type") != "uma" or f.get("stage") != "disputed":
        return
    qid, tx = f["question_id"], f["tx"]
    pending = bool(f.get("seen_pending"))
    key = (qid, tx, pending)
    if key in seen:          # same frame twice (e.g. after a reconnect)
        return
    seen.add(key)
    if pending:
        alerted[qid] = tx
        alert(f"DISPUTE (mempool) {f['title']} | question {qid}")
    elif alerted.get(qid) == tx:
        alert(f"DISPUTE confirmed in block {f['block']}: {f['title']} | answer {f['answer']}")
    else:                    # no pending frame seen for this one
        alerted[qid] = tx
        alert(f"DISPUTE in block {f['block']}: {f['title']} | answer {f['answer']}")

async def run() -> None:
    backoff = 1
    while True:
        try:
            async with websockets.connect(URL, additional_headers=sign_headers(),
                                          ping_interval=20, ping_timeout=30) as ws:
                backoff = 1
                async for raw in ws:
                    handle(json.loads(raw))
        except websockets.ConnectionClosed as e:
            print("closed", e.rcvd.code if e.rcvd else None, e.rcvd.reason if e.rcvd else "")
        except Exception as e:
            print("connect failed:", e)
            backoff = min(backoff * 2, 30)
        await asyncio.sleep(backoff)   # fresh signature on every loop

asyncio.run(run())
# pip install websockets
import asyncio, hashlib, hmac, json, os, time
import websockets

URL = "wss://partner-api.bravadotrade.com/uma/ws"
KEY = os.environ["BRAVADO_API_KEY"]
SECRET = os.environ["BRAVADO_API_SECRET"]

def sign_headers() -> dict:
    ts = str(int(time.time() * 1000))
    payload = "\n".join([ts, "GET", "/uma/ws", hashlib.sha256(b"").hexdigest()])
    sig = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()
    return {"X-BRAVADO-API-KEY": KEY, "X-BRAVADO-TIMESTAMP": ts, "X-BRAVADO-SIGNATURE": sig}

seen = set()     # (question_id, tx, pending?) already handled
alerted = {}     # question_id -> tx of the dispute we alerted on

def alert(text: str) -> None:
    print(text)  # swap for Slack, Telegram, PagerDuty...

def handle(f: dict) -> None:
    if f.get("type") == "error":
        alert(f"stream error: {f.get('code')} {f.get('message')}")
        return
    if f.get("type") != "uma" or f.get("stage") != "disputed":
        return
    qid, tx = f["question_id"], f["tx"]
    pending = bool(f.get("seen_pending"))
    key = (qid, tx, pending)
    if key in seen:          # same frame twice (e.g. after a reconnect)
        return
    seen.add(key)
    if pending:
        alerted[qid] = tx
        alert(f"DISPUTE (mempool) {f['title']} | question {qid}")
    elif alerted.get(qid) == tx:
        alert(f"DISPUTE confirmed in block {f['block']}: {f['title']} | answer {f['answer']}")
    else:                    # no pending frame seen for this one
        alerted[qid] = tx
        alert(f"DISPUTE in block {f['block']}: {f['title']} | answer {f['answer']}")

async def run() -> None:
    backoff = 1
    while True:
        try:
            async with websockets.connect(URL, additional_headers=sign_headers(),
                                          ping_interval=20, ping_timeout=30) as ws:
                backoff = 1
                async for raw in ws:
                    handle(json.loads(raw))
        except websockets.ConnectionClosed as e:
            print("closed", e.rcvd.code if e.rcvd else None, e.rcvd.reason if e.rcvd else "")
        except Exception as e:
            print("connect failed:", e)
            backoff = min(backoff * 2, 30)
        await asyncio.sleep(backoff)   # fresh signature on every loop

asyncio.run(run())
# pip install websockets
import asyncio, hashlib, hmac, json, os, time
import websockets

URL = "wss://partner-api.bravadotrade.com/uma/ws"
KEY = os.environ["BRAVADO_API_KEY"]
SECRET = os.environ["BRAVADO_API_SECRET"]

def sign_headers() -> dict:
    ts = str(int(time.time() * 1000))
    payload = "\n".join([ts, "GET", "/uma/ws", hashlib.sha256(b"").hexdigest()])
    sig = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()
    return {"X-BRAVADO-API-KEY": KEY, "X-BRAVADO-TIMESTAMP": ts, "X-BRAVADO-SIGNATURE": sig}

seen = set()     # (question_id, tx, pending?) already handled
alerted = {}     # question_id -> tx of the dispute we alerted on

def alert(text: str) -> None:
    print(text)  # swap for Slack, Telegram, PagerDuty...

def handle(f: dict) -> None:
    if f.get("type") == "error":
        alert(f"stream error: {f.get('code')} {f.get('message')}")
        return
    if f.get("type") != "uma" or f.get("stage") != "disputed":
        return
    qid, tx = f["question_id"], f["tx"]
    pending = bool(f.get("seen_pending"))
    key = (qid, tx, pending)
    if key in seen:          # same frame twice (e.g. after a reconnect)
        return
    seen.add(key)
    if pending:
        alerted[qid] = tx
        alert(f"DISPUTE (mempool) {f['title']} | question {qid}")
    elif alerted.get(qid) == tx:
        alert(f"DISPUTE confirmed in block {f['block']}: {f['title']} | answer {f['answer']}")
    else:                    # no pending frame seen for this one
        alerted[qid] = tx
        alert(f"DISPUTE in block {f['block']}: {f['title']} | answer {f['answer']}")

async def run() -> None:
    backoff = 1
    while True:
        try:
            async with websockets.connect(URL, additional_headers=sign_headers(),
                                          ping_interval=20, ping_timeout=30) as ws:
                backoff = 1
                async for raw in ws:
                    handle(json.loads(raw))
        except websockets.ConnectionClosed as e:
            print("closed", e.rcvd.code if e.rcvd else None, e.rcvd.reason if e.rcvd else "")
        except Exception as e:
            print("connect failed:", e)
            backoff = min(backoff * 2, 30)
        await asyncio.sleep(backoff)   # fresh signature on every loop

asyncio.run(run())
# pip install websockets
import asyncio, hashlib, hmac, json, os, time
import websockets

URL = "wss://partner-api.bravadotrade.com/uma/ws"
KEY = os.environ["BRAVADO_API_KEY"]
SECRET = os.environ["BRAVADO_API_SECRET"]

def sign_headers() -> dict:
    ts = str(int(time.time() * 1000))
    payload = "\n".join([ts, "GET", "/uma/ws", hashlib.sha256(b"").hexdigest()])
    sig = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()
    return {"X-BRAVADO-API-KEY": KEY, "X-BRAVADO-TIMESTAMP": ts, "X-BRAVADO-SIGNATURE": sig}

seen = set()     # (question_id, tx, pending?) already handled
alerted = {}     # question_id -> tx of the dispute we alerted on

def alert(text: str) -> None:
    print(text)  # swap for Slack, Telegram, PagerDuty...

def handle(f: dict) -> None:
    if f.get("type") == "error":
        alert(f"stream error: {f.get('code')} {f.get('message')}")
        return
    if f.get("type") != "uma" or f.get("stage") != "disputed":
        return
    qid, tx = f["question_id"], f["tx"]
    pending = bool(f.get("seen_pending"))
    key = (qid, tx, pending)
    if key in seen:          # same frame twice (e.g. after a reconnect)
        return
    seen.add(key)
    if pending:
        alerted[qid] = tx
        alert(f"DISPUTE (mempool) {f['title']} | question {qid}")
    elif alerted.get(qid) == tx:
        alert(f"DISPUTE confirmed in block {f['block']}: {f['title']} | answer {f['answer']}")
    else:                    # no pending frame seen for this one
        alerted[qid] = tx
        alert(f"DISPUTE in block {f['block']}: {f['title']} | answer {f['answer']}")

async def run() -> None:
    backoff = 1
    while True:
        try:
            async with websockets.connect(URL, additional_headers=sign_headers(),
                                          ping_interval=20, ping_timeout=30) as ws:
                backoff = 1
                async for raw in ws:
                    handle(json.loads(raw))
        except websockets.ConnectionClosed as e:
            print("closed", e.rcvd.code if e.rcvd else None, e.rcvd.reason if e.rcvd else "")
        except Exception as e:
            print("connect failed:", e)
            backoff = min(backoff * 2, 30)
        await asyncio.sleep(backoff)   # fresh signature on every loop

asyncio.run(run())

What the code does:

  • Filters to stage == "disputed". Change the filter to watch proposals or settlements too.

  • Pairs the pending and confirmed frames. They share question_id and tx. You get one early alert and one confirmation.

  • De-duplicates on (question_id, tx, seen_pending). A re-proposal and a second dispute on the same question have a new tx, so they alert again. That is what you want: a second dispute means a DVM vote.

  • Reconnects with a new signature each time.

4. Plan for reconnects

Any long-lived WebSocket can drop. Reconnect with backoff and a fresh signature, as the example does. Your client can miss frames while it is disconnected. To reduce that, you can run two connections and de-duplicate by seq. To check what you missed after a drop, query the REST endpoint GET /uma/recent?stage=disputed.

5. Follow one market

To follow a single market, filter on question_id or condition_id on your side. To check a market's full state at any time, call GET /uma/question?id=<question_id> or GET /uma/market?condition_id=<condition_id>.

Why use Bravado's UMA API and WebSocket?

You can read UMA events from the chain yourself. Then you need to index the oracle and adapter contracts, decode the ProposePrice, DisputePrice and Settle logs, classify the int256 price, and map each question back to a market.

Bravado's UMA API does that work for you:

  • One stream for the lifecycle. In our sample it carried 94,728 proposed, 151 disputed and 91,497 settled questions.

  • Decoded answers. answer is already yes, no, 50-50, too-early or other. price_raw keeps the exact value.

  • Mempool head start. In our sample, 103 of 151 disputes arrived first with seen_pending: true, a median 1.2 seconds before the confirmed frame.

  • Joinable ids. condition_id matches the rest of Bravado's market data.

  • REST for state. GET /uma/question, GET /uma/market and GET /uma/recent.

UMA access needs a paid plan. The Free plan cannot access UMA. The stream costs 0.75 credit per delivered UMA data message under unified billing. Heartbeats, errors and idle time are not billed. Pending and confirmed updates count as separate messages. A REST read costs 1 credit. See the UMA API docs and API credits.

Conclusion

Disputes are rare. In our sample about 0.16% of proposed questions were disputed, and well over half of those came from one match. When it happens, it delays resolution and can change the answer. The fix is simple. Stream every UMA frame, filter for disputed, pair the pending and confirmed frames, and alert. Never assume a market is final until you see settled.

Next steps:

Notes

¹ Ten disputes in our sample came 2 to 11 hours after the proposal, for example a batch of soccer player props on Oct 11. Polymarket's docs give a 2-hour challenge period, and a dispute can only land inside it. These requests most likely had a longer challenge window. UMA's docs say each request can set its own. The frame does not carry the liveness value, so we could not confirm it. We left these 10 out of the median.

² About 47% of undisputed questions in our sample settled 15–55 minutes after the proposal. A shorter challenge window on those requests is the likely reason, but the frame does not carry the liveness value, so we report this group separately and do not guess the setting. All timings use the send timestamps (ts_sent) of the confirmed frames.

Read more blogs

Trading insights, market analysis, and strategies for Polymarket prediction markets.

© 2026 Bravado. All rights reserved.

Not financial advice. Trade responsibly.

© 2026 Bravado. All rights reserved.

Not financial advice. Trade responsibly.

© 2026 Bravado. All rights reserved.

Not financial advice. Trade responsibly.

© 2026 Bravado. All rights reserved.

Not financial advice. Trade responsibly.