When clicking Polymarket odds in the Value Dashboard to place an order, the request fails with a 403 Forbidden from Cloudflare. This prompted a full investigation of the click-to-trade flow, existing Polymarket infrastructure, and backend architecture.
The consolidated moneylines table renders Kalshi and Polymarket odds side by side. Each odds cell has a click handler:
- Kalshi cells → call
handleTrade(teamSide) - Polymarket cells → call
handlePolyTrade(teamSide)
These are correctly separated — the system does NOT accidentally route Poly clicks through Kalshi.
handleTrade() (Kalshi) — creates a request with venue: 'kalshi', marketTicker, side, priceCents, contracts.
handlePolyTrade() (Polymarket) — creates a request with venue: 'polymarket' plus Polymarket-specific fields: tokenId, conditionId, tickSize, negRisk. It resolves the opponent's market for "NO" semantics (team win = NO on opponent).
The TradeClickRequest is stored in state, which opens PlaceOrderModal. The modal:
- Shows a venue badge (blue for Kalshi, purple for Polymarket)
- Lets users adjust contract quantity
- Shows expiry options for Kalshi only (Polymarket always uses GTC)
- Converts the request into a
PlaceOrderRequest
handleSubmitTrade() branches on tradeReq.venue:
if venue === 'kalshi' → onPlaceKalshiOrder(...)
if venue === 'polymarket' → onPlacePolymarketOrder(...)
This routing is correct.
Kalshi path: handlePlaceKalshiDashboardOrder() → api.placeOrder() → relay → Kalshi API ✅
Polymarket path: handlePlacePolymarketDashboardOrder() → placePolyOrder() → ClobClient.postOrder() → direct POST to clob.polymarket.com/order → 403 Forbidden ❌
User clicks odds cell in ConsolidatedMoneylines
→ handlePolyTrade() creates TradeClickRequest { venue: 'polymarket', tokenId, ... }
→ onTradeClick(req) callback
→ ValueDashboardView.setTradeReq(req) — opens PlaceOrderModal
→ User clicks "Place Order"
→ handleSubmitTrade(submission)
→ onPlacePolymarketOrder(args)
→ App.tsx handlePlacePolymarketDashboardOrder()
→ placePolyOrder({ client, tokenId, action: 'buy', ... })
→ ClobClient.createOrder() — signs locally with ethers.Wallet ✅
→ ClobClient.postOrder() — POST to clob.polymarket.com/order ❌ 403
@polymarket/clob-client is a Node.js SDK. When used in the browser:
- Browser adds
OriginandRefererheaders to the outgoing request - Cloudflare on
clob.polymarket.comsees the cross-origin request - Cloudflare returns 403 Forbidden
This is the exact same problem Kalshi has, which is why we built the relay server.
Request URL: https://clob.polymarket.com/order
Request Method: POST
Status Code: 403 Forbidden
Server: cloudflare
Referrer Policy: strict-origin-when-cross-origin
Call stack:
onClick @ PlaceOrderModal.tsx:265
(anonymous) @ ValueDashboardView.tsx:388
(anonymous) @ App.tsx:1192
await in placePolyOrder @ client.ts:93
await in postOrder → clob.polymarket.com → 403
This affects all Polymarket CLOB API calls from the browser, not just order placement:
createOrDeriveApiKey()— credential derivationpostOrder()— order placementgetOpenOrders()— order refreshcancelOrder()/cancelAll()— order cancellation (not yet implemented)
| Capability | Status | File |
|---|---|---|
| Order placement (buy/sell, limit, GTC) | ✅ Code exists, blocked by 403 | lib/polymarket/client.ts → placePolyOrder() |
| Client creation + wallet signing | ✅ Works (client-side) | lib/polymarket/client.ts → createPolyTradingClient() |
| API credential derivation | ✅ Code exists, likely blocked by 403 | lib/polymarket/client.ts → createOrDerivePolyApiCreds() |
| User order/trade WebSocket stream | ✅ Works (WebSocket, not HTTP) | lib/polymarket/userStream.ts |
| Market book WebSocket stream | ✅ Works (WebSocket, not HTTP) | lib/polymarket/marketStream.ts |
| Market data via Gamma API | ✅ Works | lib/polymarket/gamma.ts |
| Open orders tracking | App.tsx post-placement call |
|
| Order cancellation | ❌ Not implemented | — |
| Config persistence (localStorage) | ✅ Works | client.ts → loadPolyUserConfig() / savePolyUserConfig() |
- Package:
@polymarket/clob-clientv5.2.1 - Signing:
ethersv5.8.0Wallet— private key stays in browser - Order types: GTC only, supports postOnly (maker) and taker modes
- Price: Cents (1-99), normalized to 0.0001-0.9999 decimal
- Chain: Polygon (chain ID 137)
- Order cancellation —
@polymarket/clob-clientsupportscancelOrder()andcancelAll()but we haven't implemented wrappers or UI for them. Kalshi has both. - Sell via click-to-trade —
action: 'buy'is hardcoded inValueDashboardView.tsx:391. No way to sell Polymarket positions from click-to-trade. - Robust order refresh — The post-placement
getOpenOrders()call silently swallows errors in acatch {}block.
The relay server (apps/relay/) is a generic HTTP + WebSocket forwarder, but production mode restricts it to Kalshi:
// apps/relay/src/httpRelay.ts:80-86
if (config.nodeEnv === 'production') {
const url = new URL(request.url);
const kalshiHost = new URL(config.kalshiBaseUrl).hostname;
if (url.hostname !== kalshiHost) {
throw new ValidationError(`URL must be from ${kalshiHost} in production`);
}
}Kalshi (works):
Browser → signs request (RSA-PSS) → relayHttp envelope → Relay (8787) → Kalshi API
Polymarket (broken):
Browser → signs order (ethers Wallet) → ClobClient POST → clob.polymarket.com → 403
Polymarket WebSockets (works):
Browser → wss://ws-subscriptions-clob.polymarket.com → market data / user events
WebSockets work because they handle CORS differently than HTTP POST requests.
Add a /poly/* route to the relay that transparently proxies to clob.polymarket.com/*.
New flow:
Browser → ClobClient(HOST=relay/poly) → Relay /poly/* → clob.polymarket.com ✅
Implementation:
- Add Express route:
app.all('/poly/*', ...)that strips/polyprefix and forwards toclob.polymarket.com - Change
HOSTinlib/polymarket/client.tsfromhttps://clob.polymarket.comto the relay URL - Wallet signing still happens client-side — private key never leaves the browser
Why this is best: Fixes ALL Polymarket HTTP calls (orders, cancellations, credential derivation, open orders) in one shot. No changes needed to the ClobClient usage patterns.
Use ClobClient.createOrder() for local signing only, then POST the signed payload through our existing relay envelope format.
Why this is worse: Only fixes order posting. Every other CLOB API call (createOrDeriveApiKey, getOpenOrders, cancelOrder) would need its own manual implementation.
After fixing the 403:
- Implement order cancellation — Add
cancelPolyOrder()andcancelAllPolyOrders()wrappers using the ClobClient methods. Add UI controls. - Add sell support to click-to-trade — Allow selling Polymarket positions (right-click, modifier-click, or toggle).
- Improve order refresh — Make post-placement order refresh reliable instead of best-effort silent catch.
- Test credential derivation —
createOrDerivePolyApiCreds()also calls the CLOB API and would have hit the same 403. Verify it works through the relay.
| File | Purpose |
|---|---|
apps/dashboard/src/components/nba-value-dashboard/ConsolidatedMoneylines.tsx |
Click handlers for odds cells (handleTrade, handlePolyTrade) |
apps/dashboard/src/components/nba-value-dashboard/PlaceOrderModal.tsx |
Order confirmation modal |
apps/dashboard/src/components/pages/ValueDashboardView.tsx |
Routes orders to correct venue handler |
apps/dashboard/src/App.tsx |
handlePlaceKalshiDashboardOrder, handlePlacePolymarketDashboardOrder |
apps/dashboard/src/lib/polymarket/client.ts |
placePolyOrder(), createPolyTradingClient(), config persistence |
apps/dashboard/src/lib/polymarket/userStream.ts |
WebSocket user order/trade events |
apps/dashboard/src/lib/polymarket/marketStream.ts |
WebSocket market book data |
apps/dashboard/src/lib/polymarket/gamma.ts |
Gamma API for market metadata |
apps/relay/src/httpRelay.ts |
HTTP relay (currently Kalshi-only) |