# Overview Kalshi and Polymarket US often list the **same real-world bet** — the same game, the same player's line — but word it differently and give it different ids. Layer tells you which Kalshi market and which Polymarket US market are the same bet, so your code can treat them as one. It also pairs Kalshi with polymarket.com, with Hyperliquid (games, season winners, crypto prices, IPOs), and with Novig for sports game lines. ## How Layer fits together You work with one SDK, and two kinds of call go through it: - **Hosted by Layer:** which markets are the same bet. `client.matches()` and `client.match()` ask Layer's [API](https://uselayer.sh/docs/api.md), which you can also call directly. - **On your machine:** everything that touches a venue. Prices, order books, paper trades and live orders go straight between your code and each venue, with your own keys. Layer never sees your venue keys, prices or orders. ## What you can do today **Hosted by Layer** - **List every live match** — `GET /v0/matches` returns pairs of markets, one from each venue, that pay out the same way. - **Look up one market** — `GET /v0/match` takes a Kalshi, Polymarket, Hyperliquid or Novig market id and returns its twin on the other venue, or, once a market has settled, how both venues settled it. `POST /v0/match` does the same for up to 50 markets in one call. - **See how sure Layer is** — every match carries a confidence and the rule differences between the venues (for example, different data sources). - **Check Layer's track record** — `GET /v0/settlements` lists how every match settled on both venues, with totals, and can be filtered to one kind of market. - **Get told when matches change** — webhooks send `match.live`, `match.pulled` and `match.settled` to your server. - **See how much you can trade** — `GET /v0/quote` takes a matched Kalshi market, reads both live order books and returns the most contracts that still clear your minimum edge after fees (Kalshi with Polymarket US or polymarket.com). - **Check a trade's profit and size** — `POST /v0/profit` and `POST /v0/size` work out both venues' fees, net profit, the size that still clears your minimum edge, and return per day, from prices and books you send, for Kalshi with either Polymarket. The same math runs on your machine in the SDK (`uselayer.calc`). **In your code, with the [Python SDK](https://uselayer.sh/docs/sdk.md)** - **Read live prices and order books** from Kalshi and Polymarket US, with your own keys. - **Price both sides of a match after fees** — `quote()` reads both live books. - **Paper trade and backtest** against real order books with simulated money. - **Trade on Kalshi and Polymarket US** — live orders, fills, positions and balances, with your own key on each venue, inside guardrails you set. **In a terminal** — the [`uselayer` command line](https://uselayer.sh/docs/uselayer.md) runs lookups and profit checks without code. **For AI agents** — every page here is also Markdown at its URL plus `.md` (this one: [/docs/index.md](https://uselayer.sh/docs/index.md)). [/llms.txt](https://uselayer.sh/llms.txt) lists them all, and [/llms-full.txt](https://uselayer.sh/llms-full.txt) is all of them in one file. ## What Layer does not do yet - **Trading through the API.** The API never places a real order. Orders go from the SDK on your machine to the venue, with your own key. - **Every market.** Only markets that exist on both venues and have passed Layer's checks are matched. ## Key ideas | Term | Meaning | |---|---| | **Market** | One yes/no contract on one venue, e.g. Kalshi's "Cut 25bps" or one side of a Polymarket game. | | **Event** | A group of markets about one thing, e.g. "Fed decision in October". | | **Match** | A Kalshi market and a Polymarket, Polymarket US, Hyperliquid or Novig market that pay out in the same situations. | | **Basis** | `identical`, or `equivalent_with_caveats` when the rules differ on an edge case. | | **Caveats** | Which rule differs: `source_differs`, `timing_differs`, `rounding_differs`, `carveout_differs`, `definition_differs`. | | **Caveat notes** | How it differs, quoting what each venue's rules say. The [SDK](https://uselayer.sh/docs/sdk.md#rule-flag-notes) writes them on your machine; the API sends the codes. | | **Tier** | Who approved the match: `human_verified` (a person) or `auto_verified` (Layer's graders). | ## Settlement policies A match means both markets pay out the same way **on the result**. When the event doesn't happen normally, each venue follows its own policy, and payouts can differ: - **Cancelled, postponed or abandoned events.** Kalshi usually settles at a fair price if a game doesn't start within its window; Polymarket may wait for the makeup game, resolve 50-50, or resolve No. - **Players who don't play.** Player props pay differently when the player is inactive or never takes the field. Polymarket usually resolves the market Under (so its "over" line pays nothing), while Kalshi usually settles it at a fair price from before the game. If you hold a player's line on both venues and the player sits out, the two positions don't cancel out. These policies apply to nearly every match, so Layer doesn't list them on each one. If you hold positions on both venues, a cancelled or postponed event can leave them paying differently. Read each venue's rules before trading. ## Next - [Quickstart](https://uselayer.sh/docs/quickstart.md) — get a key and make your first request. - [API reference](https://uselayer.sh/docs/api.md) — every endpoint and field. - [How matching works](https://uselayer.sh/docs/matching.md) — how Layer decides two markets are the same bet. - [Python SDK](https://uselayer.sh/docs/sdk.md) — prices, paper trading and orders on your machine. [Command line](https://uselayer.sh/docs/uselayer.md) — the terminal. --- # Quickstart Most people come to Layer for one of two jobs: 1. **You know what you want to trade, and want the best place to trade it.** 2. **You want to find where the same bet is cheaper on one venue than the other.** Layer tells you which Kalshi and Polymarket US markets are the same bet. The [Python SDK](https://uselayer.sh/docs/sdk.md) reads both venues' prices on your machine, with your own keys, and works out the fees. About five minutes to your first answer. ## See it running: Spread Spread is an example bot that does both jobs on your machine, in paper mode: real order books, fake money. Clone it, run it, and change it however you like. The code is on GitHub at [uselayer/layer-spread-bot](https://github.com/uselayer/layer-spread-bot). You need the Layer key and Kalshi key from steps 1 and 2. ![Spread's Best venue screen: $50 on YES for Banfield to win. Kalshi wins $225, Polymarket US wins $215, fees included, so Kalshi pays $10 more.](https://uselayer.sh/images/docs/spread-best-venue.png) *Example from Oct 5: $50 on Banfield to win. Kalshi pays $10 more if you win, fees included. On another bet it's Polymarket US.* ```bash git clone https://github.com/uselayer/layer-spread-bot cd layer-spread-bot npm run setup cp .env.example .env # add your keys npm run dev # then open http://127.0.0.1:3200 ``` How it works and how to add your own strategy: [Example bot: Spread](https://uselayer.sh/docs/spread.md). ## 1. Get a Layer key Go to [uselayer.sh](https://uselayer.sh) and click **Get API access**. Sign in with GitHub, Google, Discord, a wallet, or email. Onboarding asks you to accept the terms and what you're building, then creates your first key. It starts with `lyr_`. **Copy it right away** — it's shown once. Layer only stores a hash of it. You can create, name, revoke and rotate keys any time on your [dashboard](https://uselayer.sh/dashboard). ```bash export LAYER_API_KEY="lyr_your_key" ``` ## 2. Install the SDK and add your Kalshi key ```bash pip install uselayer ``` Python 3.11 or newer. Kalshi's order books are read with your own Kalshi API key: create one in your Kalshi account settings (Ed25519 and RSA keys both work). It stays on your machine. Polymarket US prices need no key. ```bash export KALSHI_KEY_ID="your_key_id" export KALSHI_PRIVATE_KEY_PATH="~/.kalshi/key.pem" ``` ## 3. Job 1: where should I trade this? You want YES on a market and you know its Kalshi ticker. Find its twin on Polymarket US, then price the same order on both venues, fees included. Paper mode reads the real books and never sends an order. ```python from uselayer import Client client = Client() # paper mode ticker = client.matches(venue="polymarket_us", limit=1)[0].kalshi.market_id # any live match; use your own ticker found = client.match(ticker, venue="kalshi", with_="polymarket_us") if not found["matched_market"]: raise SystemExit(found["reason"]) # e.g. no_candidate: no twin on Polymarket US twin = found["matched_market"]["market_id"] for venue, market in (("kalshi", ticker), ("polymarket_us", twin)): order = client.order(venue=venue, market=market, side="yes", price=0.60, size=100) p = client.preview(order) # sends nothing print(venue, p.est_fill.avg_price, p.fees) ``` The cheaper venue is the one where the average price times 100, plus fees, is lower. Before you trade on either, check the match's `caveats`: they say where the two venues' rules differ (`client.matches()` also gives `caveat_notes`, quoting both venues' rules). ## 4. Job 2: where is the same bet cheaper? Buying YES on one venue and NO on the other, for the same matched outcome, pays exactly $1 per contract however it settles. It's worth doing when the two prices plus both fees come to less than $1. `quote()` checks both ways round for each match: ```python for m in client.matches(venue="polymarket_us", q="nfl", limit=20): q = client.quote(m) if q.net_profit_per_contract > 0: print(m.kalshi.market_id, q.a.side, q.b.side, q.contracts, q.net_profit_per_contract) ``` Found one? `prices()` shows both books, and `profit()` prices it at the size you want. An empty side of a book is `None`, so check both asks first: ```python p = client.prices(m) k, u = p.leg("kalshi"), p.leg("polymarket_us") if k.yes_ask and u.no_ask: body = {"contracts": 100, "kalshi": {"price": k.yes_ask}, "polymarket_us": {"price": u.no_ask}} r = client.profit(body, pair=m) print(r["net_profit"], r.get("return_per_day_pct")) # return per day when a venue gives a time ``` A gap during a game can be a stale quote, and the best price may have only a few contracts behind it. `quote()` sizes to what both books can fill. ## 5. Practice, then trade `client.trade(m, size=100, min_edge=0.01)` places both legs with the leg-risk guard: if one side doesn't fill, the other is sold back. It runs in paper mode by default, and in live mode with your keys for both venues. See [Python SDK](https://uselayer.sh/docs/sdk.md) for live mode, guardrails and the kill switch. To practice a strategy on live markets first, try [Nemi](https://nemi.markets) ([how it works](https://uselayer.sh/docs/nemi.md)), our paper-trading experiment: it runs on Polymarket's BTC 15-minute markets against the real order books, with fake money. It's separate from Layer. ## Without the SDK The matches are a plain HTTP API, so any language works: ```bash curl -H "authorization: Bearer $LAYER_API_KEY" \ "https://uselayer.sh/v0/matches?venue=polymarket_us&q=nfl&limit=3" ``` `venue` is `polymarket_us` (the default), `polymarket` (polymarket.com), `hyperliquid` (games, season winners, crypto prices and IPOs) or `novig` (sports game lines and season futures). You get back pairs of markets, one on each venue, that pay out the same way: ```json { "count": 3, "matches": [ { "kalshi": { "venue": "kalshi", "market_id": "KXNFLGAME-26OCT04DALHOU-DAL", "url": "https://kalshi.com/markets/…", "…": "…" }, "polymarket_us": { "venue": "polymarket_us", "market_id": "nfl-dal-hou-2026-10-04:long", "url": "https://polymarket.us/…", "…": "…" }, "category": "sports", "confidence": 0.97, "basis": "identical", "caveats": [], "caveat_notes": {} } ] } ``` - **`confidence`** — how sure Layer is that the two markets are the same bet, from 0 to 1. - **`basis`** — `identical` if the rules match exactly, or `equivalent_with_caveats` if they pay out the same except in an edge case. - **`caveats`** — which edge cases differ, for example `source_differs`. The full list, and how each venue handles a cancelled or postponed event, are in the [Overview](https://uselayer.sh/docs/index.md). - **`caveat_notes`** — `{}` from the API: the notes quote the venues' rules, which stay inside Layer. The SDK writes them on your machine from the rules it reads. Read prices from each venue's own API with the market ids, and check a trade's profit with [`POST /v0/profit`](https://uselayer.sh/docs/api.md#post-v0-profit). The [`uselayer` command line](https://uselayer.sh/docs/uselayer.md) runs the same lookups and profit checks from a terminal. ## Limits - **60 requests per minute per Layer key.** Over that you get `429 rate_limited` with a `retry-after` header. Prices and orders go straight to each venue, so they don't count. - Keys can be sent as `authorization: Bearer ` or `x-api-key: `. ## Next - [Example bot: Spread](https://uselayer.sh/docs/spread.md) — both jobs in a bot you can clone and run. - [Signals](https://uselayer.sh/docs/signals.md) — Polymarket traders' track records, what they hold and every trade they make, built on the SDK. - [Nemi](https://nemi.markets) — our paper-trading experiment: run a strategy on live Polymarket BTC 15-minute markets against the real order books, with fake money. It's separate from Layer and reads Polymarket directly. - [Python SDK](https://uselayer.sh/docs/sdk.md) — live mode, guardrails, pairs and backtests. - [API reference](https://uselayer.sh/docs/api.md) — every endpoint, field and error. - [MCP server](https://uselayer.sh/docs/mcp.md) — matched markets, lookups and profit checks as tools in Claude Code, Cursor or any MCP client. - [How matching works](https://uselayer.sh/docs/matching.md) — why you can trust a match, and how Layer checks itself against real results. --- # Starter bot Each starter is one Python file, under 80 lines, that reads live markets with your Layer API key. None of them places an order. Pick the one closest to what you're building: | Starter | What it does | File | | --- | --- | --- | | **Price gaps** | The same market on Kalshi and Polymarket US side by side, biggest YES price gap first, refreshing every 30 seconds. | [`price_gaps.py`](https://uselayer.sh/starters/price_gaps.py) | | **Ask about markets** | A `find_markets` tool for your AI agent: a tool definition plus an MCP server, returning each venue's link and YES price. | [`ask_markets.py`](https://uselayer.sh/starters/ask_markets.py) | | **Matched markets to a spreadsheet** | Every market that's the same bet on both venues, written to `matched_markets.csv`. | [`matched_markets_csv.py`](https://uselayer.sh/starters/matched_markets_csv.py) | ## Run one yourself You need Python 3.11 or newer (or [uv](https://docs.astral.sh/uv/)) and a Layer API key from [your dashboard](https://uselayer.sh/dashboard/keys). ```bash mkdir layer-price-gaps && cd layer-price-gaps curl -fsSL https://uselayer.sh/starters/price_gaps.py -o price_gaps.py python3 -m venv .venv && .venv/bin/pip install uselayer export LAYER_API_KEY=lyr_... .venv/bin/python price_gaps.py ``` Swap in `ask_markets.py` or `matched_markets_csv.py` for the other two. Layer says which markets are the same bet. The prices come from each venue's public order book, read on your machine: no Kalshi or Polymarket account needed. A price gap is a starting point, not a profit. Check fees, how many shares are on offer, and the rule differences Layer lists for each match (`caveat_notes`, see [How matching works](https://uselayer.sh/docs/matching.md)). ## Price gaps ```python """Price gaps: the same market on Kalshi and Polymarket US, side by side, most you could save first. Layer says which markets are the same bet; this script reads both venues' order books on your machine and prints what YES costs on each. You need a Layer API key, nothing else. It only reads data, never orders. pip install uselayer export LAYER_API_KEY=lyr_... # from uselayer.sh/dashboard/keys python price_gaps.py # refreshes every 30 seconds; Ctrl+C to stop python price_gaps.py --once # print the table once and exit python price_gaps.py --include-started # also list games already under way (their prices go stale) """ import sys import time from datetime import datetime, timezone from uselayer import Client from uselayer.http import Http from uselayer.venues.kalshi import KalshiMarkets from uselayer.venues.polymarket_us import PolymarketUSPublic MATCHES = 20 # how many matched markets to check each round REFRESH_SECONDS = 30 INCLUDE_STARTED = "--include-started" in sys.argv client = Client() # reads LAYER_API_KEY; paper mode, so nothing is ever traded kalshi, polymarket_us = KalshiMarkets(Http()), PolymarketUSPublic(Http()) # public order books, no venue keys def yes_asks(m): """The cheapest YES on offer on each venue (price and shares), read from the venues on your machine.""" k = kalshi.read_book(m.kalshi.market_id).book.outcome("yes").best_ask u = polymarket_us.book(m.polymarket_us.market_id).outcome("yes").best_ask return k, u # None: nobody is selling YES there def started(m): """True once the game has begun: one venue's price is often stale then, so its gap isn't real.""" t = m.kalshi.event_time or m.polymarket_us.event_time return bool(t) and datetime.fromisoformat(t) <= datetime.now(timezone.utc) def cents(price): return " -- " if price is None else f"{price * 100:4.0f}¢" def gaps(): """One row per upcoming match, soonest first (games under way only with --include-started).""" rows = [] for m in [m for m in client.matches(limit=MATCHES * 3) if INCLUDE_STARTED or not started(m)][:MATCHES]: try: k, u = yes_asks(m) except Exception as err: # one venue not answering shouldn't stop the table print(f"skipped {m.kalshi.market_id}: {err}", file=sys.stderr) continue gap = abs(k.price - u.price) if k and u else None shares = min(k.size, u.size) if k and u else None # a big gap on a few shares isn't worth much save = gap * shares if gap is not None and shares is not None else None # dollars, before fees rows.append((save, gap, m, k and k.price, u and u.price, shares)) # Most you could save first; markets missing a price on one side go last. rows.sort(key=lambda r: -1 if r[0] is None else r[0], reverse=True) return rows def show(rows): print(f"\n{time.strftime('%H:%M:%S')} YES price on each venue (cost to buy one share)") print(f"{'save':>6} {'gap':>5} {'Kalshi':>6} {'Poly US':>7} {'shares':>6} market") for save, gap, m, kalshi, poly, shares in rows: name = ("[started] " if started(m) else "") + f"{m.kalshi.event or m.kalshi.market_id} · {m.kalshi.outcome or ''}" size = " --" if shares is None else f"{shares:6.0f}" dollars = " --" if save is None else f"${save:5.2f}" print(f"{dollars} {cents(gap)} {cents(kalshi):>6} {cents(poly):>7} {size} {name[:56]}") print("shares: shares on offer at both prices. save: gap x shares, before fees.") if __name__ == "__main__": while True: show(gaps()) if "--once" in sys.argv: break time.sleep(REFRESH_SECONDS) ``` ## Ask about markets Run it once to see what your agent gets back: ```bash python ask_markets.py nba ``` To give it to Claude Code as a tool, install `mcp` in the same environment and add it as an MCP server: ```bash .venv/bin/pip install mcp claude mcp add layer-starter -e LAYER_API_KEY=lyr_... -- "$PWD/.venv/bin/python" "$PWD/ask_markets.py" --mcp ``` This runs on your machine, so you can change what the tool does. If you only want the tools, Layer's hosted [MCP server](https://uselayer.sh/docs/mcp.md) adds matched markets, lookups and profit checks with one command and no Python. For other agents, pass the file's `TOOL` definition to your model (it's in the Claude API's shape; for OpenAI, rename `input_schema` to `parameters`) and call `find_markets` when the model asks for it. ```python """Ask about markets: a tool your AI agent can call to find a market on Kalshi and Polymarket US. It searches Layer's matched markets ("fed", "chiefs", "bitcoin"), then reads both venues' YES prices on your machine. It only reads data: it never places an order. pip install uselayer export LAYER_API_KEY=lyr_... # from uselayer.sh/dashboard/keys python ask_markets.py chiefs # run the tool once and print what an agent would get python ask_markets.py --mcp # serve it over MCP (needs: pip install mcp), e.g. claude mcp add layer -- python /full/path/to/ask_markets.py --mcp """ import json import sys from uselayer import Client from uselayer.http import Http from uselayer.venues.kalshi import KalshiMarkets from uselayer.venues.polymarket_us import PolymarketUSPublic # The tool definition, in the shape the Claude API takes. For OpenAI, rename input_schema to parameters. TOOL = { "name": "find_markets", "description": "Find prediction markets that are the same bet on Kalshi and Polymarket US. " "Returns each venue's market, link and current YES price, plus any rule differences.", "input_schema": { "type": "object", "properties": { "query": {"type": "string", "description": "A few words, e.g. 'fed rates' or 'chiefs'"}, "limit": {"type": "integer", "description": "How many matches to return, 1 to 10", "default": 5}, }, "required": ["query"], }, } client = Client() # reads LAYER_API_KEY; paper mode, so nothing is ever traded kalshi, polymarket_us = KalshiMarkets(Http()), PolymarketUSPublic(Http()) # public order books, no venue keys def yes_prices(m): """What one YES share costs right now on each venue, read from the venues on your machine.""" k = kalshi.read_book(m.kalshi.market_id).book.outcome("yes").best_ask u = polymarket_us.book(m.polymarket_us.market_id).outcome("yes").best_ask return (k.price if k else None), (u.price if u else None) # None: nobody is selling YES there def find_markets(query: str, limit: int = 5) -> list[dict]: """Find prediction markets that are the same bet on Kalshi and Polymarket US, with YES prices.""" results = [] for m in client.matches(q=query, limit=max(1, min(limit, 10))): try: k, u = yes_prices(m) yes = {"kalshi": k, "polymarket_us": u} except Exception: # a venue that doesn't answer leaves its price out yes = {"kalshi": None, "polymarket_us": None} results.append({ "event": m.kalshi.event, "outcome": m.kalshi.outcome, "event_date": m.event_date, "kalshi": {"market_id": m.kalshi.market_id, "url": m.kalshi.url, "yes_price": yes["kalshi"]}, "polymarket_us": {"market_id": m.polymarket_us.market_id, "url": m.polymarket_us.url, "yes_price": yes["polymarket_us"]}, "rule_differences": m.caveat_notes, # where the two venues' rules don't say the same thing }) return results if __name__ == "__main__": if "--mcp" in sys.argv: from mcp.server.fastmcp import FastMCP server = FastMCP("layer") server.tool()(find_markets) server.run() # talks to the agent over stdin/stdout else: query = " ".join(a for a in sys.argv[1:]) or "fed" print("Tool definition:\n" + json.dumps(TOOL, indent=2)) print(f"\nfind_markets({query!r}) returned:\n" + json.dumps(find_markets(query), indent=2)) ``` ## Matched markets to a spreadsheet `--prices` adds each venue's YES price (two order books per row, so it's slower). ```python """Matched markets to a spreadsheet: every market that's the same bet on Kalshi and Polymarket US, as a CSV. Open the file in Excel, Numbers or Google Sheets. Add --prices to also read each market's YES price from both venues (slower: two order books per row). It only reads data: it never places an order. pip install uselayer export LAYER_API_KEY=lyr_... # from uselayer.sh/dashboard/keys python matched_markets_csv.py # writes matched_markets.csv python matched_markets_csv.py --prices # same, with YES prices """ import csv import sys from uselayer import Client from uselayer.http import Http from uselayer.venues.kalshi import KalshiMarkets from uselayer.venues.polymarket_us import PolymarketUSPublic MAX_ROWS = 1000 # stop after this many matches PAGE = 200 # Layer returns at most 200 matches per request OUT = "matched_markets.csv" client = Client() # reads LAYER_API_KEY; paper mode, so nothing is ever traded kalshi, polymarket_us = KalshiMarkets(Http()), PolymarketUSPublic(Http()) # public order books, no venue keys def yes_prices(m): """What one YES share costs right now on each venue, read from the venues on your machine.""" k = kalshi.read_book(m.kalshi.market_id).book.outcome("yes").best_ask u = polymarket_us.book(m.polymarket_us.market_id).outcome("yes").best_ask return (k.price if k else None), (u.price if u else None) # None: nobody is selling YES there with_prices = "--prices" in sys.argv def all_matches(): """Page through Layer's matches, soonest event first.""" offset = 0 while offset < MAX_ROWS: page = client.matches(limit=PAGE, offset=offset) yield from page if len(page) < PAGE: return offset += PAGE def row(m): k, u = m.kalshi, m.polymarket_us r = { "event_date": m.event_date, "category": m.category, "kalshi_event": k.event, "kalshi_outcome": k.outcome, "kalshi_market_id": k.market_id, "kalshi_url": k.url, "polymarket_us_event": u.event, "polymarket_us_outcome": u.outcome, "polymarket_us_market_id": u.market_id, "polymarket_us_url": u.url, "confidence": m.confidence, "rule_differences": " | ".join(m.caveat_notes.values()), # where the venues' rules differ } if with_prices: try: r["kalshi_yes_price"], r["polymarket_us_yes_price"] = yes_prices(m) except Exception: # a venue that doesn't answer leaves the price blank r["kalshi_yes_price"] = r["polymarket_us_yes_price"] = None return r if __name__ == "__main__": rows = [row(m) for m in all_matches()] if not rows: sys.exit("No matches came back. Check LAYER_API_KEY.") with open(OUT, "w", newline="") as f: writer = csv.DictWriter(f, fieldnames=list(rows[0])) writer.writeheader() writer.writerows(rows) print(f"Wrote {len(rows)} matched markets to {OUT}") for r in rows[:5]: print(f" {r['event_date']} {r['kalshi_event']} · {r['kalshi_outcome']}") ``` --- # Example bot: Spread Spread is an example bot built on the [Python SDK](https://uselayer.sh/docs/sdk.md). Clone it, run it on your machine, and change it however you like. The code is on GitHub at [uselayer/layer-spread-bot](https://github.com/uselayer/layer-spread-bot), under the MIT license. It works across **Kalshi** and **Polymarket US**, and starts in paper mode: real order books, fake money. ## What it does ![Spread's Best venue screen: $50 on YES for Banfield to win. Kalshi wins $225, Polymarket US wins $215, fees included, so Kalshi pays $10 more.](https://uselayer.sh/images/docs/spread-best-venue.png) *Example from Oct 5: $50 on Banfield to win. Kalshi pays $10 more if you win, fees included. On another bet it's Polymarket US.* Spread does the two jobs from the [Quickstart](https://uselayer.sh/docs/quickstart.md), each on its own screen: - **Best venue.** You, your strategy, or "Every market" decides the trade, for example "$50 on YES for this outcome". Spread prices it on both venues from their live order books: how many contracts the amount buys on each, at what average price and fees, and what each pays if you win. Then it shows which venue pays more for the same money, after fees, and lets you test the order there with fake money. It never sends a real order. - **Arbitrage.** Spread scans markets that are the same bet on both venues and looks for price gaps that are still money after fees, rules and depth. Most gaps aren't. Spread shows why each one was dropped, and lets you paper-trade the ones that survive. ## What runs where | Piece | Where it runs | |---|---| | Which markets are the same bet, and where their rules differ | Layer's hosted matching ([`GET /v0/matches`](https://uselayer.sh/docs/api.md#get-v0-matches)), called by the SDK with your Layer key | | Prices, order books, fees, quotes, orders and paper fills | The SDK, on your machine, talking to each venue with your own keys | | The engine: a small local service that runs the SDK for the app | Your machine, `127.0.0.1:8765`. It answers only this machine. | | The app: the two screens | Your machine, `127.0.0.1:3200` | Nothing is hosted and nothing is deployed. Layer gets your Layer key, the market ids it gave you, and search words. It never sees prices, orders, positions or venue keys. ## Set up You need [Node 20+](https://nodejs.org) and [uv](https://docs.astral.sh/uv/). uv fetches Python 3.11+ if you don't have it. ```bash git clone https://github.com/uselayer/layer-spread-bot cd layer-spread-bot npm run setup # the app's packages and the engine's Python packages cp .env.example .env # then add your keys npm run dev # starts the engine and the app together ``` Open http://127.0.0.1:3200. Ports in use? `ENGINE_PORT=8775 WEB_PORT=3210 npm run dev`. Keys in `.env`: | Key | What for | |---|---| | `LAYER_API_KEY` | Which markets are the same bet. Get one at [uselayer.sh](https://uselayer.sh). | | `KALSHI_KEY_ID`, `KALSHI_PRIVATE_KEY_PATH` | Reading Kalshi's order books. A read-only key is enough for paper mode. | | `POLYMARKET_US_KEY_ID`, `POLYMARKET_US_SECRET_KEY` | Live orders, and **Save prices** for Replay (its live stream is read with your key; nothing is traded). Otherwise paper mode reads Polymarket US's public books without a key. | `.env` is git-ignored. Each key goes only to its own service, and none is logged, shown in the app, or sent to Layer. ## Try it in paper mode Everything here uses real order books and fake money. - **Best venue → Search a game.** Type a team or league in the Game box and pick a game, then an outcome, YES or NO and an amount in dollars, and click **Compare venues**. You see what that amount wins on each venue if you're right, fees included, and which one pays more. Each venue gets its own size: the most whole contracts the amount buys there. **Test this order (fake money)** then sends it to the venue that wins more, as a test: it fills against that venue's real order book with fake money and tells you what you got ("Filled 69 YES on Kalshi at 70¢ average: $49.32, fees included"). It never sends a real order, whatever `BOT_MODE` says. In your own bot, the uselayer SDK does the same routing for real in one call: `client.buy_best(pair, "yes", spend=50)`. - **Best venue → Every market.** Pick a group of markets and click **Check every market**. Each of the next 25 markets is compared for $50 on YES, the biggest difference in what you'd win first. ![Spread's Every market screen: the next 25 sports markets, each with both venues' prices and which one pays more for $50 on YES. Kalshi pays more on 5, Polymarket US on 7, the same on 9.](https://uselayer.sh/images/docs/spread-every-market.png) *Example from Oct 5: the next 25 sports markets, and which venue pays more for $50 on YES.* - **Arbitrage → Live.** Click **Scan** (about two minutes for 50 markets). The answer comes first: how many gaps are still money after fees, or why none is. If a gap is still profit after fees, click **Paper-trade both sides** to buy YES on one venue and NO on the other with fake money. - **Arbitrage → Replay.** Pick a game that's on now and click **Save prices**. When it's done, click **Replay** to see whether a gap would have made money after fees. - **Paper account.** After a paper trade, a line at the top of the Arbitrage screen shows what's open. **Reset** starts the fake account over. ## How the arbitrage scan works Buying YES on one venue and NO on the other pays $1 a contract either way, so a price gap looks like free money. Every matched market goes through the same checks and stops at the first one it fails: 1. **Couldn't be priced.** A venue didn't answer, asked for fewer requests, or its key is missing. 2. **Nobody selling.** One venue has nobody selling one side. 3. **No gap.** YES on one venue plus NO on the other costs $1.00 or more before fees. 4. **Fees bigger than the gap.** There's a gap, but the two venues' fees are bigger. 5. **Under your minimum.** Something is left after fees, but less than your minimum a contract. 6. **Too little for sale.** The books can't fill even one contract that clears your minimum. 7. **Pays back too slowly.** The return per day is under your minimum. A 2% gap paid in a month is worse than 1% paid tomorrow. A gap that survives shows both venues side by side, then gross gap → fees → net → return per day. Every number comes from the SDK's `client.quote()`. **Paper-trade both sides** calls `client.trade()`, which reads both books again and buys only if the gap is still there after fees. **Rules differ slightly.** Layer flags a match whose two markets differ on an edge case: a different data source, deadline, rounding, exception or definition. Spread doesn't drop it. It goes through the same checks and carries a warning: both pay the same in normal cases, but in a rare case one could pay and the other not. These are listed below the gaps whose rules match. Expect most scans to end with no gap. When nothing is still money after fees, the right move is no trade. ### Replay Replay re-runs prices you saved from a live game, moment by moment, to show whether a gap would have made money after fees. It runs the saved order books through the same checks, in the SDK's backtest mode. Nothing comes pre-saved: the venues' terms don't let Spread ship their prices, so you save your own. On **Arbitrage → Replay**: 1. Under **Save prices**, pick a game (games on now are listed first, since that's when prices move) and how long to save (30 minutes unless you pick otherwise). 2. Click **Save prices**. Both venues' books for that game are saved while it trades, every change. You see the time left and how many price updates are saved so far. **Stop** ends it early and keeps what's saved. 3. When it ends, the new file is selected under **Saved prices**. Click **Replay**. The answer says how many moments were still money after fees and how long the gap lasted. Replays use each venue's fees as they were at the time. Saving prices needs your Kalshi key and your Polymarket US key: each venue's live stream is read with your own key, and nothing is traded. Advanced: the same recording from a terminal, for any Kalshi ticker Layer has matched: ```bash npm run record -- KXNBAGAME-26OCT05MEMATL-ATL --minutes 60 ``` ## Add your own strategy Your strategy decides the trade; Spread finds the cheaper venue for it. Each strategy is one Python file in `engine/spread_engine/strategies/`, and the engine lists every file there. Spread comes with examples for sports, crypto, and news, politics and economics. They're examples, not advice. 1. Copy the starter file: `cp engine/spread_engine/strategies/my_strategy.py engine/spread_engine/strategies/momentum.py`. 2. Give it a `NAME`, a `CATEGORY` and a one-line `DESCRIPTION`, and write `decide()`: which market, YES or NO, how much in dollars, and the most you'll pay a contract. `Signal(m, "yes", 50)` is $50 on YES; the SDK works out how many contracts that buys on each venue. 3. Run it against the engine: `curl "http://127.0.0.1:8765/best/signal?strategy=momentum"`. Edits apply on the next run, without a restart. ```python from . import SPORTS, NoTrade, Signal, priced NAME = "Momentum: buy YES under 40¢" CATEGORY = SPORTS DESCRIPTION = "Puts $50 on YES when it's under 40¢ on both venues." def decide(matches, client): for m, p in priced(matches, client): # open and worded the same on both venues, with both prices if p.a.yes_ask < 0.40 and p.b.yes_ask < 0.40: return Signal(m, "yes", 50, max_price=0.40, why="YES is under 40¢ on both venues.") # $50 on YES return NoTrade("Nothing is under 40¢ on both venues right now.") ``` `client` is the SDK with your keys, so a strategy can read anything, for example `client.prices(m)` or `client.book(m.kalshi)`. The helpers are in [the README](https://github.com/uselayer/layer-spread-bot#add-your-own). ## Paper and live - **Paper (the default).** Orders fill against the venues' real order books with fake money, in a local store on your machine. Nothing reaches a venue. A paper order reaches the book 0.7 seconds after it's sent, about how long a real Polymarket US order took, and fills against the book it meets then (`BOT_ORDER_LATENCY_S` changes it; `0` fills instantly). Spread never leaves an order waiting at a price. If you rest one with the SDK, paper mode's idea of your place in line is an estimate: the venues publish the total waiting at each price, not single orders. - **Live.** Set `BOT_MODE=live` in `.env`, add the key for each venue you'll trade, and restart. Orders are then real, with your money, and the header shows a red LIVE badge. Only the exact word `live` turns it on. Live or paper, every order goes through the SDK's guardrails: a budget across everything (`BOT_BUDGET`, $100 by default), a price collar, and a kill switch. Each order has a limit price and is immediate-or-cancel: it fills what's there at that price, and the rest is cancelled. A gap can still close before both orders fill, and the venues can settle the same event differently. See [Python SDK](https://uselayer.sh/docs/sdk.md) for guardrails and the kill switch. ## Next - [Spread on GitHub](https://github.com/uselayer/layer-spread-bot): the full README, tests and every strategy. - [Python SDK](https://uselayer.sh/docs/sdk.md): the calls Spread is built on. - [How matching works](https://uselayer.sh/docs/matching.md): why a match is the same bet. - [Example: Sports replay](https://uselayer.sh/docs/sports-replay.md): 38 recorded games, every gap after fees and which venue moved first. --- # Example: Sports replay The same game trades on **Kalshi** and **Polymarket US** at once, and the two prices should match. The sports replay plays back real recorded games and shows the moments they don't: when both sides could be bought for under $1 after both venues' fees, and which venue reacted to a play first.

Open the replay →

*Miami vs Notre Dame, Nov 7. At 7:36 PM ET Kalshi dropped on a play before Polymarket US did, and for 71 ms both sides cost 98.6¢ a contract, fees in.* ## What it shows - **The game chart.** Both venues' price for one team to win, for the whole game, on one clock. Blue is Kalshi, orange is Polymarket US. - **Green dots: arbitrage gaps.** Moments when buying one team on one venue and the other team on the other cost under the $1 a contract that pays out, after both venues' taker fees, at the depth on the books. - **Inside a gap.** Click a dot to zoom into the milliseconds around it: one venue moves first, the other a beat behind, and the cost of both sides dips under $1.00. - **Play.** Replays a whole game in about 2 minutes. ## What we found 38 games on the night of Nov 7, 2026, mostly college football, game winners only: | | | |---|---| | Times a gap opened, after fees | 303 | | How long a gap stayed open (median) | 63 ms | | Gaps before a game started | 0: they only happened during play | | Big price moves where Kalshi moved first | 858 | | Time for Polymarket US to follow (median) | 209 ms | The gaps are real but brief. Kalshi usually reacts to a play first, and Polymarket US catches up in a fraction of a second. A bot only gets to act on one if it sees every price change as it happens. ## Is the data real? Yes. Layer's recorders save every order-book change on both venues, around the clock, and nothing here is simulated. Two things are worked out rather than observed: - **Profits are on paper.** No trades were placed. A dollar figure means the books showed that much at those prices, after fees. Whether an order would have filled is unknown. - **Times are when Layer received each change.** The two recorders' clocks can differ by 20–100 ms. ## How it was made 1. **Record.** Layer's recorders save every order-book change from Kalshi and Polymarket US. 2. **Replay.** For one night of games, both venues' books are rebuilt for every game Layer has matched across them. At every change, the replay checks whether both sides can be bought for under $1 after fees, for how many contracts and for how long, and which venue moved first. 3. **Show.** The results are small files the page reads and draws in your browser. There's no server behind it. ## Build on it - [`GET /v0/sports/spreads`](https://uselayer.sh/docs/api.md#get-v0-sports-spreads): the spread on every NBA, NFL, NHL and college football game that's live or starts in the next 24 hours, priced after both venues' fees. - [Layer Sports](https://uselayer.sh/sports): the live spreads and who leads whom. - [Example bot: Spread](https://uselayer.sh/docs/spread.md): a bot you can clone that compares venues and scans for gaps. --- # Signals [Signals](https://usesignals.app?utm_source=uselayer_docs) lets you follow the best traders on Polymarket. See their track record, what they hold now and every trade they make, and get a Telegram alert after they trade, with what buying the same thing would cost you right now.

Try Signals free →

Signals places no orders. You buy on the exchange yourself. *The Watchlist, recorded from live Polymarket data on Oct 22: follow a trader, see every change they make, and get an alert after they trade.* ## What's in it - **Traders.** A leaderboard of every Polymarket trader with a buy in the last 30 days and a few positions closed. It shows what they did, never a verdict: no tiers and no "recommended". Sort by trading profit, profit per $ traded, win rate, volume or most weeks up, over 7 days, 30 days, 90 days or all time, and filter by Politics, Sports, Crypto or Esports. Accounts with under 4 weeks of history are hidden unless you turn on **Show new accounts**. Search finds any trader by Polymarket username or wallet. - **A trader's profile.** Profit taken and profit still open, after fees, and their wallet (cash plus open positions). Their win rate (positions closed in profit) next to the average price they paid, since a trader who pays 90¢ wins most of the time by buying alone. Their usual trade size, a profit chart with their worst drop, and profit by category. Two tabs: **Positions** (what they put in and what it's worth now) and **Recent trades**. - **Watchlist.** Follow up to 20 traders. Each one's watch page shows what they hold and every change over the last 30 days (a new position, more bought, some sold, all sold), with the facts behind each one: their position now, the size against their usual trade, their record in that category and the price now. Anyone can open a watch page, with no account needed. - **Feed.** **Following** is every trade from the traders you follow over the last 24 hours. **Top traders** is trades of $1,000 or more from the traders with the most trading profit over the last 30 days. - **[Breakdowns](https://uselayer.sh/docs/breakdowns.md).** Paste a wallet, ask in your own words, and an AI agent lays every trade out on a chart you can replay. BTC 15-minute markets for now. - **Alerts on Telegram.** A message after a trader you follow buys or sells. You get every trade by default; for each trader you can choose only trades over an amount, or one summary every 6 hours. Following a very busy trader says so first. At most 200 alerts a day. ![The Traders leaderboard in Signals: weeks up, trading profit, trend, win rate and usual trade for each trader, over 30 days. Trader names are blurred.](https://uselayer.sh/images/docs/signals-leaderboard.png) *Traders on Oct 27, sorted by trading profit over 30 days. Trader names are blurred here; the app shows them.* ![A trader's profile in Signals: their numbers, a profit chart with their worst drop, their record and profit by category. The trader's name is blurred.](https://uselayer.sh/images/docs/signals-profile.png) *A trader's profile: profit taken and still open, their record, and where their profit came from.* ![A trader's watch page in Signals: what they hold, every change they made, and the facts behind the one selected. The trader's name is blurred.](https://uselayer.sh/images/docs/signals-watch.png) *A watch page: every change they make, newest first, and the facts behind the one you pick.* ## What buying now costs Every trade has **Check your price**. Pick an amount, and Signals prices it from the order book right now: what it fills at (up to 3¢ above the cheapest price for sale), the fee, what's for sale within that limit, and what you get if it wins. The trader's own price is shown for context only. It's an estimate, not a guaranteed fill. The traders Signals follows trade on Polymarket International. When Layer has a verified match for the market on **Kalshi**, the estimate is priced on Kalshi, **Buy on Kalshi** comes first, and every way the two markets may pay out differently is listed. When Layer has a verified match on **Polymarket US**, the trade links to it too. Alerts carry the same links. ## Good to know - **Polymarket International only.** Its trades are public. Signals doesn't see trades on Polymarket US or Kalshi, so a trader's price isn't one to expect there. - **A record is the past.** Win rate, streaks and profit say what a trader did, not what they'll do next. We haven't found that buying after a trader makes money. - **The AI-written note says only what the facts say.** On a watch page, one sentence written by AI can join the facts that stand out, and it's labelled. Every figure comes from Signals, not the AI; a sentence with a number the facts don't have isn't shown, and when nothing stands out there's no sentence. - **How fresh.** Traders you follow are read every 30 seconds. Big trades come from Polymarket's feed of every trade, which runs a few minutes behind. - **Names, never wallets.** A trader without a Polymarket username shows as "Trader 0x91…76". ## Signals is open source The code is on GitHub at [uselayer/signals](https://github.com/uselayer/signals), under the MIT license. Want to build your own version? The [README](https://github.com/uselayer/signals#readme) shows how to run it. --- # Breakdowns [Breakdowns](https://usesignals.app/breakdowns?utm_source=uselayer_docs) is a research agent inside [Signals](https://uselayer.sh/docs/signals.md). Paste a wallet or name a trader, then ask in your own words. The agent shows its plan before it reads anything, then lays every trade out on a chart you can replay.

Watch a sample breakdown →

The sample needs no account. Sign in to ask your own. *A real breakdown from Nov 10, sped up: one wallet, six BTC 15-minute windows, about 3 minutes of research.* ## How it works 1. **Ask.** A wallet address or Polymarket name, and a question: "How does this wallet make money on BTC 15-minute markets?", "Replay its latest window", "Compare its winning and losing windows". 2. **See the plan.** The agent says what it will read and what that costs before it runs. Nothing starts until you press Start. 3. **Replay every window.** Each 15-minute window is a tile: the BTC price from its start, with the wallet's fills along the bottom (Up above, Down below). Open one to step through it, with the Up and Down prices, the price to beat and each fill. 4. **Read the findings.** Each one is marked **Observed** (what the data shows, with its numbers) or **Inferred** (a reading of why, said as a reading). Every number is checked against the data before it's shown; one that can't be checked is marked unverified, never hidden. Your breakdowns are saved, and only you can see them. Ask a follow-up, or click anything in a breakdown to ask about it. ## Built on Layer - **Order books and Chainlink come from Layer's recordings.** Layer records Polymarket's order books and the Chainlink BTC/USD price these markets settle on. Chainlink is recorded from Nov 10, 05:27 UTC; windows before that show only its start and result. - **The wallet's trades come from the [Python SDK](https://uselayer.sh/docs/sdk.md).** `client.whales.trades_between` reads every trade a wallet made in a time range, and `client.whales.price_history` reads Polymarket's once-a-minute prices where no order book was recorded. ## Good to know - **BTC 15-minute markets on Polymarket only**, for now. - **Research, no trading.** Breakdowns says what a wallet did and, marked as a reading, why it might have. It gives no advice and places no orders. - **Prices by their names.** Chainlink BTC/USD is what the market settles on. Binance BTCUSDT is BTC in Tether, about 0.1% from Chainlink, and is shown for moves only. - **Gaps are said plainly.** When a window has no recorded order book, or Polymarket couldn't list every trade, the breakdown says so in one sentence. --- # Trader data Every trade on Polymarket is public, so you can see what any trader has done: how much they've made, how often they win, what they hold right now and every trade they make. [Signals](https://usesignals.app?utm_source=uselayer_docs) puts it all on one page per trader, and tells you on Telegram after a trader you follow trades.

Track a trader on Signals →

![A trader's page in Signals: what they hold, their win rate and the price they pay, their usual trade, their profit, their best category, and every trade they make with the facts behind it. The trader's name is blurred.](https://uselayer.sh/images/docs/trader-data-watch.png) *One trader's page on Signals, Nov 5: what they hold, how often they win and what they pay, and every trade they make, explained. The name is blurred here; the app shows it.* ## What you can see about a trader - **Profit.** What they've made from trading, after fees. Profit they've taken is kept apart from profit still open in positions they hold. - **Win rate.** How many of their closed positions made money, for example 440 of 1,093. - **What they pay.** The average price of what they buy. Read it next to the win rate (see the example below). - **Weeks up.** How many of the last 12 weeks they ended in profit. Steady beats one lucky week. - **Usual trade.** How much they usually put into a trade, so you can tell a big move from a normal one. - **Where their profit comes from.** Their profit by category: sports, politics, crypto and so on. - **Worst drop.** The biggest fall in their profit from a high, so you see the bad stretches too. - **What they hold now.** Every open position: what they put in and what it's worth now. - **Every trade.** Each buy and sell, newest first, with the price they paid. ![A trader's profile in Signals: profit taken in the last 30 days, open profit, their wallet, and a profit chart with their worst drop. The trader's name is blurred.](https://uselayer.sh/images/docs/trader-data-profile.png) *Their profit over 30 days, with the worst drop marked.* ![A trader's record in Signals: win rate, average price paid, dollars traded, profit per dollar traded, weeks up, and profit by category.](https://uselayer.sh/images/docs/trader-data-record.png) *Their record: win rate next to the price they pay, weeks up, and where their profit comes from.* ## Reading the numbers: an example Say a trader shows a **44% win rate** and **pays 22¢ on average**. A 44% win rate sounds poor until you see the price. A 22¢ buy pays $1 if it wins, so they only need to win a little more than 1 time in 5 to come out ahead. Winning 44% of the time at that price is very good. The other way round: a trader who wins 90% of the time but pays 90¢ is only breaking even. Most of their wins come from buying near-sure things. That's why Signals always shows the win rate next to the price paid. ## Follow a trader 1. **Find one.** Browse the [Traders](https://usesignals.app/?utm_source=uselayer_docs#/traders) list, sorted by profit, win rate or weeks up, or search by Polymarket username. 2. **Follow them.** Tap **Follow** on their page. It opens Telegram, with no account needed, and you get a message after they buy or sell. You can follow up to 20 traders. ![The Traders list in Signals, filtered to sports: each trader's weeks up, trading profit, trend, win rate with the price they pay, and usual trade. Trader names are blurred.](https://uselayer.sh/images/docs/trader-data-leaderboard.png) *The Traders list, sports, 30 days. Names are blurred here; the app shows them.* Signals is free and places no orders. You buy on the exchange yourself. ## Good to know - **Polymarket International.** Its trades are public, so that's where the traders come from. - **A record is the past.** Profit and win rate say what a trader did, not what they'll do next. ## For developers Building with this data? The [developer guide on GitHub](https://github.com/uselayer/uselayer-sdk/blob/main/python/trader-data.md) covers every call in the Python SDK. --- # Nemi [Nemi](https://nemi.markets) lets you test a trading strategy with paper money on real Polymarket prices. Sign up, pick a strategy, and it trades on its own: every order is filled against Polymarket's live order book, with Polymarket's fee, and paid out on Polymarket's own result. Nothing reaches Polymarket, and no real money is involved. Nemi is an experiment, and it's separate from Layer: it reads Polymarket directly and doesn't use Layer's API or the SDK. For paper trading with the SDK, see [Sandbox](https://uselayer.sh/docs/sandbox.md). ![Nemi's home page: two strategies on paper, entry-bands and vwap-rider, each trading Polymarket's Bitcoin 15-minute markets, with total profit after fees, each one's status, profit, trades and win rate.](https://uselayer.sh/images/docs/nemi-home.png) *Two strategies on paper, Oct 27, on live Polymarket prices: +$223 after fees between them, counting the position vwap-rider still holds. vwap-rider's orders are bigger than its $1,000 wallet, so most are refused, and Nemi says so.* ## What it covers today - **Polymarket's Bitcoin 15-minute markets only.** Each one asks whether Bitcoin's price ends the 15 minutes up or down. Other venues and markets are greyed out for now. - **Paper money only.** Each strategy you make gets its own paper wallet: you pick its starting money, from $1 to $10,000, and you can make up to 5. - **Strategies from templates.** Start from one (Entry Bands, VWAP Rider, or a plain example), give it a starting balance, and it trades by itself, checking the market every 5 to 10 seconds, around the clock. For each strategy you see its positions, every order with its fill or why it was refused, its settlements, its profit and loss after fees, and a report worked out from its orders. A status says whether it's running, idle, starved (it keeps placing orders but nothing has filled for an hour), stalled or errored, since when, and why. ## How a paper order fills 1. **It waits half a second**, about how long a real order takes to reach Polymarket. 2. **It takes shares from the live book, best price first**, the way a market order does. A limit price is the worst it accepts; nothing rests on the book. 3. **It pays Polymarket's taker fee** on every level it takes. An order is refused, and the page says why, when the market has closed, the book is empty, the best price is past its limit, it's under Polymarket's minimum size, the wallet can't cover it, or Nemi's copy of the book is out of sync. If the book runs out first, the order fills in part. ## How a window settles Polymarket resolves each window automatically, about 80 seconds after it closes, and Nemi pays $1 a winning share on that result. Each settlement also shows the prices that explain it: the start price, and Chainlink's 60-second average and last price at the close. It says when the side that led with 90 seconds left lost. ## Good to know - **Your orders don't move the book.** Shares a paper order takes are still there for the next one, so a big order fills as if no one else were trading, and two strategies can take the same shares. - **Market orders only.** There are no resting (maker) orders, so no maker fills or rebates. - **Open positions are valued at the price you could sell at now**, after fees. - **A paper result isn't a promise.** Real orders can fill differently, especially large ones. --- # API reference Base URL: `https://uselayer.sh`. All responses are JSON. ## Authentication Every endpoint except `/v0/health` needs a key from your [dashboard](https://uselayer.sh/dashboard): ``` authorization: Bearer lyr_your_key ``` `x-api-key: lyr_your_key` also works. Each key allows **60 requests per minute**. No key? Agents can pay for each call in USDC on Base instead: see [Pay per call (x402)](https://uselayer.sh/docs/pay-per-call.md). Every endpoint and its inputs as an OpenAPI 3.1 file, for a coding agent or a client generator: [`/v0/openapi.json`](https://uselayer.sh/v0/openapi.json). --- ## GET /v0/matches Every live match, soonest event first (by day, then by Kalshi's event time within the day, so 15-minute crypto windows lead their day). Without `from`, a match whose event ended over 12 hours ago is left out, even though both venues still have it open: a game that was never played or is waiting on a result, an election not yet certified. Send `from` with a past day to see those. Use it to discover what Layer can answer. For example, one weekend's NFL matches: `?category=sports&q=nfl&from=&to=`, with the dates as `YYYY-MM-DD`. A filter it can't read, such as `from=Oct 5`, returns `400 bad_request`. | Query | Default | Meaning | |---|---|---| | `limit` | `50` | 1–200; a number outside that is clamped, and anything else means 50. | | `offset` | `0` | Skip this many matches first, to page past `limit`. Send the last answer's `next_offset`. | | `category` | all | Only matches in that category, e.g. `sports`. A category with no live matches yet returns `count: 0`. | | `series` | — | Only these Kalshi series, comma-separated, any case, up to 20: e.g. `KXBTC15M,KXETH15M` for the 15-minute Bitcoin and Ethereum windows. Use a match's `kalshi.series`. A series Kalshi has never used returns `400 bad_request`. | | `from` | — | Only events on or after this day (`YYYY-MM-DD`). Without it, events that ended over 12 hours ago are left out: "ended" is the later of the two venues' `event_time`, and a match with no `event_time` stays. | | `to` | — | Only events on or before this day. | | `q` | — | Words that must all appear, ignoring case, somewhere in either venue's event title, question or outcome, or in Kalshi's series ticker or Polymarket's event slug (so league names such as `nfl` work). E.g. `chiefs` or `fed december`. Up to 200 characters; only the first 10 words are used. For crypto, a coin's name finds every market on that coin: `bitcoin`, `ethereum`, `solana`, `ripple` (the tickers `btc`, `eth`, `sol`, `xrp` search the text like any word). | | `venue` | `polymarket_us` | Which venue the matches are with: `polymarket_us` (Polymarket US, polymarket.us), `polymarket` (polymarket.com), `hyperliquid` (Hyperliquid's outcome markets) or `novig` (Novig's sports game lines). | | `prices` | `true` | `false` leaves out live prices and the best trade, for a faster answer. See [Live prices](#live-prices) below. | **Response** | Field | Meaning | |---|---| | `count` | Number of matches returned. | | `total` | Number of matches for these filters, on every page. | | `limit`, `offset` | The page size and offset used. A `limit` above 200 is answered with 200. | | `has_more` | `true` when there are matches after this page. | | `next_offset` | The `offset` for the next page, or `null` on the last. Page with it until `has_more` is `false`. | | `hint` | Only when `count` is `0`: why, and what to send instead. E.g. an `offset` past the end, a word no match contains, the filter that emptied the list, or another `venue` that has matches. | | `matches[].kalshi` | The Kalshi market (see *Market object*). | | `matches[].polymarket_us` | The Polymarket US market. With `venue=polymarket` this field is `matches[].polymarket` instead, with `venue=hyperliquid` it's `matches[].hyperliquid`, and with `venue=novig` it's `matches[].novig`. | | `matches[].category` | Layer's category for the event. | | `matches[].confidence` | 0–1. `1` when a person approved the match. | | `matches[].basis` | `identical` or `equivalent_with_caveats`. | | `matches[].caveats` | Which rules differ (see [Overview](https://uselayer.sh/docs/index.md)). | | `matches[].caveat_notes` | Always `{}` for now. The notes quote the venues' rules, and the venues' terms keep their wording inside Layer, so show the `caveats` codes. The [Python SDK](https://uselayer.sh/docs/sdk.md#rule-flag-notes) writes the notes on your machine from the rules it reads (`m.caveat_notes`). | | `matches[].tier` | `human_verified` or `auto_verified`. | | `attribution` | Where the data comes from (see *Using Layer data*). | | `checked_at` | When this answer was produced. | ### Live prices Every account gets each match's live prices and best trade, read from both venues when you ask. So do `GET /v0/match` and `POST /v0/match`. Send `prices=false` (in the body for `POST /v0/match`: `"prices": false`) to skip them for a faster answer. A settled match has none. To trade, your code still reads order books and places orders with your own venue keys: the [Python SDK](https://uselayer.sh/docs/sdk.md) does it for you. **Want live order books?** For NBA, NFL, NHL and NCAAF game winners on Kalshi and Polymarket US, the [Stream](https://uselayer.sh/docs/stream.md) (Beta) pushes each change to the best prices over a WebSocket. For other markets, Layer doesn't stream books yet. If you'd use every venue's full order book and each change to it, over one WebSocket in one format, email [david@uselayer.sh](mailto:david@uselayer.sh?subject=Live%20order%20books) and say what language your bot is written in. Other ideas we're weighing are on [Feature requests](https://uselayer.sh/docs/feature-requests.md). | Field | Meaning | |---|---| | `prices.as_of` | When the prices were read (ISO 8601, UTC). | | `prices.kalshi`, `prices.polymarket_us` (or `polymarket`, `hyperliquid`, `novig`) | Each venue's best prices: `yes` and `no`, each with a `bid` and an `ask` of `{price, size}`, in dollars and contracts. `null` for a side with no orders, or a venue that didn't answer. | | `best_trade` | Buying YES on one venue and NO on the other at the best asks, the cheaper way round, after both taker fees: `kalshi` and the other venue's leg (`buy`, `price`, `size`), then `contracts`, `cost`, `fees`, `payout`, `net_profit`, `net_profit_per_contract`, `return_pct`, `profitable`, `expected_payout_at`, `latest_payout_at`, `days_held`, `return_per_day_pct` and `annualized_return_pct`. The same math as `POST /v0/profit`. `null` when a side has no ask. | | `best_trade.event_passed` | `true` only when the expected payout time has gone. The result is probably known, so prices can show a large gap that isn't a real trade: `profitable` is `false` and the per-day figures are `null`, whatever the prices say. | | `best_trade.event_started` | `true` only once the event has started and its expected payout time hasn't gone: for a game, from the start until the money is expected back. Prices move within seconds then. | | `best_trade.stale_prices` | `true` only when the event has started and a venue's prices were over 5 seconds old when Layer read them. Both venues answer price requests from a cache (Kalshi for up to 15 seconds, Polymarket US for up to 30), so during a game one venue's price can be from before a move and the other's from after it, which shows a gap that was never there to trade. `profitable` is then `false`, whatever the prices say; the other figures are still worked out. Ask again for a fresher read, or use the [Stream](https://uselayer.sh/docs/stream.md) for live books. | | `best_trade.price_age_seconds` | How old each venue's prices were when Layer read them, in seconds, by venue: `{"kalshi": 0, "polymarket_us": 17}`. `0` is straight from the venue. Only for venues that say (Kalshi and Polymarket US). | | `prices_unavailable` | On the answer, only when a venue didn't answer: the venues whose prices are missing. The matches are still served. | Hyperliquid matches get `prices.hyperliquid` but `best_trade: null`: Layer doesn't work out Hyperliquid's fees yet. Novig matches get `prices.novig` and a `best_trade` with a `novig` leg. Novig charges takers 0.06 × price × (1 − price) per contract: on a game line only once the game has started, so a trade before the game has no Novig fee, and on a season future always. If Layer can't read the market's fee or whether its game has started, `best_trade` is `null`. Novig's book holds only bids, so the ask on an outcome is 1 minus the best bid on the other outcome; sizes are in $1 contracts, like Kalshi's (one Novig contract pays 1¢). **Hyperliquid.** `venue=hyperliquid` covers games (winners, soccer results with each team and the draw, a game's totals and spreads), season winners, crypto prices at a set time and IPO dates. There are no player props. If a venue's matches are ever switched off, asking for them here, on `/v0/match` or on `/v0/settlements` answers `403 venue_not_served`. See [How matching works](https://uselayer.sh/docs/matching.md#hyperliquid). **Novig.** `venue=novig` covers sports only: game lines (moneylines, spreads, totals and soccer's three-way result, each team and the draw) and season futures (champions, conference and division winners, playoff qualifiers), each team its own contract. There are no player props or half-game lines. If Novig's matches are ever switched off, asking for them here, on `/v0/match` or on `/v0/settlements` answers `403 venue_not_served`. See [How matching works](https://uselayer.sh/docs/matching.md#novig). --- ## GET /v0/match The twin of one market on the other venue. | Query | Required | Meaning | |---|---|---| | `venue` | yes | `kalshi`, `polymarket`, `polymarket_us`, `hyperliquid` or `novig` — the venue of the id you pass. | | `market_id` | yes | Kalshi: the market ticker. Polymarket: the `conditionId`, market slug, or numeric id. Polymarket US: the market slug; for a market with two named sides, `:long` or `:short`. Hyperliquid: the coin of one side of an outcome, so `#14960` is side 0 of outcome 1496. In a URL, send its `#` as `%23`: `market_id=%2314960`. Novig: `:`, or Novig's `outcomeId` alone. | | `prices` | no | `false` leaves out live prices, for a faster answer (see [Live prices](#live-prices)). | | `with` | no | For a Kalshi market: which venue to find its twin on, `polymarket_us` (the default), `polymarket`, `hyperliquid` or `novig`. For a Polymarket, Polymarket US, Hyperliquid or Novig market, leave it out: the twin is always on Kalshi. Naming the market's own venue is allowed and changes nothing; naming another one is a `400` (see below). | **From one Polymarket to the other.** Layer pairs each Polymarket, Hyperliquid and Novig with Kalshi, not with each other, so there's no direct lookup from a polymarket.com market to its Polymarket US twin (or back). It takes two steps, through Kalshi: ```bash # 1. The polymarket.com market's Kalshi match: matched_market.market_id is the Kalshi ticker curl -H "Authorization: Bearer $LAYER_API_KEY" "https://uselayer.sh/v0/match?venue=polymarket&market_id=" # 2. That Kalshi market's twin on Polymarket US curl -H "Authorization: Bearer $LAYER_API_KEY" "https://uselayer.sh/v0/match?venue=kalshi&market_id=&with=polymarket_us" ``` If step 1 has no match, there's no Polymarket US twin to find either. Swap the venues to go from Polymarket US to polymarket.com (`with=polymarket` in step 2). **Match found** | Field | Meaning | |---|---| | `source_market` | The market you asked about. | | `matched_market` | Its twin on the other venue. | | `side` | Always `same`: YES on one pays like YES on the other. | | `tier`, `confidence`, `basis`, `caveats`, `caveat_notes` | As in `/v0/matches`. | | `match_reason` | Who approved it: `approved by a reviewer` or `auto-approved`. | | `prices`, `best_trade` | As in `/v0/matches` (see [Live prices](#live-prices)). | | `settlement` | Only once both markets have settled: how each venue paid out and whether they agreed (see *Settlement object* under `GET /v0/settlements`). A market that was in a live match when it settled is still answered after it closes, with this field. | | `attribution` | Where the data comes from (see *Using Layer data*). | | `checked_at` | When this answer was produced. | **No match** — `matched_market` is `null` and `reason` says why. `source_market` is still the market you asked about, or `null` when Layer doesn't know the id or can't tell which side you mean (`specify_outcome`). | `reason` | Meaning | |---|---| | `not_indexed` | Layer doesn't know this id, or the market has closed without a match that was live when it settled. | | `no_candidate` | Layer found nothing on the other venue that could be the same bet. | | `pending_review` | A possible match exists but hasn't been approved yet. | | `rejected` | A possible match was checked and is not the same bet. | | `outcome_unmapped` | The events match, but this particular outcome has no twin. | | `rules_changed_pending_review` | A venue edited its rules since approval; re-checking. | | `specify_outcome` | This Polymarket market has two named sides; pass `:0` or `:1` (Polymarket US: `:long` or `:short`). | ### Market object | Field | Meaning | |---|---| | `venue` | `kalshi`, `polymarket`, `polymarket_us`, `hyperliquid` or `novig`. | | `market_id` | The id to use with the venue's own API. For Hyperliquid, the side's coin (`#14960`). For Novig, `:`: Novig's market, then the outcome (the side) within it. | | `group_id` | The venue's event id (Kalshi event ticker, Polymarket event slug). For Hyperliquid, Layer's own id for the outcome, question, game or price time the market is in (`price-BTC-20261103-2000`: BTC at 8:00 PM UTC on Nov 3, 2026). For Novig, `#`: Novig's game and the kind of line (`moneyline`, `spreads` or `totals`). | | `url` | The market's page on the venue, where it's traded. Link to it when you show the market. For Hyperliquid it's the Outcomes page (`https://app.hyperliquid.xyz/outcomes`), not a page for that market. For Novig it's `https://novig.com/events/`, which opens novig.com with that game and side in the order slip. | | `series` | Kalshi: the market's series ticker, one league and one market type, e.g. `KXNFLGAME`. Kalshi's `GET /series/` gives its `fee_multiplier` for `POST /v0/profit`. Polymarket and Polymarket US: the event's series, one league and season with every market type, e.g. `nfl-2026`. Each Polymarket names its own series (Polymarket US calls the same NFL games `nfl-2025`), and the names change by season. Hyperliquid: the competition, e.g. `nba` (a season winner too), or `fomc`, `price-btc`, `ipo-anthropic`. Novig: the league, e.g. `nba` or `champions-league`. Always present for Kalshi; for the others, left out when the venue gives none. | | `slug`, `yes_token_id` | Polymarket only: the market slug and the CLOB token id for YES — what you trade with. Polymarket US markets have `slug` only (without the `:long` / `:short` side). | A market object carries ids and links, not the venue's own wording. For a market's title, question, times and settlement sources, read it from the venue with its `market_id`, on your machine. --- ## GET /v0/settlements How each match settled on both venues, and whether they agreed: Layer's track record, newest first. Only matches that were live in the API when they settled are listed. Use it to check a match after the fact, or to settle your own positions. For example, everything that settled since your last call: `?since=2026-10-01T12:00:00Z` (pass the `checked_at` of your last answer). To see the record for one kind of market, send `series` or `category`. A Kalshi series is one league and one market type, so `?series=KXNFLGAME` is every NFL game-winner match, and the answer adds `scope_totals`: how many of those settled and how many agreed. For a whole league, every market type, send the Polymarket side's series instead: `?polymarket_series=nfl-2025` (Polymarket US's NFL series). | Query | Default | Meaning | |---|---|---| | `venue` | `polymarket_us` | Which venue: `polymarket_us` (Polymarket US), `polymarket` (polymarket.com), `hyperliquid` or `novig`. | | `since` | — | Only settlements Layer recorded at or after this day (`YYYY-MM-DD`, from midnight UTC) or time (ISO 8601 with a time zone). | | `outcome` | all | `agreed`, `disagreed` or `edge_case`. | | `series` | all | Only matches whose Kalshi market is in this series, e.g. `KXNFLGAME` (the `series` field of the Kalshi *Market object*). Any case works. A Kalshi series with nothing settled yet returns `count: 0`. A name Kalshi doesn't have is a `400`, and so is a Polymarket series (like `nfl-2026`): send that as `polymarket_series`. | | `polymarket_series` | all | Only matches whose Polymarket market is in this series, e.g. `nfl-2026` (the `series` field of the Polymarket *Market object*). One league and season, every market type. Any case works. Use this venue's own name: `nfl-2026` is polymarket.com's, and Polymarket US's is `nfl-2025`. With `venue=hyperliquid`, send the competition, e.g. `nba`; with `venue=novig`, the league, e.g. `nba`. A name this venue doesn't have is a `400`, and the hint says if another venue has it. | | `category` | all | Only matches in this category, as in `/v0/matches`: `sports`, `politics`, `economics`, `crypto`, `finance`, `climate`, `tech`, `culture`, `world` or `other`. | | `limit` | `50` | 1–200; a number outside that is clamped, and anything else means 50. | | `offset` | `0` | Skip this many settlements first, to page past `limit`. | **Response** | Field | Meaning | |---|---| | `count` | Number of settlements returned. | | `totals` | Every settlement of a live match with this venue, whatever the filters: `settled`, `agreed`, `disagreed`, `edge_case`. | | `scope_totals` | Only when you send `series`, `category` or `polymarket_series`: the same four counts for that kind of market only (all of them, when you send more than one), whatever `since`, `outcome`, `limit` and `offset` are. A settlement whose Kalshi market Layer has no event for can't be placed in a `series` or `category`, and one whose Polymarket market has none can't be placed in a `polymarket_series`. It counts in `totals` but never in that `scope_totals` or narrowed list. | | `settlements[].kalshi` | The Kalshi market (see *Market object*). | | `settlements[].polymarket_us` | The Polymarket US market. With `venue=polymarket` this field is `settlements[].polymarket` instead, with `venue=hyperliquid` it's `settlements[].hyperliquid`, and with `venue=novig` it's `settlements[].novig`. | | `settlements[].settlement` | How it settled (see *Settlement object*). | | `attribution` | Where the data comes from (see *Using Layer data*). | | `checked_at` | When this answer was produced. | ### Settlement object | Field | Meaning | |---|---| | `results` | What each venue paid on its side of the match: `yes`, `no` or `void` (refunded, e.g. a cancelled game). Keyed by venue: `{ "kalshi": "yes", "polymarket": "yes" }`, or `polymarket_us`, `hyperliquid` or `novig` in place of `polymarket`. A Hyperliquid side that paid only part of its $1 (half, when Hyperliquid settles an outcome 50-50) is `void`. Novig grades each outcome WIN (`yes`) or LOSS (`no`); a refund (PUSH) or a settlement at Novig's fair market value (FMV, what it pays when it voids a market) is `void`. Layer reads Novig's grades with a read-only Novig key, so a Novig match only settles here once that key is in place. | | `outcome` | `agreed`: both paid the same. `disagreed`: one paid Yes and the other No, so the match was wrong; Layer pulls it from the API and fixes the cause. `edge_case`: one venue voided the market while the other resolved it, from the venues' different settlement policies (see the [Overview](https://uselayer.sh/docs/index.md)). | | `explanation` | Why the two differed, in plain English, or `null` when they agreed. | | `settled_at` | When Layer saw both results (ISO 8601, UTC). Layer checks every 6 hours, so this is up to about 6 hours after the later venue settled. | ```bash curl -H "Authorization: Bearer $LAYER_API_KEY" "https://uselayer.sh/v0/settlements?venue=polymarket_us&since=2026-10-01" ``` Layer's record on NFL game winners with Polymarket US: ```bash curl -H "Authorization: Bearer $LAYER_API_KEY" "https://uselayer.sh/v0/settlements?venue=polymarket_us&series=KXNFLGAME&limit=1" ``` ```json { "count": 1, "totals": { "settled": 4053, "agreed": 4052, "disagreed": 1, "edge_case": 0 }, "scope_totals": { "settled": 2, "agreed": 2, "disagreed": 0, "edge_case": 0 }, "settlements": [ { "kalshi": { "series": "KXNFLGAME", "...": "..." }, "polymarket_us": { "...": "..." }, "settlement": { "outcome": "agreed", "...": "..." } } ], "attribution": "...", "checked_at": "2026-10-02T18:00:00.000Z" } ``` Layer's record on every NFL market type with polymarket.com (game winners, spreads, totals and player props), by its series name: ```bash curl -H "Authorization: Bearer $LAYER_API_KEY" "https://uselayer.sh/v0/settlements?venue=polymarket&polymarket_series=nfl-2026&limit=1" ``` ```json { "count": 1, "totals": { "settled": 5405, "agreed": 5405, "disagreed": 0, "edge_case": 0 }, "scope_totals": { "settled": 294, "agreed": 294, "disagreed": 0, "edge_case": 0 }, "...": "..." } ``` --- ## POST /v0/match Look up many markets in one call: up to 50, from any venue, mixed freely. The whole call counts as **one request** against your key's 60 per minute. ```bash curl -X POST -H "authorization: Bearer $LAYER_KEY" \ -H "content-type: application/json" \ -d '{"markets":[{"venue":"kalshi","market_id":"KXFEDDECISION-26OCT-C25"},{"venue":"polymarket","market_id":"afcq-ben-mau-2026-09-29-ben"}]}' \ "https://uselayer.sh/v0/match" ``` **Request body** | Field | Meaning | |---|---| | `markets` | 1–50 lookups. | | `markets[].venue` | `kalshi`, `polymarket`, `polymarket_us`, `hyperliquid` or `novig`, as in `GET /v0/match`. | | `markets[].with` | Optional, as in `GET /v0/match`: for a Kalshi market, which venue to find its twin on. On a Polymarket, Polymarket US, Hyperliquid or Novig market it can only name that market's own venue; another one is a `400`, since Layer pairs each of them with Kalshi. To go from one Polymarket to the other, send the market without `with`, then send its `matched_market.market_id` as `{"venue":"kalshi","market_id":"…","with":"polymarket_us"}` (or `"polymarket"`) in a second call. | | `markets[].market_id` | Any id `GET /v0/match` accepts. | | `prices` | Optional: `false` leaves out live prices, for a faster answer. | **Response** | Field | Meaning | |---|---| | `count` | Number of results, the same as the number of markets you sent. | | `results` | One per market, in the order you sent them. Each has the `venue` and `market_id` you sent, plus exactly the fields `GET /v0/match` returns: a match, or `matched_market: null` with a `reason`. | | `attribution`, `checked_at` | As in `GET /v0/match`, once for the whole answer. | A market Layer doesn't know comes back as `not_indexed` in its place in `results`; it doesn't fail the call. The call fails with `400 bad_request` if the body is malformed or an entry's `with` names a venue it can't, and `detail` names the entry (for example `markets.3.venue`). It also fails as a whole (`403 venue_not_served` or `503 venue_unavailable`) when a venue it touches is switched off or its matches aren't served. --- ## POST /v0/profit Is buying one side on each venue profitable once fees are paid? You send the prices; Layer applies each venue's official fee schedule. Buying YES on one venue and NO on the other, for the same matched outcome, pays exactly $1 per contract however it settles. So the trade is profitable when the two prices plus both fees come to less than $1 per contract. The trade is Kalshi plus one other venue: send `kalshi` and exactly one of `polymarket` or `polymarket_us`. Hyperliquid and Novig aren't priced here yet. ```bash curl -X POST -H "authorization: Bearer $LAYER_KEY" \ -H "content-type: application/json" \ -d '{"contracts":100,"kalshi":{"price":0.42},"polymarket":{"price":0.55,"fee_rate":0.05}}' \ "https://uselayer.sh/v0/profit" ``` **Request body** | Field | Default | Meaning | |---|---|---| | `contracts` | required | Contracts bought on each venue, a whole number up to 1,000,000. | | `kalshi.price` | required | What you pay per contract on Kalshi, in dollars (`0.42` is 42¢). Up to 6 decimal places. | | `kalshi.market_id` | — | Optional. The Kalshi ticker of a live match (`kalshi.market_id` in `/v0/matches`). Layer then fills in the fees and `days_held` you leave out: see *Send the match id instead of the fees* below. | | `kalshi.role` | `taker` | `taker` if your order fills against the book, `maker` if it rests first. | | `kalshi.fee_type` | `quadratic` | The series' `fee_type` from Kalshi's API: `quadratic`, `quadratic_with_maker_fees` or `quadratic_with_combo_maker_fees`. Only the last two charge makers. Filled in from `market_id`. | | `kalshi.fee_multiplier` | `1` | The series' `fee_multiplier` from Kalshi's API, 0–10. Some series use `0.5`, and fee-free ones use `0`. Filled in from `market_id`. | | `polymarket.price` | required | What you pay per share on Polymarket, in dollars. | | `polymarket.role` | `taker` | Polymarket charges takers only, so `maker` pays no fee. | | `polymarket.fee_rate` | — | The market's `feeSchedule.rate` from Polymarket's API. Send this or `category`, or send `kalshi.market_id` and Layer fills it in. | | `polymarket.category` | — | Uses Polymarket's default rate for the category: `crypto` 0.07; `sports`, `economics`, `culture`, `weather`, `other` 0.05; `finance`, `politics`, `mentions`, `tech` 0.04; `geopolitics` (or `world`) 0. `climate` is accepted as `weather`. | | `polymarket.exponent` | `1` | The market's `feeSchedule.exponent`, a whole number 1–4. Filled in from `market_id`. | | `polymarket_us.price` | required | What you pay per contract on Polymarket US, in dollars. Buying NO at X is a YES order at 1 − X there; send X. | | `polymarket_us.role` | `taker` | `taker` pays the fee; `maker` gets Polymarket US's rebate. | | `polymarket_us.fee_coefficient` | `0.095` | The market's `feeCoefficient` from Polymarket US's API: its taker rate. Polymarket US's markets report the Tier 1 rate; send your own tier (0.085, 0.075 or 0.0695 from $10K, $50K or $250K of trailing 30-day taker volume) if you trade more. Filled in from `market_id`. | | `settles_at` | — | Optional. When the money comes back: a date (`2026-10-04`, read as 00:00 UTC) or a time with an offset (`2026-10-04T17:00:00Z`), in the future. A good estimate is the later of the two markets' event times, plus a few hours for the results to be confirmed; read them from each venue (the SDK's `client.matches()` fills them in). A market's close time is the latest it can be: for a game, often days later than you'll really be paid. Or send `kalshi.market_id` and Layer works out `days_held` itself. Adds return per day to the answer. | | `days_held` | — | Optional. The same thing as a number of days (above 0, up to 3,650; `0.5` is 12 hours). Send `settles_at` or `days_held`, not both. | `price` is the price of the side you buy on that venue. Pass the YES price on one venue and the NO price on the other. **Response** ```json { "contracts": 100, "kalshi": { "price": 0.42, "role": "taker", "cost": 42, "fee": 1.71, "fee_rate": 0.07, "fee_multiplier": 1, "fee_type": "quadratic" }, "polymarket": { "price": 0.55, "role": "taker", "cost": 55, "fee": 1.2375, "fee_rate": 0.05, "exponent": 1 }, "payout": 100, "spread": 0.03, "gross_profit": 3, "fees": 2.9475, "net_profit": 0.0525, "return_pct": 0.05, "profitable": true } ``` | Field | Meaning | |---|---| | `kalshi.cost`, `polymarket.cost` | Price × contracts on each venue. The second leg is under the venue you sent: `polymarket` or `polymarket_us`, with its `fee_coefficient` in place of `fee_rate` and `exponent`. A Polymarket maker shows `fee_rate: 0`; a Polymarket US maker shows the rebate as `fee_coefficient: -0.0125` until 2026-10-19, and `0` from then on (see below). | | `kalshi.fee`, `polymarket.fee` | Each venue's fee for the order, in dollars. | | `payout` | $1 × contracts: what the pair pays whichever way it settles. | | `spread` | Per contract, before fees: $1 − Kalshi price − Polymarket price. | | `gross_profit` | Payout − both costs, before fees. | | `fees` | Both fees together. | | `net_profit` | Payout − both costs − both fees. Negative means you'd lose money. | | `return_pct` | Net profit as a percentage of everything you pay (costs plus fees). | | `profitable` | `true` when `net_profit` is above zero. | **Return per day of capital held** A 2¢ edge that ties your money up for a month is a worse trade than the same edge paid out on Sunday. Send `settles_at` or `days_held` and the answer adds three fields: ```json { "days_held": 4, "return_per_day_pct": 0.0131, "annualized_return_pct": 4.79 } ``` | Field | Meaning | |---|---| | `days_held` | Days your money is tied up: `days_held` as you sent it, or from now until `settles_at`, to 2 decimal places. | | `return_per_day_pct` | `return_pct` ÷ `days_held`, to 4 decimal places. | | `annualized_return_pct` | `return_per_day_pct` × 365: simple, not compounded. | Both are worked out from the unrounded return, so compare opportunities by these rather than redoing the sum from the rounded `return_pct`. A losing trade gives negative numbers. For a very short hold the annualized figure gets large fast; treat it as a way to rank trades, not a promise you can repeat the trade every day. The money is only counted as tied up until settlement: the time a venue takes to pay out or to let you withdraw isn't included. **Send the match id instead of the fees** Pricing a match from `/v0/matches`? Send its Kalshi ticker as `kalshi.market_id` next to the two prices, and Layer fills in what it knows about the pair: ```bash curl -X POST -H "authorization: Bearer $LAYER_KEY" \ -H "content-type: application/json" \ -d '{"contracts":100,"kalshi":{"price":0.42,"market_id":"KXFEDDECISION-26OCT-C25"},"polymarket":{"price":0.55}}' \ "https://uselayer.sh/v0/profit" ``` - **Kalshi:** the series' `fee_multiplier` and `fee_type`. - **Polymarket:** the market's own `fee_rate` and `exponent` (its `feeSchedule`). - **Polymarket US:** the market's own `fee_coefficient`. - **`days_held`:** from now until the match's expected payout: the later of the two venues' event times plus 6 hours, never past the later closing time, and at least 1 day. The Polymarket leg you send picks the match: `polymarket` for the international one, `polymarket_us` for the US one. Anything you send yourself is used as you sent it, so you can still pass `settles_at`, `days_held` or any fee to override Layer's. The answer is the same as sending every value yourself, plus two fields: ```json { "match": { "kalshi": "KXFEDDECISION-26OCT-C25", "polymarket": "0x5f3c…", "expected_payout_at": "2026-10-28T18:00:00.000Z", "latest_payout_at": "2026-10-28T18:00:00.000Z" }, "filled_in": ["kalshi.fee_multiplier", "kalshi.fee_type", "polymarket.fee_rate", "polymarket.exponent", "days_held"] } ``` | Field | Meaning | |---|---| | `match` | The pair Layer priced: each market's id, and when the money is expected back and the latest it can be (`null` when neither venue gives a time). | | `filled_in` | The fields Layer filled in. Anything not listed is what you sent, or the default. | Layer reads the fees from each venue when you call, so the answer uses the fees in force right then. If the ticker has no match on the Polymarket you sent (live now, or live when it settled), the answer is `404 no_match`, with the reason and how to check the ticker. If a venue doesn't answer when Layer asks for a fee, or Polymarket gives no fee schedule for the market and you sent no `category`, the answer is `503 fees_unavailable`. Try again, or send that fee yourself. If Polymarket's fee can't be read and you sent `category`, Layer uses the category's rate and leaves `polymarket.fee_rate` out of `filled_in`. **How fees are worked out** - **Kalshi:** `multiplier × rate × contracts × price × (1 − price)`, rounded up to the next cent. The rate is 0.07 for takers. Makers pay 0.0175, or 0.035 on combo series, but only on series whose `fee_type` includes maker fees. This follows [Kalshi's fee schedule](https://kalshi.com/docs/kalshi-fee-schedule.pdf) and matches its published fee table. Members whose balance is kept to $0.0001 may pay up to 1¢ less per order. - **Polymarket:** `contracts × fee_rate × (price × (1 − price))^exponent`, rounded to 5 decimal places, takers only ([Polymarket's fees](https://docs.polymarket.com/trading/fees)). - **Polymarket US:** `fee_coefficient × contracts × price × (1 − price)`, rounded to the nearest cent with ties to even. Since 2026-11-06 at 1 PM ET the taker coefficient is tiered by the taker's trailing 30-day volume: 0.095 under $10,000, 0.085 to $50,000, 0.075 to $250,000 and 0.0695 above. Polymarket US's markets report 0.095, so Layer prices at that unless you send your own `fee_coefficient`. Makers get a rebate of `0.0125 × contracts × price × (1 − price)`, shown as a negative fee, until 2026-10-19. From then on Polymarket US pays a maker rebate only to makers with $10M or more of maker volume the month before, so Layer shows no rebate. This follows [Polymarket US's fee schedule](https://docs.polymarket.us/fees) and matches its published 100-lot table. Its volume rebates, paid weekly, aren't included. You send the prices, so no venue data is served: `/v0/profit` keeps working when a venue's data is switched off (see *Errors*). With `kalshi.market_id` it reads the match, so it's paused like `/v0/match`. The answer assumes each order fills in full at the price you send, as a single order. It leaves out deposit and withdrawal costs, rebates, and any price movement while you trade. Fees change: if a venue's schedule differs from this, the venue's schedule wins. --- ## POST /v0/size How much of a gap can you actually trade? The best prices often have only a few dollars behind them. You send the order book for the side you'd buy on each venue, and the smallest profit per contract you'll accept. Layer walks both books from the best price down and returns the largest size that still clears it after both venues' fees, your average price on each venue, and the slippage against the best price. The trade is Kalshi plus one other venue, as in `/v0/profit`: send `kalshi` and exactly one of `polymarket` or `polymarket_us`. Hyperliquid and Novig aren't sized here yet. ```bash curl -X POST -H "authorization: Bearer $LAYER_KEY" \ -H "content-type: application/json" \ -d '{"min_edge":0.01,"kalshi":{"asks":[{"price":0.42,"size":100},{"price":0.43,"size":300}]},"polymarket_us":{"asks":[{"price":0.52,"size":150},{"price":0.55,"size":500}]}}' \ "https://uselayer.sh/v0/size" ``` **Request body** | Field | Default | Meaning | |---|---|---| | `kalshi.asks` | required | The asks for the side you buy on Kalshi: a list of `{"price": 0.42, "size": 100}`, in any order, up to 500 levels. Prices in dollars with up to 6 decimal places; sizes above 0, up to 10,000,000, with up to 6 decimal places. | | `kalshi.fee_multiplier` | `1` | The series' `fee_multiplier` from Kalshi's API, as in `/v0/profit`. | | `polymarket.asks` | required | The asks for the side you buy on Polymarket (that outcome token's book). | | `polymarket.fee_rate`, `polymarket.category`, `polymarket.exponent` | — | As in `/v0/profit`: send `fee_rate` or `category`. | | `polymarket_us.asks` | required | The asks for the side you buy on Polymarket US. | | `polymarket_us.fee_coefficient` | `0.095` | As in `/v0/profit`. | | `min_edge` | `0` | The smallest profit per contract after fees you'll take, in dollars (`0.01` is 1¢). With `0`, every contract that makes anything counts. | | `max_contracts` | — | Optional cap on the answer, a whole number up to 1,000,000. | | `settles_at`, `days_held` | — | Optional, as in `/v0/profit`: adds return per day to the answer. | Send the asks for the side you'd buy: YES on one venue and NO on the other. Where a venue lists only bids, turn them round: a bid of `b` for YES is an ask of `1 − b` for NO, with the same size. Kalshi's order book (`GET /markets/{ticker}/orderbook`) lists YES bids and NO bids, so Kalshi's YES asks are `1 −` each NO bid. **Response** ```json { "contracts": 100, "limited_by": "min_edge", "min_edge": 0.01, "edge_at_best": 0.019236, "edge_at_last": 0.019236, "kalshi": { "best_price": 0.42, "average_price": 0.42, "slippage": 0, "slippage_cost": 0, "cost": 42, "fee": 1.71, "fee_rate": 0.07, "fee_multiplier": 1, "fills": [{ "price": 0.42, "contracts": 100, "cost": 42, "fee": 1.71 }] }, "polymarket_us": { "best_price": 0.52, "average_price": 0.52, "slippage": 0, "slippage_cost": 0, "cost": 52, "fee": 2.37, "fee_coefficient": 0.095, "fills": [{ "price": 0.52, "contracts": 100, "cost": 52, "fee": 2.37 }] }, "payout": 100, "cost": 94, "fees": 4.08, "slippage_cost": 0, "net_profit": 1.92, "net_profit_per_contract": 0.0192, "return_pct": 1.96, "profitable": true } ``` Here the next Kalshi level (43¢) would earn only 0.91¢ a contract, below the 1¢ asked for, so the answer stops at 100. Leave `min_edge` out and it takes 50 more at 43¢: 150 contracts, $0.50 of slippage on Kalshi, $2.37 net. | Field | Meaning | |---|---| | `contracts` | The largest whole number of contracts, bought on each venue, where every contract clears `min_edge`. `0` when even the best prices don't. | | `limited_by` | What stopped it: `min_edge` (the next contract earns too little), `kalshi_book`, `polymarket_book` or `polymarket_us_book` (you sent no more levels), or `max_contracts`. | | `edge_at_best` | Profit per contract after fees at both best prices. Negative means there's no trade at any size. | | `edge_at_last` | Profit per contract after fees on the last contract counted; `null` when `contracts` is `0`. | | `kalshi.fills`, … | Each level you'd fill at: its price, the contracts taken there, their cost and fee. | | `average_price` | Cost ÷ contracts on that venue; `null` when `contracts` is `0`. | | `slippage` | Average price − best price, per contract. | | `slippage_cost` | What the slippage costs in dollars, per venue and in total: cost − best price × contracts. | | `cost`, `fees`, `payout`, `net_profit`, `return_pct`, `profitable` | As in `/v0/profit`, for `contracts` at these fills. `cost` is both legs before fees. | | `net_profit_per_contract` | `net_profit` ÷ `contracts`. | **How the size is worked out** - Layer pairs the cheapest remaining level on each venue and asks whether a contract there earns at least `min_edge` (and more than 0) after both taker fees. If it does, it takes as many as both levels have; if not, it stops. Books only get worse further down, so the first pair that fails ends the walk. - That decision uses the edge before rounding. The totals then bill each fill (each level) the way the venue does, with its rounding (see *How fees are worked out* above). So `net_profit` can differ from the edges by a cent or two, mostly from Kalshi rounding each fill up to the cent. - The answer is in whole contracts, so both legs pay out the same. A fractional level on either venue can be used in part. - Every order is priced as a taker: walking the book means your order fills against it. It's a snapshot: the answer is only as good as the books you send, and books move while you trade. It assumes each level fills in full for you, ahead of anyone else. You send the books, so no venue data is served: `/v0/size` keeps working when a venue's data is switched off. --- ## GET /v0/quote How much of a matched market can you trade right now? Send a Kalshi ticker. Layer reads the full order book on both venues, tries the trade both ways round (Kalshi YES with Polymarket NO, and the reverse), and returns the better one: the most contracts that still clear `min_edge` after both venues' taker fees. It's [`POST /v0/size`](#post-v0-size) without having to fetch and send the books. Kalshi with Polymarket US by default, or polymarket.com with `venue=polymarket`. ```bash curl -H "Authorization: Bearer $LAYER_API_KEY" "https://uselayer.sh/v0/quote?market_id=&min_edge=0.01" ``` **Query parameters** | Param | Required | Meaning | |---|---|---| | `market_id` | yes | The Kalshi ticker of a live match: `kalshi.market_id` in `GET /v0/matches?venue=polymarket_us`. | | `venue` | no | `polymarket_us` (default) or `polymarket` (polymarket.com). The answer's second leg, and its keys in `match` and `books_as_of`, use this name. | | `min_edge` | no | The smallest profit per contract after fees you'll take, in dollars. `0.01` is 1¢. Default: anything above 0. | | `max_contracts` | no | Caps the answer, a whole number from 1 to 1,000,000. | **Response** The fields of [`POST /v0/size`](#post-v0-size), without the price-by-price `fills`, plus: | Field | Meaning | |---|---| | `match` | The two market ids, `basis`, `caveats`, `expected_payout_at` and `latest_payout_at`. | | `kalshi.market_id`, `kalshi.side`, `polymarket_us.market_id`, `polymarket_us.side` | Which market and which side (`yes` or `no`) to buy on each venue. | | `limited_by` | What stopped the size: `min_edge`, `max_contracts`, `kalshi_book`, `polymarket_us_book`, `no_orders` when neither way round has an ask on both venues, or `event_passed` when the expected payout time has gone and the result is probably known. With `no_orders` or `event_passed`, `kalshi`, `polymarket_us` and `edge_at_best` are `null`. | | `books_as_of` | When each venue produced the book Layer read. | | `event_started`, `stale_prices` | As on a match's `best_trade`: `event_started` is `true` once the event has started, and `stale_prices` is `true` when it has and a book was over 5 seconds old (see `books_as_of`). `profitable` is then `false`; the sizes and prices are still worked out. Ask again for a fresher read. | `contracts: 0` is a normal answer: there's nothing to trade at your `min_edge` right now. **Errors:** `404 no_match`, `422 match_settled`, `422 market_not_open` (a market is closed or halted), `503 books_unavailable` (a venue didn't answer), `503 fees_unavailable` (polymarket.com gave no fee schedule), and the same `503 venue_unavailable` and `403 venue_not_served` as `/v0/match`. It's a snapshot and doesn't promise a fill: books move while you trade, and if only one order fills you're left holding one side. It's priced as a taker on both venues. ## GET /v0/sports/spreads Beta. The spread on every NBA, NFL, NHL and college football game that's live or starts within the next 24 hours, matched between Kalshi and Polymarket US. Only game winners matched as `identical` are included. Each game is priced the way [`GET /v0/quote`](#get-v0-quote) prices a match: Layer reads both venues' books, tries both teams both ways round (Kalshi YES with Polymarket US NO, and the reverse), and keeps the trade closest to a profit after both venues' taker fees. Games are sorted by `edge_cents`, highest first, so the list isn't empty when nothing is profitable. ```bash curl -H "Authorization: Bearer $LAYER_API_KEY" "https://uselayer.sh/v0/sports/spreads?league=nba" ``` **Query parameters** | Param | Required | Meaning | |---|---|---| | `league` | no | `nba`, `nfl`, `nhl` or `ncaaf`. Leave it out for all four. | **Response** ```json { "as_of": "2026-11-10T22:00:00.123Z", "games": [{ "event_id": "KXNBAGAME-26NOV10MIAUTA", "market_id": null, "league": "nba", "title": "Miami vs Utah — MIA vs UTA (Nov 10)", "question": "Miami vs Utah: Miami wins", "starts_at": "2026-11-11T02:00:00.000Z", "status": "upcoming", "kalshi": { "ticker": "KXNBAGAME-26NOV10MIAUTA-MIA", "yes_bid": 0.74, "yes_ask": 0.75, "ask_size": 25910.93, "as_of": "2026-11-10T21:59:58.000Z" }, "polymarket_us": { "slug": "aec-nba-mia-uta-2026-11-10:long", "yes_bid": 0.75, "yes_ask": 0.76, "ask_size": 354725.36, "as_of": "2026-11-10T21:59:41.000Z" }, "direction": "kalshi_yes_pmus_no", "edge_cents": -3.09, "arbitrage": false, "contracts": 0, "net_dollars": 0 }] } ``` | Field | Meaning | |---|---| | `event_id` | The game: its Layer event id, or the Kalshi event ticker while it has none. | | `market_id` | The Layer market the row prices (one team's win), or `null` while it has none. | | `question` | The question both venues' prices are for. Each game has one per team; the row shows the one with the better trade. | | `starts_at`, `status` | When the game starts, and `live` once it has, `upcoming` before. | | `kalshi.ticker`, `polymarket_us.slug` | The market on each venue. The Polymarket US slug ends in `:long` or `:short`: which side of its market is this row's YES. | | `yes_bid`, `yes_ask`, `ask_size` | Each venue's best prices and the contracts offered at the ask, for YES on `question`. Only for accounts that see live prices, as in [`GET /v0/matches`](#live-prices). | | `as_of` | When each venue produced the book Layer read. | | `direction` | The better way round: `kalshi_yes_pmus_no` buys YES on Kalshi and NO on Polymarket US; `pmus_yes_kalshi_no` the reverse. | | `edge_cents` | Profit per contract at both best asks, after both taker fees, in cents. Below 0 means the trade loses money. | | `arbitrage` | `true` when the trade makes money after fees on at least one whole contract. | | `contracts`, `net_dollars` | How many contracts clear a positive edge at the books' depth, and the net profit on them in dollars, as `POST /v0/size` works them out. | Books are read at most every 5 seconds per game, so `as_of` can be up to 5 seconds old. Spreads that clear fees often close quickly, so a profitable row may be gone by the time you read it. **Errors:** `400` for an unknown `league`, `503 books_unavailable` (a venue didn't answer), and the same `503 venue_unavailable` and `403 venue_not_served` as `/v0/match`. It's a snapshot and doesn't promise a fill, as with `/v0/quote`. ## GET /v0/sports/lead Beta. Over the last `days` days Layer has processed: for Kalshi and Polymarket US game winners in the NBA, NFL, NHL and college football, which venue moved first, how often the other followed, and how long it took, plus how often spreads that cleared both venues' fees appeared and how long they stayed open. Computed each night from Layer's recordings of both venues' order books. ```bash curl -H "Authorization: Bearer $LAYER_API_KEY" "https://uselayer.sh/v0/sports/lead?league=nba&days=7" ``` | Param | Required | Meaning | |---|---|---| | `league` | no | `nba`, `nfl`, `nhl` or `ncaaf`. Leave it out for all four. | | `days` | no | 1 to 30, default 7, ending on the last day processed. A day is the games that started on it, US Eastern. | Fields and definitions: [Sports spreads and lag](https://uselayer.sh/docs/sports.md#get-v0-sports-lead). **Errors:** `400` for an unknown `league` or a `days` outside 1 to 30. ## GET /v0/sports/events Beta. One day's recorded spreads and moves, one by one with their times, to line up against your own bot's logs. ```bash curl -H "Authorization: Bearer $LAYER_API_KEY" "https://uselayer.sh/v0/sports/events?league=nba&date=2026-11-09" ``` | Param | Required | Meaning | |---|---|---| | `league` | no | `nba`, `nfl`, `nhl` or `ncaaf`. Leave it out for all four. | | `date` | yes | `YYYY-MM-DD`: games that started that day, US Eastern. | `status` says whether Layer has processed that day (`done`); before then the lists are empty. Fields: [Sports spreads and lag](https://uselayer.sh/docs/sports.md#get-v0-sports-events). **Errors:** `400` for an unknown `league` or a missing or malformed `date`. --- ## Webhooks Instead of polling, register an https URL and Layer POSTs to it when a match changes: | Event | When | |---|---| | `match.live` | A match goes live in the API (it now shows in `/v0/matches`). | | `match.pulled` | A live match comes out of the API. `data.reason` says why: `rules_changed` (a venue edited its rules; Layer re-checks it and sends `match.live` again if it's still the same bet), `settled_differently` (the venues paid differently on one line, so the whole game is re-checked) or `rejected` (it wasn't the same bet). | | `match.settled` | A match that was live has settled on both venues. `data.settlement` says how (see *Settlement object*). | Each event is one line of a game, like one row of `/v0/matches`. Events go out within about a minute of Layer seeing the change. Layer looks for new matches every hour and for settlements every 6 hours, so that's how fresh they are. Crypto windows are faster: Layer checks for them every minute, and a new 15-minute window's `match.live` usually goes out about 30 seconds after the window opens (see [Crypto windows](https://uselayer.sh/docs/matching.md#crypto-windows)). ### POST /v0/webhooks Register an endpoint. You can have up to 5. | Field | Default | Meaning | |---|---|---| | `url` | required | Where to send events. Must be `https` and reachable from the internet. | | `events` | all three | Which events to send, e.g. `["match.live", "match.settled"]`. | | `venues` | all | Matches with which venues: any of `"polymarket"`, `"polymarket_us"`, `"hyperliquid"` and `"novig"`. An endpoint registered before a venue was added, with `venues` left out, keeps the venues there were then (before Hyperliquid, the two Polymarkets only; before Novig, no Novig): register it again to add the new ones. | | `description` | — | A note for yourself, up to 200 characters. | ```bash curl -X POST https://uselayer.sh/v0/webhooks \ -H "Authorization: Bearer $LAYER_API_KEY" -H "content-type: application/json" \ -d '{"url":"https://example.com/layer-webhook","venues":["polymarket_us"]}' ``` ```json { "id": "whe_3f2a9c1d0b7e4a65", "url": "https://example.com/layer-webhook", "events": ["match.live", "match.pulled", "match.settled"], "venues": ["polymarket_us"], "description": null, "created_at": "2026-10-01T18:00:00.000Z", "secret": "whsec_…", "next": "POST /v0/webhooks/whe_3f2a9c1d0b7e4a65/test sends a signed test event to it now." } ``` **Save `secret` now.** It's shown only in this answer, and you need it to check that a request came from Layer. To change it, delete the endpoint and register it again. An endpoint gets only events that happen after you register it. ### GET /v0/webhooks Your endpoints, each with how its deliveries are going: `delivered`, `pending` (waiting for a retry) and `failed` (gave up) counts, and `last_attempt` (`at`, `status`, `error`, `ok`), as `{ "endpoints": [ … ] }`. ### GET /v0/webhooks/{id} One endpoint and its last 20 deliveries, newest first: `event_id`, `type`, `test`, `status` (`pending`, `delivered` or `failed`), `attempts`, `last_attempt_at`, `last_status` (your server's HTTP status, or `null` if it didn't answer), `last_error` and `next_attempt_at`. ### DELETE /v0/webhooks/{id} Stops sending to it. Deliveries still waiting for a retry are dropped. Answers `{ "deleted": true, "id": "…" }`. ### POST /v0/webhooks/{id}/test Sends one signed test event now and answers with how your server replied: `delivered`, `status`, `error`, `ms` and `sent` (the exact payload). With no body it sends `webhook.test`. With `{"type":"match.live"}` (or `match.pulled`, `match.settled`) it sends a real recent example of that event from your endpoint's venues, so you can test your handler on the real shape. Test payloads have `"test": true` and aren't retried. ```bash curl -X POST https://uselayer.sh/v0/webhooks/whe_3f2a9c1d0b7e4a65/test \ -H "Authorization: Bearer $LAYER_API_KEY" -d '{"type":"match.settled"}' ``` ### Payload ```json { "id": "evt_1042", "type": "match.live", "timestamp": "2026-10-01T18:30:12.000Z", "data": { "kalshi": { "venue": "kalshi", "market_id": "KXNFLGAME-26OCT04DALHOU-DAL", "…": "…" }, "polymarket_us": { "venue": "polymarket_us", "market_id": "nfl-dal-hou-2026-10-04:long", "…": "…" }, "category": "sports", "tier": "auto_verified", "confidence": 0.97, "basis": "identical", "caveats": [], "caveat_notes": {} } } ``` | Field | Meaning | |---|---| | `id` | The event's id. Every retry of it has the same `id`, so use it to ignore duplicates. | | `type` | `match.live`, `match.pulled`, `match.settled`, or `webhook.test`. | | `timestamp` | When Layer saw the change (ISO 8601, UTC). Events can arrive out of order; use this to order them. | | `test` | `true` on events sent by the test endpoint; absent otherwise. | | `data` | The match, in the same shape as a `/v0/matches` row: `kalshi` and `polymarket` (or `polymarket_us`, `hyperliquid` or `novig`) market objects (see *Market object*), `category`, `tier`, `confidence`, `basis`, `caveats`, `caveat_notes`. `match.pulled` adds `reason`; `match.settled` adds `settlement`. A `webhook.test` event carries `{ "message": "…" }` instead. | ### Checking the signature Layer signs every request the [Standard Webhooks](https://www.standardwebhooks.com) way, so its libraries verify it (`npm install standardwebhooks`, `pip install standardwebhooks`). Each request has three headers: | Header | Meaning | |---|---| | `webhook-id` | The event's `id`. | | `webhook-timestamp` | When this attempt was sent, in Unix seconds. Refuse requests more than 5 minutes old. | | `webhook-signature` | `v1,` then the base64 HMAC-SHA256 of `..`, keyed with your secret (the part after `whsec_`, base64-decoded). | ```js import { Webhook } from "standardwebhooks"; const wh = new Webhook(process.env.LAYER_WEBHOOK_SECRET); // whsec_… // In your handler, with the raw request body as a string (not re-serialized JSON): const event = wh.verify(rawBody, { "webhook-id": req.headers["webhook-id"], "webhook-timestamp": req.headers["webhook-timestamp"], "webhook-signature": req.headers["webhook-signature"], }); // throws if the signature or timestamp is wrong ``` ### Answering and retries Answer with any `2xx` within 10 seconds, and do slow work after you answer. Anything else (another status, a redirect, a timeout, or no answer) is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours, then Layer gives up on that event. That's 8 attempts over about 28 hours, and `GET /v0/webhooks/{id}` shows each one. Events aren't sent while a venue is switched off or its matches aren't served (`venue_unavailable`, `venue_not_served` below). They also aren't sent to an account with no active API key. --- ## GET /v0/health No key needed. Is the API up and can it reach its database? ```json { "ok": true, "database": { "reachable": true, "ms": 62 }, "venues": { "kalshi": { "open_events": 12587, "open_markets": 125458, "last_pull": "…" }, "polymarket": { "open_events": 42935, "open_markets": 364647, "last_pull": "…" }, "polymarket_us": { "open_events": 4032, "open_markets": 65561, "last_pull": "…" }, "hyperliquid": { "open_events": 73, "open_markets": 167, "last_pull": "…" }, "novig": { "open_events": 1154, "open_markets": 18405, "last_pull": "…" } }, "live_market_pairs": 14, "live_market_pairs_by_venue": { "polymarket": 14, "polymarket_us": 9 }, "checked_at": "…" } ``` `venues` lists every venue Layer pulls, Hyperliquid's outcome markets and Novig's game lines included (for Novig, `open_events` counts one per game and kind of line). `live_market_pairs` counts live matches with polymarket.com; `live_market_pairs_by_venue` counts them for each Polymarket, and doesn't count Hyperliquid or Novig yet. --- ## Errors | Status | `error` | When | |---|---|---| | 400 | `bad_request` | Missing or invalid query parameters or body; `detail` says which field. | | 401 | `unauthorized` | No key, or the key is unknown or revoked. `detail` says which, and `hint` where to get a key and how to send it. | | 403 | `venue_not_served` | Matches with that venue (`venue`) aren't available right now, such as Hyperliquid's or Novig's while they're switched off. | | 404 | `no_match` | `POST /v0/profit`: the `kalshi.market_id` you sent has no live match on the Polymarket you sent. `detail` gives the reason, `hint` how to check it. | | 404 | `not_found` | `/v0/webhooks/{id}`: no endpoint with that id on your account. | | 409 | `too_many_endpoints` | `POST /v0/webhooks`: you already have 5 endpoints. Delete one first. | | 409 | `no_example` | `POST /v0/webhooks/{id}/test`: there's no event of that type on the endpoint's venues to send as an example yet. | | 429 | `rate_limited` | Over 60 requests/minute. Wait `retry-after` seconds. | | 503 | `fees_unavailable` | `POST /v0/profit` with `kalshi.market_id`: a venue didn't answer when Layer asked for a market's fee. Try again, or send that fee yourself. | | 503 | `venue_unavailable` | Data from a venue is temporarily switched off (`venues` lists which), so matches are paused. | | 503 | `database_not_configured` / `database_unreachable` | Layer can't reach its database. | Every 400 says what's wrong and how to fix it: ```json { "error": "bad_request", "detail": "body: send settles_at or days_held, not both", "hint": "Send one or the other: \"settles_at\": \"2026-10-14\" if you know when the market settles, or \"days_held\": 4 if you know how many days your money is tied up.", "docs": "https://uselayer.sh/docs/api" } ``` | Field | Meaning | |---|---| | `detail` | What's wrong, field by field (`kalshi.price: …`), separated by `;` when there's more than one problem. Stable enough to match on. | | `hint` | How to fix it, in plain English with a working example. Show it to whoever wrote the call. Its wording can change. | | `docs` | This page. | --- ## Using Layer data Every `/v0/matches`, `/v0/match` and `/v0/settlements` answer includes an `attribution` line. Layer serves which markets are the same bet and how matches settled. Live prices, order books and orders go straight between your code and each venue, under your own account with that venue, and trading happens on each venue. When you show a market to your users, credit the venue and link to its `url`. Use Layer data inside your own product; don't republish it as a standalone feed or aggregator. The full terms are at [/terms](https://uselayer.sh/terms). --- # Pay per call (x402) An agent doesn't need a Layer account. It can call the API with no key and pay for each answer in USDC on Base, using [x402](https://x402.org). You get the same answer an account with a key gets, and you're charged only when the call succeeds: a `400` or any other error costs nothing. A [free key](https://uselayer.sh/dashboard) is still the cheaper way for anything you'll call often: it has no per-call price. ## Prices | Endpoint | What it answers | Price per call | |---|---|---| | `GET /v0/match` | One market's twin on another venue, with both venues' live prices | $0.02 | | `POST /v0/match` | The same for up to 50 markets | $0.25 | | `GET /v0/matches` | Every live match, with prices and the best trade | $0.10 | | `GET /v0/settlements` | How matched pairs settled on both venues | $0.05 | | `POST /v0/profit` | Profit after both venues' fees for prices you send | $0.01 | | `GET /v0/quote` | The most contracts of one matched market that clear your edge, from both live books | $0.02 | | `POST /v0/size` | The largest size that clears your edge, from order books you send | $0.02 | Every other endpoint needs a key. Inputs and answers are the same as in the [API reference](https://uselayer.sh/docs/api.md). ## How it works 1. Call the endpoint with no key. Layer answers `402 payment_required`, with the price, the network (Base) and where to pay in the `PAYMENT-REQUIRED` header. 2. Your x402 client signs a USDC payment for that amount and calls again with it in the `PAYMENT-SIGNATURE` header. 3. Layer answers. Only then is the payment settled; the `PAYMENT-RESPONSE` header carries the transaction. ```bash curl -i "https://uselayer.sh/v0/match?venue=kalshi&market_id=KXFEDDECISION-26DEC-H0" # HTTP/2 402 # payment-required: eyJ4NDAyVmVyc2lvbiI6Mi… ``` ## From code Any x402 client works. With [`@x402/fetch`](https://www.npmjs.com/package/@x402/fetch) and a wallet that holds USDC on Base: ```typescript import { wrapFetchWithPaymentFromConfig } from "@x402/fetch"; import { ExactEvmScheme } from "@x402/evm"; import { privateKeyToAccount } from "viem/accounts"; const account = privateKeyToAccount(process.env.EVM_PRIVATE_KEY as `0x${string}`); const fetchWithPayment = wrapFetchWithPaymentFromConfig(fetch, { schemes: [{ network: "eip155:8453", client: new ExactEvmScheme(account) }], }); const res = await fetchWithPayment("https://uselayer.sh/v0/matches?category=sports&q=nba"); console.log(await res.json()); ``` From an AI assistant, an MCP wallet such as [AgentCash](https://agentcash.dev) pays for you: ask it to call `https://uselayer.sh/v0/match` for a market. ## Where agents find Layer - [Coinbase's x402 Bazaar](https://docs.cdp.coinbase.com/x402/bazaar), the catalog agents search for paid APIs. - [x402scan](https://www.x402scan.com/server/ccdd3552-9376-4537-91f0-e112a8bb3f1b), which AgentCash reads. - [`/openapi.json`](https://uselayer.sh/openapi.json): every paid endpoint, its inputs, an example answer and its price. Questions, or a price that doesn't work for you: [david@uselayer.sh](mailto:david@uselayer.sh?subject=Pay%20per%20call). --- # Markets **Beta, early access.** These endpoints are open to selected accounts while they're tested, and may still change. `/v0/matches` is unchanged and stays the way to get Kalshi ↔ one-venue matches. A **Layer market** is one real-world question, like "Velez Sarsfield beat Union Santa Fe on Nov 6", with every venue's market for it side by side. Each venue's market is a **listing**. `/v0/matches` gives you Kalshi and one other venue per item, one venue per request. `/v0/markets` gives you every venue in one item. ## Reading a market Every answer below is a real one. Five things to look at: 1. **`question`**: the real-world question, in plain words. It's left out when a venue on the market doesn't allow Layer to show its wording, as on every market that includes polymarket.com (see [All fields](#all-fields)). 2. **`listings`**: one per venue that lists it, each with its `venue`, `market_id` and `url`. 3. **`basis`** on each listing: `identical` means the same bet. `equivalent_with_caveats` means the same question with a rule difference, named in `caveats` (such as `rounding_differs`). Keep only `identical` listings and you always have the same bet. 4. **`price`** on each listing: the venue's live best `bid` and `ask` for YES and NO, with sizes. 5. **`best_trade`**: the best "buy YES on one venue, NO on another" between identical listings, with fees. `best_trade_any` is the same between any two listings, rule differences included. ## GET /v0/markets Open markets listed on two or more venues that haven't ended, soonest first: by `start_time` for markets starting later or in the last 4 hours, else by `event_time` (a season-long market sorts by when it settles). ``` curl -H "Authorization: Bearer $LAYER_API_KEY" "https://uselayer.sh/v0/markets?category=crypto&from=2026-11-06&limit=1&prices=false" ``` ```json { "count": 1, "markets": [ { "id": "lyr_0117c14f6267", "event_id": "lyr_evt_185a779da3e5", "category": "crypto", "question": "ETH at or above $2,960 at 5 PM ET, Nov 6", "event_time": "2026-11-06T22:05:00.000Z", "status": "open", "listings": [ { "venue": "kalshi", "market_id": "KXETHD-26NOV0617-T2959.99", "url": "https://kalshi.com/markets/kxethd/x/kxethd-26nov0617", "series": "KXETHD", "basis": "identical", "caveats": [], "reference": true, "question": "Ethereum price at Nov 6, 2026 at 5pm EST?", "outcome": "$2,960 or above" }, { "venue": "polymarket_us", "market_id": "cpceth-eth-above-wk-2026-11-06-2960", "url": "https://polymarket.us/event/eth-above-wk-2026-11-06", "series": "eth-strike-weekly", "basis": "equivalent_with_caveats", "caveats": ["rounding_differs"], "question": "ETH Price at 5:00PM ET on Fri, Nov 6", "outcome": "$2,960 or above" } ] } ], "attribution": "Market data from Kalshi (kalshi.com) and Polymarket US (polymarket.us). Each market's url links to its page on the venue, where it's traded." } ``` Here Kalshi is the **reference** listing, and Polymarket US is the same question with one difference: the two venues round the price differently. | Query | Default | Meaning | |---|---|---| | `category` | all | `sports`, `crypto`, `politics`, `economics`, … | | `q` | none | Words that must all appear: `q=nfl`, `q=bitcoin`. | | `from` | not ended yet | `YYYY-MM-DD`, the first day (UTC). Without it, only markets that haven't ended: their `event_time` is now or later, or they have none. | | `to` | none | `YYYY-MM-DD`, the last day (UTC). `from` and `to` filter on the same time the list sorts by. | | `venues` | any | Listed on **all** of these: `venues=kalshi,polymarket_us`. Names: `kalshi`, `polymarket`, `polymarket_us`, `novig`, `hyperliquid`. | | `event_id` | none | Every market of one game or occasion. | | `limit`, `offset` | `50`, `0` | Page size (1–200) and how many to skip. | | `prices` | `true` | `false` leaves out prices and best trades, for a faster answer. | ## GET /v0/markets/{id} One market, with live prices and best trades. ``` curl -H "Authorization: Bearer $LAYER_API_KEY" "https://uselayer.sh/v0/markets/lyr_60a98333e4c6" ``` ```json { "market": { "id": "lyr_60a98333e4c6", "event_id": "lyr_evt_781c1f1f4743", "category": "sports", "question": "Union Santa Fe vs Velez Sarsfield: Velez Sarsfield wins", "event_time": "2026-11-07T01:00:00.000Z", "status": "open", "listings": [ { "venue": "kalshi", "market_id": "KXARGPREMDIVGAME-26NOV06UNIVEL-VEL", "url": "https://kalshi.com/markets/kxargpremdivgame/x/kxargpremdivgame-26nov06univel", "basis": "identical", "caveats": [], "reference": true, "outcome": "Velez Sarsfield", "price": { "yes": { "bid": { "price": 0.36, "size": 1262.4 }, "ask": { "price": 0.37, "size": 3437.12 } }, "no": { "bid": { "price": 0.63, "size": 3437.12 }, "ask": { "price": 0.64, "size": 1262.4 } } } }, { "venue": "polymarket_us", "market_id": "atc-lpa-uni-vel-2026-11-06-vel", "url": "https://polymarket.us/event/lpa-uni-vel-2026-11-06", "basis": "identical", "caveats": [], "outcome": "CA Vélez Sarsfield (Reg. Time)", "price": { "yes": { "bid": { "price": 0.35, "size": 1777 }, "ask": { "price": 0.37, "size": 1400.5 } }, "no": { "bid": { "price": 0.63, "size": 1400.5 }, "ask": { "price": 0.65, "size": 1777 } } } } ], "prices_as_of": "2026-11-06T03:26:37.655Z", "best_trade": { "kalshi": { "buy": "no", "price": 0.64, "size": 1262.4 }, "polymarket_us": { "buy": "yes", "price": 0.37, "size": 1400.5 }, "contracts": 1262, "cost": 1274.62, "fees": 40.8, "payout": 1262, "net_profit": -53.42, "net_profit_per_contract": -0.04233, "return_pct": -4.06, "profitable": false, "expected_payout_at": "2026-11-07T07:00:00.000Z", "days_held": 1.15, "listings": [ { "venue": "kalshi", "market_id": "KXARGPREMDIVGAME-26NOV06UNIVEL-VEL", "basis": "identical", "caveats": [] }, { "venue": "polymarket_us", "market_id": "atc-lpa-uni-vel-2026-11-06-vel", "basis": "identical", "caveats": [] } ] }, "best_trade_any": { "…": "the same trade as best_trade here: both listings are identical" } }, "attribution": "Market data from Kalshi (kalshi.com) and Polymarket US (polymarket.us). Each market's url links to its page on the venue, where it's traded." } ``` How to read it: - **The trade:** buy NO on Kalshi at 64¢ and YES on Polymarket US at 37¢, for 1,262 contracts. - **What it costs:** $1,274.62, plus $40.80 in fees, to get $1,262 back. That's a loss of $53.42, so `profitable` is `false`. Most of the time there's no free money; when there is, `profitable` is `true`. - **Shortened:** some fields are left out here to keep it short, such as each listing's `question`, `group_id` and `series`, and the trade's `latest_payout_at` and per-day returns. [All fields](#all-fields) lists them. Layer ids never change. If two markets are merged, the old id answers with the one it became, and `merged_from` says so. ## GET /v0/markets/lookup Find the Layer market from any venue's market id. ``` curl -H "Authorization: Bearer $LAYER_API_KEY" "https://uselayer.sh/v0/markets/lookup?venue=kalshi&market_id=KXARGPREMDIVGAME-26NOV06UNIVEL-VEL" ``` This answers with the same `market` as above. When there's no Layer market for it, `market` is `null` and `reason` says why: ```json { "market": null, "reason": "no_twin_yet", "detail": "Layer knows this market, but no other venue it serves lists the same question yet." } ``` | `reason` | Meaning | |---|---| | `no_twin_yet` | No other venue lists the same question yet, or a new match hasn't reached Layer markets yet (they're updated in batches, so they can lag `/v0/matches`). | | `under_review` | Its listing is held while its match or settlement is checked. | | `unknown_market` | Layer hasn't read that market id. | | `venue_not_served` | That venue isn't served right now. | For polymarket.com, the condition id, the numeric id or the slug all work: `` for a Yes/No market, `:` for one outcome of a multi-outcome market. ## Moving from /v0/matches Nothing changes unless you want it to. `/v0/matches` stays as it is. - **One item instead of several.** One `/v0/markets` item is all the `/v0/matches` items that share a Kalshi market, across venues. Your Kalshi ↔ Polymarket US pair is the market's `kalshi` listing plus its `polymarket_us` listing. - **Basis per listing.** `basis` and `caveats` are on each listing, compared with the reference, instead of on the pair. - **Find yours.** Have a match from `/v0/matches`? Call `/v0/markets/lookup?venue=kalshi&market_id=`. - **Lag.** Layer markets can lag `/v0/matches` for new matches. ## Errors | Status | `error` | When | |---|---|---| | 400 | `bad_request` | A filter Layer can't read; `detail` says which. | | 401 | `unauthorized` | No key, or the key is unknown or revoked. | | 403 | `markets_not_enabled` | Your account doesn't have early access yet. | | 404 | `not_found` | `/v0/markets/{id}`: no Layer market with that id. | | 429 | `rate_limited` | Over 60 requests/minute, as for every `/v0` endpoint. Wait `retry-after` seconds. | | 503 | `markets_unavailable` | Briefly, right after a deploy. Try again in a few minutes. | ## Not yet - **Live 15-minute crypto windows.** They're in `/v0/matches`. `/v0/markets` has crypto price levels, but not yet the short windows. - **Markets Kalshi doesn't list.** Every Layer market today includes a Kalshi listing. - **Settled markets** (`/v0/markets/settled`), Gemini, and crypto price ranges. - **Hyperliquid best trades.** Hyperliquid listings get a price but no best trade: its fees aren't modelled. ## All fields | Field | Meaning | |---|---| | `id`, `event_id` | The market's Layer id, and its game's or occasion's. | | `status` | `open`, `closed` (trading over) or `settled`. The list returns only open markets; `/{id}` and lookup return any. | | `question`, `start_time`, `event_time` | Shown when every venue on the market allows Layer to show its data; otherwise left out. On a market that includes polymarket.com, they're missing. `start_time`: when the game or window starts (ISO 8601), or `null` when Layer doesn't know it yet. `event_time`: for a game, the venue's expected settle time, a few hours after the start; for a crypto window, its end. | | `reference_hidden` | `true` when the reference listing isn't shown (its venue isn't served, or it's under review). The other listings' `basis` still holds. | | `listings[].reference` | `true` on the listing the others are compared with: Kalshi's when it's there. | | `listings[].question`, `outcome` | The venue's own wording, where that venue allows it. | | `listings[].group_id`, `series`, `slug` | The venue's event, series and (for polymarket.com) slug. | | `listings[].price` | `null` only when that venue's prices couldn't be read; a side with no order has a `null` bid or ask (an ended game shows them all `null`). | | `prices_unavailable` | At the top of the answer: venues whose prices couldn't be read this time. | | `best_trade`, `best_trade_any` | `null` when no pair has both asks. Priced like [`POST /v0/profit`](https://uselayer.sh/docs/api.md#post-v0-profit). | | `attribution` | One sentence naming every venue on the answer once. Show it wherever you show the data. | --- # Sports spreads and lag **Beta.** These routes are new and may still change. Three routes for game winners in the NBA, NFL, NHL and NCAAF, on Kalshi and Polymarket US: - [`/v0/sports/spreads`](#get-v0-sports-spreads): the spread between the two venues for every game right now, after both venues' taker fees. - [`/v0/sports/lead`](#get-v0-sports-lead): over the last days, which venue moved first, how often the other followed, and how long it took. - [`/v0/sports/events`](#get-v0-sports-events): one day's recorded spreads and moves, one by one, to check against your own bot. See all three on one page at [uselayer.sh/sports](https://uselayer.sh/sports). Every row there shows the call that returns it. Only games where both venues list the identical market are included. A pair still under review is left out. ## GET /v0/sports/spreads One row per game that's live or starts within 24 hours: of its two questions, one per team, the one with the better trade. Closest to a profit first, so the list is never empty. ``` curl -H "Authorization: Bearer $LAYER_API_KEY" "https://uselayer.sh/v0/sports/spreads?league=nba" ``` ```json { "as_of": "2026-11-10T22:00:00.123Z", "games": [ { "event_id": "lyr_evt_…", "market_id": "lyr_…", "league": "nba", "title": "…", "question": "…: … wins", "starts_at": "…", "status": "live", "kalshi": { "ticker": "…", "yes_bid": 0.39, "yes_ask": 0.40, "ask_size": 100, "as_of": "…" }, "polymarket_us": { "slug": "…", "yes_bid": 0.46, "yes_ask": 0.47, "ask_size": 300, "as_of": "…" }, "direction": "kalshi_yes_pmus_no", "edge_cents": 1.96, "arbitrage": true, "contracts": 100, "net_dollars": 1.96 } ] } ``` | Query | Default | Meaning | |---|---|---| | `league` | all four | `nba`, `nfl`, `nhl` or `ncaaf`. | | Field | Meaning | |---|---| | `event_id`, `market_id` | Layer's ids for the game and for its market, the same as in [`/v0/markets`](https://uselayer.sh/docs/markets.md). Before Layer has listed the game, `event_id` is Kalshi's event ticker and `market_id` is `null`. | | `question` | What YES means on both venues: which team wins. | | `status` | `live` or `upcoming`. | | `kalshi`, `polymarket_us` | Each venue's best YES bid and ask in dollars, the size at the ask, and `as_of`, when Layer read that venue's book. | | `direction` | The cheaper way round: `kalshi_yes_pmus_no` buys YES on Kalshi and NO on Polymarket US; `pmus_yes_kalshi_no` is the reverse. | | `edge_cents` | Profit per contract in cents at both best asks, after both venues' taker fees (before the venues round fees to the cent). Below zero is a loss. | | `arbitrage` | `true` when at least one whole contract makes a profit after both venues' fees, rounded up to the cent as the venues charge them. It can be `false` while `edge_cents` is just above zero, when the rounding eats the edge. | | `contracts` | How many contracts you could buy that way round and still make a profit, walking both books. | | `net_dollars` | The profit on those contracts, after fees. | Books are cached for 5 seconds per game. ## GET /v0/sports/lead Over the last `days` days: for each pair of venues, how often one moved first, how often the other followed, and how long it took. Plus how often spreads appeared and how long they lasted. ``` curl -H "Authorization: Bearer $LAYER_API_KEY" "https://uselayer.sh/v0/sports/lead?league=nba&days=7" ``` ```json { "league": "nba", "days": 7, "from": "2026-11-03", "to": "2026-11-09", "days_done": 7, "move_days": 7, "games": 0, "pairs": [ { "leader": "kalshi", "follower": "polymarket_us", "moves": 0, "follow_rate": 0, "together": 0, "together_rate": 0, "lag_ms_p50": 0, "lag_ms_p90": 0, "lags_2s_plus": 0, "lags_5s_plus": 0 } ], "spreads": { "per_day": 0, "length_ms_p50": 0, "length_ms_p90": 0, "cents_p50": 0, "over_1s": 0 } } ``` | Query | Default | Meaning | |---|---|---| | `league` | all four | `nba`, `nfl`, `nhl` or `ncaaf`. | | `days` | `7` | How many days back (1 to 30), ending on the last day Layer has processed. A day is the games that started on it, US Eastern. | | Field | Meaning | |---|---| | `from`, `to` | The first and last day counted. `null` before the first day is processed. | | `days_done` | Days in that range Layer has processed. | | `move_days` | Of those, days whose moves are counted once per move. `pairs` comes from these days only. Days processed before 12 November 2026 counted each move twice, so their moves are left out; their spreads still count. | | `games` | Games recorded in those days. | | `pairs[].moves` | Price moves `leader` made first. A move is counted once: either venue's mid price moved 3¢ or more within 2 seconds, and the leader is the venue whose price got 2¢ past where it was 2 seconds earlier first. Both venues need a price. | | `pairs[].follow_rate` | Share of those moves the `follower` also made: 2¢ or more the same way within 30 seconds, counting `together`. | | `pairs[].together` | Moves the `follower` made within 1 second of the `leader`. Each venue's updates can reach Layer up to about 0.1 seconds apart, so these count as simultaneous, not as a lead. | | `pairs[].together_rate` | `together` as a share of `moves`. When it is high, the two venues mostly move at the same time and neither leads. | | `pairs[].lag_ms_p50`, `lag_ms_p90` | Median and 90th-percentile time the `follower` took, in milliseconds, over every follow, `together` included (moves in the same millisecond left out; `null` if none). | | `pairs[].lags_2s_plus`, `lags_5s_plus` | Follows that took 2 seconds or more, and 5 seconds or more. | | `spreads.per_day` | Spreads that cleared both venues' fees, per day. Spreads less than 250 ms apart count as one. | | `spreads.length_ms_p50`, `length_ms_p90` | Median and 90th-percentile time a spread stayed open, in milliseconds. | | `spreads.cents_p50` | Median profit per contract after fees, in cents, taking each spread at its best moment. | | `spreads.over_1s` | Spreads that stayed open for 1 second or more. | The numbers come from Layer's recordings of both venues' order books, processed each night at about 10:15 UTC. Each game is read from an hour before its start to the end of play. A move is measured on one team's price; the other team's is the same market the other way up. When a venue's recording goes quiet for more than 5 seconds (an outage of the feed or of Layer's recorder), its prices count as unknown until it resumes, so no spread or move is measured across the gap. The full method, findings and limits: [How we measure sports gaps](https://uselayer.sh/docs/sports-method.md). ## GET /v0/sports/events One day's recorded spreads and moves, each with its time, so you can line them up against your own logs. ``` curl -H "Authorization: Bearer $LAYER_API_KEY" "https://uselayer.sh/v0/sports/events?league=nba&date=2026-11-09" ``` ```json { "league": "nba", "date": "2026-11-09", "status": "done", "spreads": [ { "event_id": "lyr_evt_…", "market_id": "lyr_…", "league": "nba", "title": "…", "question": "…: … wins", "started_at": "…", "length_ms": 0, "edge_cents": 0, "contracts": 0, "dollars": 0 } ], "moves": [ { "event_id": "lyr_evt_…", "market_id": "lyr_…", "league": "nba", "title": "…", "question": "…: … wins", "at": "…", "leader": "kalshi", "follower": "polymarket_us", "move_cents": 3, "followed": true, "lag_ms": 0, "together": true } ] } ``` | Query | Default | Meaning | |---|---|---| | `league` | all four | `nba`, `nfl`, `nhl` or `ncaaf`. | | `date` | required | `YYYY-MM-DD`: games that started that day, US Eastern. | | Field | Meaning | |---|---| | `status` | `done` once Layer has processed the day; `running`, `failed` or `not processed` otherwise, with empty lists. | | `event_id`, `market_id`, `title`, `question` | The game and the team's market, as in `/v0/sports/spreads`. | | `spreads[]` | Each spread that cleared both venues' fees: when it opened (`started_at`), how long it stayed open (`length_ms`), the best profit per contract while it was open (`edge_cents`), and the number of contracts that made the most profit (`contracts`) with that profit (`dollars`). Spreads on one game less than 250 ms apart count as one. | | `contracts`, `dollars` | On paper: what buying `contracts` on both books at that moment would have locked, after fees. Most spreads close within a fraction of a second, often before an order could reach both venues, so a real fill is usually smaller or none. | | `moves[]` | Each price move of 3¢ or more within 2 seconds, counted once: which venue moved first (`leader`), by how much, whether the other followed within 30 seconds (`followed`), after how long (`lag_ms`, `0` when both moved in the same millisecond), and whether that was within 1 second (`together`). Days processed before 12 November 2026 list no moves. | ## Worked example Kalshi's best YES ask on a game is 40¢ for 100 contracts. Polymarket US's best NO ask on the same game is 54¢, also for 100 contracts. YES on one venue plus NO on the other pays $1 whoever wins. | | Kalshi YES | Polymarket US NO | Total | |---|---|---|---| | Cost of 100 contracts | $40.00 | $54.00 | $94.00 | | Taker fee | $1.68 | $2.36 | $4.04 | | Payout | | | $100.00 | | **Profit** | | | **$1.96** | `/v0/sports/spreads` returns this game with `direction: "kalshi_yes_pmus_no"`, `edge_cents: 1.96`, `arbitrage: true`, `contracts: 100` and `net_dollars: 1.96`. The fees are each venue's published taker fee: Kalshi's is 0.07 × contracts × price × (1 − price), rounded up to the cent. Polymarket US's is its fee coefficient (0.095 at the lowest volume tier) × contracts × price × (1 − price). A Polymarket US NO ask is 1 minus its YES bid, so a YES bid of 46¢ is a NO ask of 54¢. A smaller gap can lose money. At 42¢ + 55¢ the gap is 3¢, but the fees are about 4.1¢ per contract, so `edge_cents` is −1.06 and `arbitrage` is `false`. --- # Stream **Beta.** The stream may still change, and may go quiet while it's worked on. One WebSocket pushes every top-of-book change for game winners in the NBA, NFL, NHL and NCAAF, on Kalshi and Polymarket US, with the spread after both venues' taker fees each way round. A page that refreshes every few seconds misses most cross-venue spreads; the stream sends each change as Layer receives it. ``` wss://stream.uselayer.sh/v0/stream ``` Send your Layer API key as `Authorization: Bearer ` (or `x-api-key`). A browser can't set headers, so it can send `?api_key=` instead. Each key can hold 5 connections at once. ## Subscribe Choose games when you connect, or later with a message: | Connect to | You get | |---|---| | `/v0/stream` | All four leagues | | `/v0/stream?league=nba,nhl` | Those leagues (`nba`, `nfl`, `nhl`, `ncaaf`) | | `/v0/stream?event_id=` | One game (both teams' markets); comma-separate several | | `/v0/stream?coin=btc,eth,sol` | 15-minute crypto windows for those coins (see below) | ```json {"type": "subscribe", "league": "nfl"} {"type": "subscribe", "event_id": "KXNBAGAME-26NOV10MILNYK"} {"type": "subscribe", "coin": "btc"} {"type": "unsubscribe", "league": "nhl"} ``` The games are those Layer has matched as identical on both venues, live or starting within 24 hours. A game appears when it's matched and leaves when it settles; you get a new `markets` message each time your list changes. ## Crypto windows Ask for a coin (`btc`, `eth` or `sol`) to get its 15-minute up-or-down window on Kalshi and Polymarket US. You get crypto only when you ask for a coin; `/v0/stream` with no filter stays sports only, and `?league=nba&coin=btc` gets both. Each window is one market: YES means the price is up at the end of the window, on both venues. Kalshi opens a window only when it starts, so a window joins the stream about 20 seconds after it opens and leaves at its end. You get a `markets` message each time one window gives way to the next. A crypto `book` message has `coin`, `window_start` and `window_end` in place of `league`. Its `event_id` is Kalshi's event ticker for the window (`KXBTC15M-26NOV121245`), and `polymarket_us.slug` is the window's market (`cpc-btc-updown-15m-2026-11-12-1730z`). Polymarket US and Kalshi only: the stream doesn't carry polymarket.com. ## Messages Every message is one JSON object with a `type`. **`markets`**: the markets your subscription covers, sent when you connect, after each subscribe or unsubscribe, and when a game is added or ends. A game is two markets, one per team. **`book`**: one market's best prices on both venues. You get one for each market when you subscribe, then one each time either venue's best bid or ask changes, in price or size. ```json { "type": "book", "market_id": "lyr_fd3b51298cd3", "event_id": "lyr_evt_54357dd135a3", "league": "ncaaf", "question": "Ohio vs Miami (OH): Miami (OH) wins", "kalshi": { "ticker": "KXNCAAFGAME-26NOV10OHIOMOH-MOH", "yes_bid": 0.49, "yes_ask": 0.5, "bid_size": 45302.09, "ask_size": 217132.88, "received_at": "2026-11-10T17:30:20.325Z" }, "polymarket_us": { "slug": "aec-cfb-ohio-miaoh-2026-11-10:short", "yes_bid": 0.49, "yes_ask": 0.495, "bid_size": 365457.38, "ask_size": 305316.01, "received_at": "2026-11-10T17:30:10.982Z" }, "edge_cents": { "kalshi_yes_pmus_no": -5.12, "pmus_yes_kalshi_no": -4.62 }, "stale_cents": { "kalshi": 0.25, "polymarket_us": -0.25 } } ``` | Field | Meaning | |---|---| | `market_id` | The Layer market for this team's win, as in [Markets](https://uselayer.sh/docs/markets.md). `null` for a game Layer hasn't made a market for yet. | | `event_id` | The game: its Layer event id, or Kalshi's event ticker until Layer has one. Both teams' markets share it. | | `league` | `nba`, `nfl`, `nhl` or `ncaaf`. | | `question` | What YES pays on, on both venues: this team wins. | | `kalshi.ticker` | Kalshi's market for this team. | | `polymarket_us.slug` | Polymarket US's market and side. Polymarket US lists one market per game: `:long` is its YES, `:short` its NO, so YES here is always this team winning. | | `yes_bid`, `yes_ask` | The venue's best bid and ask for YES, in dollars. `null` when that side is empty. Kalshi's YES ask is 1 minus its best NO bid. | | `bid_size`, `ask_size` | Contracts at those prices. | | `received_at` | When Layer received that venue's latest change to this book (UTC). The venue's own message time isn't sent. | | `edge_cents.kalshi_yes_pmus_no` | Profit per contract, in cents, from buying YES on Kalshi and NO on Polymarket US at the best asks, after both venues' taker fees. Positive means both legs together cost less than the $1 they pay. `null` when a side has no asks. | | `edge_cents.pmus_yes_kalshi_no` | The same the other way round. | | `stale_cents` | How far each venue's mid (halfway between bid and ask) is from the other's, in cents. | The edge is at the best prices only, for one contract. For the size you can trade at a positive edge, send both books to [`POST /v0/size`](https://uselayer.sh/docs/api.md#post-v0-size), or read [`GET /v0/quote`](https://uselayer.sh/docs/api.md#get-v0-quote). Venue prices and sizes (`yes_bid`, `yes_ask`, `bid_size`, `ask_size`) come with keys that have live price access. Other keys get every other field. **`heartbeat`**: every 5 seconds, with each venue's feed `up` or `down`. If none arrives for 15 seconds, reconnect. ```json {"type": "heartbeat", "at": "2026-11-10T22:00:05.000Z", "venues": {"kalshi": "up", "polymarket_us": "up"}} ``` **`gap`**: a venue's feed dropped, so no message for that venue's books doesn't mean they didn't change. `since` is the last message Layer received from it. **`resumed`**: the venue is back. Each of its markets gets a fresh `book` message. ```json {"type": "gap", "venue": "kalshi", "since": "2026-11-10T22:00:00.000Z"} {"type": "resumed", "venue": "kalshi", "since": "2026-11-10T22:00:00.000Z", "until": "2026-11-10T22:00:03.000Z"} ``` **`error`**: a message Layer couldn't use, with `error` and `detail`. The connection stays open. A connection is refused before it opens with `401` (no key or an unknown key), `400` (an unknown league), or `429` (more than 5 connections for the key). A client that reads too slowly for messages to keep up is disconnected. ## Example ```python import asyncio, json, os import websockets # pip install websockets async def main(): url = "wss://stream.uselayer.sh/v0/stream?league=nba" headers = {"Authorization": f"Bearer {os.environ['LAYER_API_KEY']}"} async with websockets.connect(url, additional_headers=headers) as ws: async for raw in ws: m = json.loads(raw) if m["type"] == "book": best = max(m["edge_cents"].values(), key=lambda e: -1e9 if e is None else e) print(m["question"], best) elif m["type"] in ("gap", "resumed"): print(m) asyncio.run(main()) ``` To trade an edge, your code still places both orders itself, with your own venue keys. Books can change between the message and your orders. --- # Sandbox Practice trading before you risk money. Orders fill against Polymarket US's and Kalshi's live order books with each venue's real fees, and nothing is sent to either venue. Each account starts with **$10,000** of simulated cash. Use it three ways: - **On the web:** [uselayer.sh/sandbox](https://uselayer.sh/sandbox). Search a Polymarket US market, buy or sell, and watch your positions and profit. - **Over the API:** the endpoints below, with your API key. Kalshi and [pairs](#post-v0-sandbox-pairs) are API only. The web page and the API share one account. - **On your own machine:** the Python SDK's [paper mode](#on-your-own-machine), with nothing sent to Layer. The [Python SDK](https://uselayer.sh/docs/sdk.md)'s `mode="sandbox"` sends Polymarket US orders only; use the API for Kalshi and pairs. ## On your own machine The Python SDK's **paper mode** runs the same kind of practice trading locally: it reads the venue's live order book, fills your orders against it with the venue's real fees, and sends nothing to the venue or to Layer. ```python from uselayer import Client client = Client() # paper mode is the default ``` - [Paper trade](https://uselayer.sh/docs/sdk.md#paper-trade): the full example. - [One strategy, every mode](https://uselayer.sh/docs/sdk.md#one-strategy-every-mode): the same code in backtest, paper and live. - [What paper mode can't tell you](https://uselayer.sh/docs/sdk.md#what-paper-mode-can-t-tell-you): where simulated fills and real ones differ. - [Spread on GitHub](https://github.com/uselayer/layer-spread-bot): an example bot that runs in paper mode. Clone it and run it ([how it works](https://uselayer.sh/docs/spread.md)). ## How orders fill - Every order has a limit price. A buy takes the cheapest offers up to your price, level by level; a sell takes the highest bids down to your price. You never pay more, or sell for less, than your limit. - Each level is charged the venue's taker fee on its own, the same way [`POST /v0/size`](https://uselayer.sh/docs/api.md#post-v0-size) prices fills: Polymarket US's `feeCoefficient` × C × p × (1 − p) to the cent, or Kalshi's 0.07 × the series' `fee_multiplier` × C × P × (1 − P), rounded up to the cent. - Orders fill at once. Whatever the book can't fill at your price is canceled (`tif: "ioc"`, the default), or the whole order is (`tif: "fok"`, all or nothing). Orders that wait in the book aren't in the sandbox yet. - **No:** buying NO at 60¢ fills against YES bids at 40¢, as on the venue. - **Kalshi** lists only bids, YES bids and NO bids, so a YES offer is a NO bid turned round: a NO bid at 60¢ is a YES offer at 40¢. Kalshi takes fractional contracts in steps of 0.01. - Positions are valued at the best bid: what you could sell them for now. When the venue settles a market, each contract pays what the venue paid ($1 if it won, $0 if it lost, or Kalshi's fair price if a game is canceled) and the position closes. - Fills can look better than they would live: the sandbox can't know where your order would wait in line, or how fast the venue would answer. ## Safety checks The sandbox runs the same always-on checks as the SDK, so an order it accepts is one the SDK would send: | Check | Rejects | |---|---| | Price collar | A limit more than 5¢ past the best price (above the best offer for a buy, below the best bid for a sell) | | Tick size | A price that isn't a multiple of the market's tick (often $0.01, sometimes $0.001) | | Minimum size | Fewer contracts than the market's minimum | | Size step | Kalshi: a size that isn't a multiple of 0.01 contracts | | Holdings | Selling more than you hold. To bet the other way, buy the other side | | Cash | A buy that costs more, with fees, than your cash | | Open market | Buying in a market that isn't open. You can still sell | ## POST /v0/sandbox/orders Places an order. The body is the [Python SDK](https://uselayer.sh/docs/sdk.md)'s order shape (the fields below), so the SDK can send its own orders as they are. ```bash curl -X POST -H "authorization: Bearer $LAYER_API_KEY" -H "content-type: application/json" \ "https://uselayer.sh/v0/sandbox/orders" \ -d '{"venue":"polymarket_us","market":"aec-nfl-buf-pit-2026-10-04","side":"yes","action":"buy","price":0.43,"size":150}' ``` | Field | | |---|---| | `venue` | `"polymarket_us"` or `"kalshi"` | | `market` | Polymarket US: the slug, as in the market's URL, or `:long` / `:short` for a market with two named sides. Kalshi: the ticker, e.g. `KXMLBGAME-26OCT062130MILSD-SD` | | `side` | `"yes"` or `"no"` | | `action` | `"buy"` (default) or `"sell"` | | `price` | Your limit in dollars per contract, above 0 and below 1 | | `size` | Contracts | | `tif` | `"ioc"` (default) or `"fok"` | | `client_id` | Optional. Send the same order again with the same one (after a timeout, say) and you get the first order back (`repeated: true`) instead of a second fill. The same `client_id` with a different order answers 409 `client_id_reused`. Defaults you spell out, key order and the SDK's result fields (`id`, `status`, `filled`, …) don't make it a different order | The answer (201, or 200 with `repeated: true` for a repeated `client_id`) is the order, each fill and your cash after it. On a book with 100 offered at 42¢ and 300 at 43¢: ```json { "order": { "id": "sbx_4f0c…", "client_id": "…", "venue": "polymarket_us", "market": "aec-nfl-buf-pit-2026-10-04", "side": "yes", "action": "buy", "price": 0.43, "size": 150, "tif": "ioc", "mode": "sandbox", "status": "filled", "filled": 150, "avg_price": 0.423333, "fees": 2.54, "fee_estimate": 2.56, "book_as_of": "2026-10-01T18:30:02.000Z", "created_at": "2026-10-01T18:30:02.410Z" }, "fills": [ { "price": 0.42, "contracts": 100, "cost": 42, "fee": 1.69 }, { "price": 0.43, "contracts": 50, "cost": 21.5, "fee": 0.85 } ], "cash": 9933.96, "note": null, "repeated": false } ``` `status` is `filled`, or `canceled` when some or all of it couldn't fill (`filled` says how much did, and `note` why). `fee_estimate` is the fee on the whole order at its limit price; `fees` is what the fills actually cost. ## POST /v0/sandbox/pairs Buys both sides of a [match](https://uselayer.sh/docs/api.md#get-v0-matches) between Kalshi and Polymarket US: YES on one venue and NO on the other, which pays $1 a contract however the event settles. If one side fills and the other doesn't, the sandbox does what the SDK's leg-risk guard does, and tells you which of four things happened. ```bash curl -X POST -H "authorization: Bearer $LAYER_API_KEY" -H "content-type: application/json" \ "https://uselayer.sh/v0/sandbox/pairs" \ -d '{"kalshi":"KXMLBGAME-26OCT062130MILSD-SD","max_cost_per_leg":50,"min_edge":0.01}' ``` | Field | | |---|---| | `kalshi` | The Kalshi ticker of a live match: `kalshi.market_id` in [`GET /v0/matches?venue=polymarket_us`](https://uselayer.sh/docs/api.md#get-v0-matches) | | `polymarket_us` | Optional. The matched Polymarket US market id, checked against Layer's match. Leave it out and Layer uses the match | | `size` | Contracts wanted, a whole number | | `max_cost_per_leg` | The most either leg may cost, with fees, in dollars. Send `size`, this, or both | | `min_edge` | The smallest profit per contract after both fees you'll take, in dollars. Default 0.01 (1¢) | | `max_position` | Optional. The most contracts you may hold on each leg's market and side after the pair, counting what you hold already | | `on_miss` | `"unwind"` (default): sell an unmatched first leg back. `"hold"`: keep it | | `max_unwind_loss` | How far below its entry price an unmatched leg may be sold back, in dollars per contract. Default 0.05 | | `first` | Optional. `"kalshi"` or `"polymarket_us"`: the leg to send first. By default the thinner book goes first | | `client_id` | Optional. Send the same pair again with the same one and you get the first pair back (`repeated: true`) instead of a second trade. The same `client_id` with a different pair answers 409 `client_id_reused` | | `what_if` | Optional. Books you build, to see what happens when a side doesn't fill (below) | How it runs: 1. Layer reads both books and sizes the pair both ways round (Kalshi YES with Polymarket US NO, and the reverse) with the same walk as [`POST /v0/size`](https://uselayer.sh/docs/api.md#post-v0-size), keeping the better. It stops at `size`, at each leg's `max_cost_per_leg` and `max_position`, and never goes more than 5¢ past either best offer. Both legs are checked together; if either fails a [safety check](#safety-checks), nothing is sent (422). 2. If no contract clears `min_edge` after both fees, nothing is sent. 3. The thinner leg goes first, fill-what-you-can. If none of it fills, the second isn't sent. 4. The second leg goes for what the first filled, up to its break-even: the highest price at which the pair still clears `min_edge` after its fee, within the collar and `max_cost_per_leg`. 5. If the second leg falls short, the extra first-leg contracts are sold back, never below their entry price minus `max_unwind_loss`. The answer's `outcome`: | `outcome` | What happened | Look at | |---|---|---| | `hedged` | Both legs filled | `hedged` (contracts) and `locked_in`: what they pay at settlement after their cost and fees | | `missed` | Nothing was traded: the edge didn't clear `min_edge`, a cap left no room, or the first leg found nothing | `quote.edge_at_best` and `explanation` | | `unwound` | One leg filled and the other didn't, so the first was sold back | `unwind_cost`: what that cost with fees | | `one_sided` | The first leg couldn't all be sold back (no bid above the floor, or `on_miss: "hold"`) | `exposure`: the contracts you hold on one side, what they cost and are worth now. `cost_with_fees` is what you lose if it settles against you | Every answer has an `explanation` in plain English you can show as it is, the `quote` it was sized on (with the match's `best_trade` at the top of both books, as [`GET /v0/match`](https://uselayer.sh/docs/api.md#get-v0-match) shows it), every order sent (`group_id` is the pair's id), and `notes`. The orders show in `GET /v0/sandbox/orders` too. ### Worked example On books like these: Kalshi has YES offered at 40¢ (its best NO bid is 60¢) and Polymarket US has NO offered at 54¢ (its best YES bid is 46¢). That's 6¢ before fees, a contract: - Kalshi's fee: 0.07 × 0.40 × 0.60 = 1.68¢ - Polymarket US's fee: 0.095 × 0.54 × 0.46 = 2.36¢ (its markets' `feeCoefficient`, the Tier 1 rate) - Edge after fees: 6 − 1.68 − 2.36 = **1.96¢**, which clears a 1¢ `min_edge` `{"kalshi": "…", "size": 10}` buys 10 YES on Kalshi for $4.00 plus a $0.17 fee (0.168 rounded up), then 10 NO on Polymarket US for $5.40 plus $0.24 (0.236, half to even). The pair pays $10 whichever side wins, so it's `hedged`, and `locked_in` is $10 − $9.81 = **$0.19**: ```json { "pair": { "id": "pair_9b2…", "client_id": "…", "outcome": "hedged", "explanation": "Both legs filled: 10 contracts hedged (Kalshi YES + Polymarket US NO), locking in $0.19 after fees, paid when the match settles.", "what_if": false, "kalshi": "KX…", "polymarket_us": "aec-…", "first_leg": "kalshi", "hedged": 10, "locked_in": 0.19, "unwind_cost": 0, "exposure": null, "quote": { "contracts": 10, "limited_by": "size", "min_edge": 0.01, "edge_at_best": 0.019602, "net_profit": 0.19, "kalshi": { "side": "yes", "best_price": 0.4, "limit_price": 0.4, "…": "…" }, "polymarket_us": { "side": "no", "best_price": 0.54, "…": "…" }, "best_trade": { "…": "…" } }, "orders": [ { "venue": "kalshi", "side": "yes", "filled": 10, "avg_price": 0.4, "fees": 0.17, "…": "…" }, { "venue": "polymarket_us", "side": "no", "filled": 10, "avg_price": 0.54, "fees": 0.24, "…": "…" } ], "notes": [], "books": { "kalshi": "live", "polymarket_us": "live" } }, "cash": 9990.19, "repeated": false } ``` Now say Polymarket US's offers vanish before the second leg arrives. Kalshi's best YES bid is 38¢, and the floor is the higher of 40¢ − `max_unwind_loss` (35¢) and 38¢ − the 5¢ collar (33¢), so the 10 are sold back at 38¢ with a $0.17 fee (0.07 × 10 × 0.38 × 0.62 = 0.165, rounded up). The outcome is `unwound`, and `unwind_cost` is 10 × 2¢ + $0.17 + the first leg's $0.17 = **$0.54**. If Kalshi's best bid had been 30¢, under the floor, they'd stay unsold: `one_sided`, with `exposure` showing 10 YES costing $4.17 with fees. ### What if a side doesn't fill? Live books rarely leave a pair half done, so `what_if` lets you build the books yourself and watch what the pair does. Each book is for the market's YES side, as its id names it: `{"bids": [{"price": 0.38, "size": 100}], "asks": [{"price": 0.40, "size": 100}]}`. - `what_if.books.kalshi` / `.polymarket_us`: the book used for the quote and the first leg, instead of the live one. - `what_if.then.kalshi` / `.polymarket_us`: the book an order meets after the first leg (the second leg, or the sell-back). Without it, Layer uses `what_if.books`, else reads the live book again. ```json { "kalshi": "KX…", "size": 10, "what_if": { "then": { "polymarket_us": { "asks": [] }, "kalshi": { "bids": [{ "price": 0.30, "size": 100 }] } } } } ``` Here Polymarket US has nothing to sell when the second leg arrives, and Kalshi's only bid is below the floor, so the answer is `one_sided`. A what-if pair is answered like any other (`"what_if": true`) but **never changes your account**. ### GET /v0/sandbox/pairs Your pairs, newest first, each as `POST` answered it. `limit` is 1–200 (default 50). ## POST /v0/sandbox/preview The same body as an order. Says what the order would fill now, its fees, and every check it would fail, and changes nothing. ```json { "allowed": false, "problems": [{ "code": "price_collar", "detail": "Limit 0.48 is more than 0.05 above the best ask 0.42.", "hint": "Use a limit of at most 0.47." }], "estimate": { "filled": 10, "avg_price": 0.42, "cost": 4.2, "fees": 0.17, "fills": [{ "price": 0.42, "contracts": 10, "cost": 4.2, "fee": 0.17 }], "note": null }, "fee_estimate": 0.17, "book_as_of": "…", "cash": 10000 } ``` ## GET /v0/sandbox Your account: cash, each position valued at the best bid now, and profit or loss since you started. Positions whose market has settled are paid out first. ```json { "mode": "sandbox", "cash": 9933.96, "positions_value": 60, "total": 9993.96, "starting_cash": 10000, "pnl": -6.04, "started_at": "2026-10-01T18:29:00.000Z", "positions": [ { "venue": "polymarket_us", "market": "aec-nfl-buf-pit-2026-10-04", "side": "yes", "question": "Bills vs. Steelers", "contracts": 150, "avg_price": 0.423333, "cost": 63.5, "fees": 2.54, "price_now": 0.4, "value": 60, "pnl": -6.04, "opened_at": "…" } ] } ``` A position's `pnl` is `value − cost − fees`. When its book can't be read, `price_now`, `value` and `pnl` are `null` and `total` counts it at cost. ## GET /v0/sandbox/orders Your orders, newest first. `limit` is 1–200 (default 50). ## GET /v0/sandbox/markets Open markets to trade, soonest first: Polymarket US, or Kalshi with `venue=kalshi`. `q` searches events, questions and outcomes (every word must match), and `limit` is 1–50 (default 20). ```json { "markets": [{ "venue": "polymarket_us", "market": "aec-nfl-buf-pit-2026-10-04", "event": "Bills vs. Steelers", "outcome": "Bills", "event_time": "2026-10-04T17:00:00.000Z", "close_time": "…" }] } ``` ## POST /v0/sandbox/reset Starts again: every order, fill, pair and position goes, and cash is back to $10,000. Answers with the account. ## Errors As everywhere in the API, each error has a `detail`, and a `hint` except on 503s. | Status | `error` | When | |---|---|---| | 400 | `bad_request` | The body isn't a valid order or pair | | 400 | `resting_orders_not_in_sandbox` | `tif: "gtc"`, `post_only` or `expires_at` | | 400 | `venue_not_in_sandbox` | `venue: "polymarket"` (polymarket.com isn't in the sandbox) | | 403 | `sandbox_not_enabled` | Layer has turned the sandbox off for your account | | 404 | `market_not_found` | The venue has no such market | | 404 | `not_a_match` | Pairs: Layer has no live Polymarket US match for that Kalshi ticker | | 409 | `client_id_reused` | The `client_id` was already used for a different order or pair (`detail` names it). Nothing was traded. Send a new `client_id`, or the exact same request to get the first one back | | 422 | `not_a_match` | Pairs: the `polymarket_us` you sent isn't the Kalshi market's match (`hint` names it) | | 422 | `match_settled` | Pairs: the match has settled | | 422 | `price_collar`, `off_tick`, `below_min_size`, `off_size_step`, `sell_more_than_held`, `not_enough_cash`, `market_closed` | A safety check failed (see above). For a pair, nothing was sent | | 503 | `venue_unavailable` | A venue didn't answer, or Layer has paused it (`venues` says which); try again in a few seconds | --- # Python SDK `uselayer` is one Python interface for trading prediction markets on Kalshi and Polymarket US with your own venue keys. Matches also cover polymarket.com and Hyperliquid's outcome markets, and `client.whales` reads traders on Polymarket and Kalshi. The code is on GitHub at [uselayer/uselayer-sdk](https://github.com/uselayer/uselayer-sdk), under the MIT license. | Call | What it does | Where it runs | |---|---|---| | `client.matches()`, `client.match()` | Which markets are the same bet: Kalshi with Polymarket US, polymarket.com or Hyperliquid | Asks Layer | | `client.prices()` | Both venues' best bids and asks, with sizes | Your machine, your keys | | `client.buy()`, `client.trade()` | Orders on one venue, or both legs of a match | Your machine, your keys | | `client.positions()` | What you hold; `fills()` and `balances()` too | Your machine, your keys | | `client.pnl()` | Profit and loss per market and in total | Your machine, your keys | | `client.reconcile()` | Checks your local record against what each venue reports | Your machine, your keys | | `client.whales` | Traders' records, holdings and scores, and what buying their trade costs now ([trader data guide](https://github.com/uselayer/uselayer-sdk/blob/main/python/trader-data.md)) | Your machine, public venue data | Only the match lookup reaches Layer. It sends your Layer key, the market ids and your filters, and nothing else: your venue keys, prices and orders never leave your machine. - **Paper mode** (the default) fills orders against the venues' real order books with simulated money. Nothing is sent to a venue. - **Live mode** sends orders with your own Kalshi or Polymarket US key, for your own account. - **Backtest mode** replays books you saved. Every venue and mode takes the same order shape. Guardrails check every order before it's sent. Fees come from each venue's published schedule in force at the time of the trade, and match Layer's API ([`POST /v0/profit`](https://uselayer.sh/docs/api.md#post-v0-profit), [`POST /v0/size`](https://uselayer.sh/docs/api.md#post-v0-size)) to the millionth of a dollar. Everything stays on your machine: a local SQLite file per mode, no telemetry. ## Install ```bash pip install uselayer ``` Python 3.11 or newer. The package is on [PyPI](https://pypi.org/project/uselayer/). **Reading Polymarket traders needs 0.18.0 or later** (`pip install -U uselayer`). Polymarket retired its Data API v1 on Oct 24, 2026, and older versions can't read its traders. Trading and matches aren't affected. ## Paper trade ```python from uselayer import Client client = Client() # paper mode: real books, simulated fills m = client.markets(limit=20)[0] # open Polymarket US markets, no key needed book = client.book(m.slug) order = client.order( venue="polymarket_us", market=m.slug, side="yes", price=book.outcome("yes").best_ask.price, size=5 ) print(client.preview(order)) # fill, fees, every rule's decision print(client.send(order)) # the order, filled against the book ``` `preview()` shows what an order would do and sends nothing. `client.positions()`, `client.fills()` and `client.orders()` read the local store. Every paper fill is a `SimulatedFill` with `simulated=True`. ## Live mode ```python from uselayer import Client, PolymarketUS client = Client(mode="live", polymarket_us=PolymarketUS(key_id="...", secret_key_path="~/.pmus/secret")) client.balances()["polymarket_us"].cash order = client.buy(venue="polymarket_us", market="", side="yes", price=0.42, size=5) client.positions() # from the venue ``` Create the key at polymarket.us/developer. It stays on your machine: requests are signed with it locally and only the signature is sent. - Books in live mode come from the venue's WebSocket, so they aren't cached. - An order whose answer never arrives raises `outcome_unknown`. It is never sent again on its own: call `client.sync()` and check `client.orders()`. - If the local store is new but your account already has open orders or positions, live mode starts with the kill switch on, until you run `python -m uselayer resume --mode live`. ## Profit and loss ```python p = client.pnl() p.net, p.realized, p.unrealized, p.fees # dollars, in total p.resolution_mismatch_loss # of realized: lost to pairs the venues settled differently for r in p.rows: # one row per side of each market you've held r.market, r.side, r.contracts, r.realized, r.unrealized, r.fees, r.outcome ``` - **`realized`**: profit from contracts sold or settled, before fees. - **`unrealized`**: the contracts you hold at the best bid, what you could sell them for now, minus what they cost. `max_daily_loss` values positions at the same bid. - **`fees`**: every fee paid. - **`net`**: `realized + unrealized - fees`. - **`resolution_mismatch_loss`**: how much of `realized` hedged pairs lost because the two venues settled them differently, against the $1 per contract they should have paid (see [When the venues settle a pair differently](#when-the-venues-settle-a-pair-differently)). It's a breakdown, already inside `realized` and `net`, not subtracted again. A position with no bid to value it at shows `unrealized=None`, is listed in `p.missing_marks` and is left out of the total. In paper and backtest mode, positions settle when their market does: - **Payout.** Each contract pays $1 if its side won and $0 if it lost. On a `void`, it pays the venue's price when the venue gives one (Kalshi's fair price for a canceled game), or else what the contract cost. Fees aren't refunded. - **What happens next.** The position closes, and resting orders on the market are canceled. Payouts are in `client.settlements()`. - **Paper mode** asks the venue whether a market you hold has settled, at most once a minute per market, whenever you call `positions()`, `pnl()` or `monitor()`. `client.settle()` asks now. - **A backtest** settles at each `resolution` event it replays, and the market takes no more orders. In live mode, `pnl()` reports what each venue says about your positions, including closed and settled ones, and values the contracts you hold at the bid. `fees` is what each venue charged: Kalshi reports it on each position. For Polymarket US it comes from your account's trade history, and so do positions Polymarket US has settled, which drop off its positions list. ## Reconcile with the venues In live mode, the SDK keeps its own record of your orders and fills on your machine, and the guardrails count your positions from it. `client.reconcile()` reads each venue's fills, positions and open orders with your key and lists every difference: ```python r = client.reconcile() r.ok # your record and every venue agree for m in r.mismatches: print(m.kind, m.venue, m.market, m.message) ``` - **`missed_fill`**: the venue filled an order the SDK sent, and your record doesn't have that fill (say, the process stopped mid-order). - **`unknown_fill`**: your record has a fill for an SDK order that the venue doesn't show. - **`outside_fill`**: a fill of an order the SDK didn't send, such as a trade on the venue's website or from another bot. - **`position`**: the contracts you hold in a market, per your record, differ from the venue's. Holding NO shows as a negative number of YES contracts. - **`outside_order`**: an open order on the venue that the SDK didn't send. - **`stale_order`**: your record says an order is open, and the venue doesn't list it as open. It only reads: your record doesn't change. `client.reconcile(repair=True)` also adds the fills the venue reported for orders the SDK sent. Fills of orders it didn't send are never added, only reported. Markets the venue has settled aren't compared. From a terminal, `python -m uselayer reconcile` prints the same list and exits 1 when anything differs, so you can run it on a schedule. ## Guardrails ```python client = Client( rules={ "max_position": {"per_market": 200}, # $ at risk in one market "budget": 1000, # $ at risk in total "max_daily_loss": {"amount": 150}, # stop opening positions after this loss today "approve_above": 100, # ask before orders above $100 "stop_loss": {"pct": 25}, # exits sent by client.monitor() } ) ``` Rules can also come from a YAML or JSON file: `Client(rules="guardrails.yaml")`. YAML needs `pip install "uselayer[yaml]"`. Rules are fixed when the client is created. Rules cover position size, budget, daily loss, allowed markets, approvals, stop-loss and take-profit. A price collar, an order throttle and a kill switch are always on. ### Kill switch ```bash python -m uselayer kill # from any terminal on this machine python -m uselayer status python -m uselayer resume # a person turns it off ``` `client.kill()` does the same from code: it cancels resting orders and blocks new ones. The switch stays on, even after a restart, until a person runs `python -m uselayer resume`. The client a strategy or agent holds can't resume. ## Pairs When two markets are the same bet, buying YES on one and NO on the other pays $1 per contract either way. `quote()` prices that after both fees. `trade()` places both legs. ```python q = client.quote(pair) # pair: a Match from client.matches(), or two (venue, market) t = client.trade(pair, size=100, min_edge=0.01) t.status # "hedged" | "missed" | "unwound" | "exposed" ``` A quote reads as three numbers for the contracts that clear `min_edge`: ```python q = client.quote(pair, size=100, min_edge=0.01) q.gross_spread # $1 a contract at settlement minus what they cost, before fees q.fees # both venues' taker fees on them q.net_profit # gross_spread - fees: what they lock in q.gross_at_best, q.edge_at_best # the gap per contract at the best asks, before and after fees ``` When fees close the gap, `q.contracts` is 0 and so are the three totals, but `q.gross_at_best` still shows the gap before fees. The money is tied up until both markets pay out, so a quote also gives the return per day: ```python q.return_pct # net profit over what the contracts cost with fees, in percent q.settles_at # when the money is expected back: the later of the two markets' payouts q.days_held # days from now until then (at least 1) q.return_per_day_pct # return_pct spread over days_held ``` `settles_at` follows the same rule as `client.profit()`, from each venue's own market times, so the two agree on the same pair. When neither venue gives a time, all three are `None`: the SDK never guesses a date. A backtest has no venue times, so pass one: `quote(pair, settles_at="2026-10-05")`. `client.matches(q="...")` needs a Layer API key. It sends Layer your key, the market ids Layer gave you and your filters, and nothing else: no prices, orders, positions or venue keys. ### polymarket.com and Hyperliquid Matches are with Polymarket US by default. `venue="polymarket"` gives polymarket.com, and `venue="hyperliquid"` gives Hyperliquid's outcome markets, both paired with Kalshi: ```python for m in client.matches(venue="hyperliquid", q="nba"): m.kalshi.market_id, m.hyperliquid.market_id # a Kalshi ticker, "#14960" m.hyperliquid.question, m.hyperliquid.event_time client.match("", venue="kalshi", with_="hyperliquid")["matched_market"] ``` - Hyperliquid matches are sports games only: game winners, soccer results (each team and the draw), and a game's totals and spreads. No season winners or player props. - A Hyperliquid `market_id` is the coin of one side of an outcome: `"#14960"` is side 0 of outcome 1496. Its `url` is Hyperliquid's Outcomes page. - Each market's event, question, outcome, times and rules are read from Hyperliquid's public API on your machine: two reads per client, however many matches. - Matches only: `book()`, `prices()` and `quote()` on a Hyperliquid market raise `venue_switched_off`. A Hyperliquid market's twin is always on Kalshi, so `with_` naming a Polymarket raises `invalid_order` before anything is sent. - Novig's sports game lines (`venue=novig` in the [API](https://uselayer.sh/docs/api.md)) aren't in the SDK yet: ask the API for them directly. - When Layer isn't serving a venue's matches, it answers 403 `venue_not_served`, and the SDK raises `VenueError("venue_switched_off")` (your key is fine). ### Rule-flag notes `m.caveats` says which rules differ between the two markets, as codes (`source_differs`, `definition_differs`, ...). Layer's API sends the codes only, and the SDK writes a note for each from the rules it read: ```python m.caveats # ["definition_differs"] m.caveat_notes # {"definition_differs": "Kalshi's rules don't say. Hyperliquid's rules: \"The Counted Play is the full game including overtime. ...\""} m.caveat_notes_from # "venues": the SDK wrote them; "layer": Layer sent them; None: no notes m.kalshi.rules, m.kalshi.resolution_sources ``` - Each note quotes the two sentences of each venue's rules that use that code's words most, word for word (cut at 400 characters). When none do, it says so: "Kalshi's rules don't say." - For `source_differs`, when both venues name a settlement source and they differ, the note names both instead. - A code that neither venue's rules speak to gets no note. Nothing is guessed, no AI is involved, and nothing is sent to Layer. - Matches with polymarket.com get no notes yet. `titles=False` skips the venue reads, and the notes with them. ### The leg-risk guard - The thinner leg goes first, immediate-or-cancel. - The other leg goes for what filled, up to its break-even price. - If it can't be completed within `chase_s`, the first leg is sold back, never below its entry price minus `max_unwind_loss` (`on_miss="unwind"`, the default). With `on_miss="hold"`, the open contracts are reported instead. - Both legs pass the guardrails together before either is sent. `trade()` runs in paper, backtest and live mode. Live, each leg goes to its venue with your own key. A Kalshi ↔ Polymarket US pair needs both keys, and both are checked before anything is sent: ```python client = Client(mode="live", kalshi=Kalshi.from_env(), polymarket_us=PolymarketUS.from_env()) t = client.trade(pair, size=10, min_edge=0.01) if t.status == "exposed": print(t.exposure, t.notes) # contracts left on one side ``` If a venue answers with an error mid-pair, the open leg is still sold back or reported. If a venue can't say whether an order went through, nothing is sold back: the trade comes back `exposed`, and its notes say to run `client.sync()` before trading those markets again. ### When the venues settle a pair differently A hedged pair pays $1 per contract only if both venues settle the bet the same way. They don't always: their rules can differ, one can void a market the other settles, or one can settle days after the other. This is a real risk of trading pairs, and the SDK tracks it: ```python for m in client.resolution_mismatches(): m.kind # "both_lost" | "both_won" | "void_one_leg" | "settle_gap" m.impact # what the pair paid minus the $1 per contract expected (negative: lost) m.a.venue, m.a.outcome, m.a.payout # each venue's result ``` A pair is flagged when both legs lost, both won, one venue voided or refunded its market and the other didn't, or one leg was still open 48 hours after the other settled. Each one is saved on your machine with both venues' results, and `pnl()` shows how much of `realized` they lost as `resolution_mismatch_loss`. Paper, backtest and live mode. In live mode the SDK asks each venue what the pair's markets paid, with your keys. Nothing is sent to Layer. ## One order, on the cheaper venue When you know the bet and only need to decide where to place it, `buy_best()` places the order on whichever venue of a matched pair is cheaper for that size, after fees, at the moment you call it. ```python p = client.preview_best(pair, "yes", 10, max_price=0.55) # the comparison; sends nothing r = client.buy_best(pair, "yes", 10, max_price=0.55) r.why.reason # "Kalshi is $0.06 cheaper all-in for 10 contracts: $4.35 vs $4.41 on Polymarket US." [(v.venue, v.all_in, v.skip) for v in r.why.venues] ``` For each venue it walks the book for your size, level by level, and adds that venue's fees, the way a paper fill is priced. The lower all-in cost wins; on a tie, the venue with more contracts on offer at its price. `side` is the same outcome on both venues: each market id in a match already names it. `sell_best(pair, side, size, min_price=)` sells contracts you hold where they pay more after fees, only on a venue where you hold them. A buy can also be given in dollars, the way people bet (uselayer 0.4.3 or later): ```python r = client.buy_best(pair, "yes", spend=50) # $50 on YES, fees included r.order.size # the most whole contracts $50 buys on the chosen venue r.why.reason # "Kalshi wins $3.00 more if you're right: $50.00 buys 69 contracts there for $49.32 all-in, vs 66 for $49.80 on Polymarket US." ``` The same money buys a different number of contracts on each venue, so each gets its own size: the most whole contracts whose cost plus fees fits in `spend`, within `max_price` and the price collar, from one read of its book. The venue where they pay more if you're right ($1 a contract) wins; the same number on both is compared on cost, as above. Give `size` or `spend`, not both. `spend` is for buys, and `preview_best(pair, "yes", spend=50)` takes it too. The order is an immediate-or-cancel limit at the deepest price the walk reached, sent through `send()`, so every guardrail, the price collar and the kill switch apply. The comparison is a snapshot: a book can move before the order arrives, and the limit, never past `max_price` or the price collar, caps what it can pay. A venue is left out, with the reason in `r.why.venues`, when it's switched off, you gave no key for it, your `venues` or `markets` rules don't allow it, its market is closed, its book is stale, it can't fill the size within your limit, or `spend` doesn't buy one contract there. If neither venue can take the order, nothing is sent. Paper, backtest and live mode; live, you choose to call it, on your own account and keys. ## Prices, fees and profit `prices()` reads both sides of a match from each venue's order book, with your own keys. An empty side is `None`. ```python pair = client.matches(venue="polymarket_us", q="nfl")[0] p = client.prices(pair) # or [("kalshi", ticker), ("polymarket_us", slug)] k, u = p.leg("kalshi"), p.leg("polymarket_us") k.yes_ask, k.yes_ask_size, u.no_ask, u.no_ask_size, k.as_of ``` `fees()` reads each market's fee settings, and how long your money would be held, from the venues (Kalshi ↔ Polymarket US pairs, paper and live mode): ```python client.fees(pair) # {"kalshi": {"fee_type": "...", "fee_multiplier": 1.0}, # "polymarket_us": {"fee_coefficient": 0.0695}, "days_held": 6.76, "match": {...}} ``` `profit()` takes the same body as [`POST /v0/profit`](https://uselayer.sh/docs/api.md#post-v0-profit) and gives the same answer, on your machine. With `pair=`, it fills in each market's fees and `days_held` from the venues; anything you send wins. With a `kalshi.market_id` in the body instead, it asks Layer for the Polymarket US twin, its only call to Layer. ```python if k.yes_ask and u.no_ask: # an empty side is None, and profit() needs both prices body = {"contracts": 100, "kalshi": {"price": k.yes_ask}, "polymarket_us": {"price": u.no_ask}} r = client.profit(body, pair=pair) r["net_profit"], r["filled_in"], r.get("return_per_day_pct") # only when days_held is known ``` `uselayer.calc.profit()` and `uselayer.calc.size()` are the same math with nothing read: you send every number, and `calc.size()` answers like [`POST /v0/size`](https://uselayer.sh/docs/api.md#post-v0-size) from the books you pass. ## One strategy, every mode `run()` calls your strategy for each pair on each new book. The same function runs in every mode. ```python def strategy(client, pair, quote): if quote.net_profit_per_contract >= 0.02: client.trade(pair, size=100) Client(mode="backtest", books=saved_books).run(strategy, [pair]) # the past Client().run(strategy, [pair], iterations=60) # now, paper ``` In paper and live mode it runs every `interval_s` (1 second by default) until `iterations` rounds, `stop()` or the kill switch. ## Backtest ```python from uselayer import Client from uselayer.backtest import load_books, record_books record_books(Client(), [""], "books.jsonl") # run on a schedule to build a history bt = Client(mode="backtest", books=load_books("books.jsonl")) bt.replay(lambda client, book: ...) # place orders as each book arrives ``` `record_books()` saves one snapshot per call. The replay uses the same fill model, fees and rules as paper mode, on the replayed clock. Trades in the data fill resting orders once the estimated line ahead of them at their price is used up. To backtest on history you already have (CSV, Parquet, raw venue stream messages or PMXT files), see [Backtest data](https://uselayer.sh/docs/backtest-data.md). ## Fees by date ```python from datetime import UTC, datetime from uselayer import FeeSettings, calculate_fee, rules_at rules_at("polymarket_us", datetime.now(UTC)).source # the schedule's page calculate_fee( FeeSettings(venue="polymarket_us"), contracts=100, price=0.5, role="taker", at=datetime.now(UTC) ) ``` Before the earliest schedule the SDK knows, it raises `no_venue_rules` instead of guessing. ## What paper mode can't tell you - **Your exact place in line.** A resting paper order joins the back of the line at its price: everything already there is ahead of it. Trades at its price use up that line first, and only what's left fills your order. A trade through its price, or a book whose other side reaches it, fills it too, and the same contracts never fill it twice. Venues publish the total at each price, not single orders, so the line is an estimate: when a level shrinks by more than its trades explain, the difference counts as cancels, spread through the line (`Client(queue_cancels="behind")` puts them all behind you, the worst case). With books alone (`monitor()` in paper mode, or `record_books()` snapshots), a resting order fills only when a book crosses its price. - **How fast the venue answers.** A paper order reaches the book the moment you send it. - **When a market settles.** Paper mode learns that a market settled when it next asks the venue (at most once a minute per market). On Kalshi, a result counts only once it's final, not while it can still be disputed. - **Freshness without a key.** In paper mode, Polymarket US's public book is cached for up to 30 seconds. The SDK stamps each book with the venue's time. When a copy is older than `max_quote_age_s` (10 seconds by default), it waits for a fresh one before using it. ## For AI agents - `AGENTS.md` and `llms.txt` ship inside the package. - Every public method has a docstring with an example. - Every object has `.to_dict()`. - Every error is a `VenueError` with `code`, `hint` and `next`. - Without the SDK: Layer's hosted [MCP server](https://uselayer.sh/docs/mcp.md) gives an agent matched markets, lookups and profit checks as tools, and [`/llms.txt`](https://uselayer.sh/llms.txt) lists these docs as Markdown. ```python from uselayer import VenueError try: client.send(order) except VenueError as e: print(e.code, e.hint, e.next) ``` ## Kalshi Kalshi's books are read with your own Kalshi API key, on your machine. Paper mode fills Kalshi orders against them with Kalshi's fee schedule and each series' fee multiplier. ```python from uselayer import Client, Kalshi client = Client(kalshi=Kalshi(key_id="...", private_key_path="~/.kalshi/key.pem")) # or KALSHI_KEY_ID + KALSHI_PRIVATE_KEY_PATH client.book("", venue="kalshi").outcome("yes").best_ask client.buy(venue="kalshi", market="", side="yes", price=0.42, size=5) # paper fill ``` To price a buy on a Kalshi market without a key, `client.whales.preview_kalshi()` reads Kalshi's public book ([trader data guide](https://github.com/uselayer/uselayer-sdk/blob/main/python/trader-data.md#price-a-buy-now)). Create the key in your Kalshi account settings; Ed25519 and RSA keys both work. Market ids are Kalshi tickers. `Kalshi(..., environment="demo")` uses Kalshi's demo exchange. Backtest mode replays Kalshi books you saved without a key. **Live Kalshi orders.** `Client(mode="live", kalshi=...)` places, cancels and tracks Kalshi orders with your own key, for your own account, as it does on Polymarket US. Your guardrails and the kill switch check every order first. Pairs across the two venues (`trade()`) run live too, with both keys. `trade()` also runs on Kalshi ↔ Polymarket US pairs, in every mode: ```python client = Client(kalshi=Kalshi.from_env(), layer_key="lyr_...") pair = client.matches(venue="polymarket_us", q="nfl")[0] # m.kalshi and m.polymarket_us client.quote(pair).net_profit_per_contract ``` One more thing paper mode can't tell you: **Kalshi's sub-cent billing.** The SDK rounds each Kalshi fee up to the cent, from the published schedule, as Layer's API does. Kalshi bills fees to fractions of a cent, so a paper fee can be up to a cent higher than what Kalshi would charge. ## Record every tick `record_stream()` keeps each venue's live stream open and saves every book change and trade, for Kalshi and Polymarket US in one call, one file and one event format, with the venue's time (`as_of`) and your machine's (`received_at`). Kalshi tickers (in capitals) and Polymarket US slugs can be mixed; `venue=` reads every id as one venue's. It reconnects on its own and writes a `gap` event for each market while its venue was disconnected. Kalshi numbers its messages, so a lost one is a gap too, followed by a fresh book. In backtest mode a market has no book during a gap. Each venue's stream needs your key for it, though nothing is traded: `KALSHI_KEY_ID` and `KALSHI_PRIVATE_KEY_PATH` (a read-only Kalshi key is enough), and `POLYMARKET_US_KEY_ID` and `POLYMARKET_US_SECRET_KEY`. ```python from uselayer import Client from uselayer.backtest import load_books, record_stream record_stream(["", ""], "ticks.jsonl", duration_s=3600) # Kalshi + Polymarket US, or until Ctrl-C Client(mode="backtest", books=load_books("ticks.jsonl")).replay(on_book) ``` In paper mode, pass `on_event=client.feed` so the stream's trades fill your resting orders as they print: `record_stream([""], "ticks.jsonl", on_event=client.feed)`. From a terminal, with progress: ```bash python -m uselayer record --out ticks.jsonl --minutes 60 ``` ## Import your own data `import_events()` turns history you already have (CSV, Parquet, raw venue stream messages or PMXT files) into backtest events, and checks it first. Backtests also replay polymarket.com data. See [Backtest data](https://uselayer.sh/docs/backtest-data.md). ## Trader data `client.whales` reads traders on Polymarket and Kalshi from the venues' public data, on your machine, with no venue key. It covers: - Leaderboards, search by username, and big trades - A trader's holdings, trades and wallet - Their track record: positions closed, hourly profit history, and profit by category - A score of whether their edge is real - What buying one of their trades would cost now, on Polymarket or Kalshi - Copies in paper mode ```python who = client.whales.search_traders("")[0] client.whales.completed_positions(who.id).summary # their record in one line, or why it can't be given buy = next(t for t in client.whales.trades("polymarket", who.id) if t.action == "buy") client.whales.preview_copy(buy, spend=50) # what $50 of it costs now ``` Every call is in the [trader data guide](https://github.com/uselayer/uselayer-sdk/blob/main/python/trader-data.md) on GitHub. --- # Backtest data The [Python SDK](https://uselayer.sh/docs/sdk.md) backtests on order-book history you supply. This page describes the format that history takes (the SDK's **events**) and how `import_events()` turns files you already have into it. Everything runs on your machine. Layer never sees your files. ## Import a file ```python from uselayer import Client, import_events data = import_events( "ticks.csv", venue="kalshi", columns={"time": "ts", "market": "ticker", "bid": "yes_bid", "bid_size": "yes_bid_qty", "ask": "yes_ask", "ask_size": "yes_ask_qty"}, price_scale=0.01, # the file has cents ) print(data.report.summary()) Client(mode="backtest", books=data).replay(on_book) ``` `import_events()` reads these formats. It guesses the format from the file when you leave `format=` out. | `format=` | What it reads | |---|---| | `csv`, `parquet` | One row per event. `columns=` maps the SDK's field names to yours. Parquet needs `pip install 'uselayer[parquet]'` | | `jsonl` | The SDK's own format, one event per line, as `save_events()` writes it | | `polymarket_us` | Messages from Polymarket US's markets WebSocket (`marketData`, `trade`, `marketDataLite`) | | `polymarket` | Messages from polymarket.com's market channel (`book`, `price_change`, `last_trade_price`, `tick_size_change`, `market_resolved`) | | `kalshi` | Messages from Kalshi's WebSocket (`orderbook_snapshot`, `orderbook_delta`, `trade`, `market_lifecycle_v2`) | | `pmxt` | PMXT's hourly Polymarket order-book Parquet files, both of their schemas | For the three WebSocket formats, put one message per line. You can wrap each one as `{"received_at": "", "message": }` to keep your receive time. To join consecutive files, such as hours of PMXT, pass a list: `import_events(["hour1.parquet", "hour2.parquet"], markets=[...])`. ### Options | Option | What it does | |---|---| | `venue`, `market` | For CSV and Parquet: the venue and market of every row, when the file has no column for them | | `columns` | For CSV and Parquet: `{sdk_field: your_column}` (fields below) | | `kind` | What every row is, when the file has no `kind` column | | `price_scale` | Multiply prices by this, e.g. `0.01` for cents | | `markets` | Keep only these markets. Required for `pmxt`, whose hours hold every Polymarket market: pass outcome token ids or condition ids | | `tokens` | For `polymarket` and `pmxt`: `{token_id: (market, "yes" or "no")}` names a market and folds its NO token into the YES book. Without it, each token is its own market, with the token as YES | | `tick_size` | The tick to check prices against: one number, or `{market: tick}` | | `max_gap_s` | Report silences longer than this. Default: 10 times the data's median spacing, and at least 60 s | | `strict` | `True` (the default) raises when the checker finds errors. `False` imports anyway; read `data.report` | ## The checker Every import is checked before a backtest can use it, so a bad file can't quietly give wrong results. | Problem | Severity | Means | |---|---|---| | `crossed` | error | A book's best bid is at or above its best ask | | `impossible` | error | A price not between $0 and $1, or a size that isn't a positive number. Often cents read as dollars | | `off_tick` | error | A price that isn't a multiple of the market's tick | | `unreadable` | error | A row or line that can't be read: no time, bad JSON, an unknown kind | | `sequence_gap` | error | Kalshi's message numbers skipped, so messages were lost and the book is wrong until the next snapshot | | `gap` | warning | A silence much longer than the data's usual spacing, or a gap a recorder marked | | `out_of_order` | warning | A row received (or stamped) earlier than the row before it for the same market | | `stale_book` | warning | A full book re-sent with an old time, after newer data. It is skipped | | `no_starting_book` | warning | A level change before the market's first full book. A replay skips it | | `repaired` | warning | Polymarket book levels removed because the venue's own best bid and ask said they were gone | Errors raise `VenueError` with code `bad_data`. Its message holds the summary, and `raw` holds the full report. Warnings never stop an import. One token from a real PMXT hour (`polymarket_orderbook_2026-07-21T04.parquet`) reports: ```text 29766 events, 1 markets, 2026-07-21T04:00:00.095000+00:00 → 2026-07-21T04:59:59.952000+00:00. warning gap ×1: no events for 2172 s, from 2026-07-21T04:09:00.161000+00:00 to 2026-07-21T04:45:11.982000+00:00. warning no_starting_book ×6985: a level change for 1554… comes before any full book. (row 7139984) warning repaired ×139: removed 139 book level(s) that the venue's own best bid/ask showed were already gone. warning stale_book ×10: a re-sent book from 2026-07-21T04:06:28.100000+00:00 arrived after newer data; skipped. (row 7149344) ``` The 36-minute gap is a stretch missing from that hour's file. Through a gap the checker finds, a replay keeps the last book, so read results from that stretch with care. Through a recorder's `gap` event (below), the market has no book until the next one. You can check any events yourself, for example ones you built in code: `check_events(events).summary()`. ## The event format Every event is a JSON object with a `kind`. Prices are dollars from 0 to 1 for the market's **YES** side. NO is the mirror: a NO bid at 40¢ is a YES ask at 60¢. Times are ISO 8601 in UTC. - `as_of` is always the **venue's** time, except on a recorder `gap`. - `received_at` is when your machine got the event, if it was recorded. It is optional. - A backtest replays events in `as_of` order. ### `book`: a full order book ```json {"kind": "book", "venue": "kalshi", "market": "KXFED-26SEP-T4.25", "bids": [{"price": 0.41, "size": 120}], "asks": [{"price": 0.43, "size": 80}], "as_of": "2026-09-01T14:00:00Z", "received_at": null, "source": "recorded"} ``` `bids` run best first (highest), and `asks` run best first (lowest). `size` is in contracts. ### `book_change`: one level changing ```json {"kind": "book_change", "venue": "polymarket_us", "market": "aec-nfl-sample-2026-09-07", "book_side": "ask", "price": 0.56, "size": 40, "as_of": "2026-09-07T17:00:01Z"} ``` `size` is the level's new total. `0` removes the level. A backtest applies each change to the market's last book and replays the result as a book. Kalshi sends changes as amounts to add or subtract, and the importer turns those into totals. ### `trade`: a trade the venue printed ```json {"kind": "trade", "venue": "kalshi", "market": "KXSAMPLE-26SEP21-T50", "price": 0.45, "size": 10, "trade_id": "t-1", "aggressor": "buy", "as_of": "2026-09-21T15:00:02.150Z"} ``` `aggressor` is what the taker did to YES: `buy` means they took the asks, `sell` means they hit the bids. It is `null` when unknown. ### `status`: the market opened, paused, closed or halted ```json {"kind": "status", "venue": "kalshi", "market": "KXSAMPLE-26SEP21-T50", "status": "paused", "as_of": "2026-09-21T15:30:00Z"} ``` ### `resolution`: how the market settled ```json {"kind": "resolution", "venue": "kalshi", "market": "KXSAMPLE-26SEP21-T50", "outcome": "no", "as_of": "2026-09-21T16:00:00Z"} ``` `outcome` is `yes`, `no` or `void` (refunded). ### `gap`: the recorder was disconnected ```json {"kind": "gap", "origin": "recorder", "venue": "polymarket_us", "market": "aec-nfl-sample-2026-09-07", "as_of": "2026-09-07T17:00:00Z", "until": "2026-09-07T17:00:12Z", "reason": "disconnected: ConnectionClosedError: …"} ``` `record_stream()` writes one for each market while it was disconnected. Both times are your machine's clock, since the venue sent nothing. In a backtest the market has no book from `as_of` until the next book after the gap. ### CSV and Parquet fields Each row is one event. Map your columns to these fields with `columns=`, or name your columns after them. | Field | Used for | |---|---| | `time` | The venue's time (or `received_at` alone, if the file has only that) | | `received_at` | When you received it | | `venue`, `market` | Which market (or pass `venue=` / `market=`) | | `kind` | `book`, `book_change`, `trade`, `status` or `resolution`. Guessed from which fields are filled when left out | | `bids`, `asks` | A full book: JSON levels, `[[price, size], ...]` | | `bid`, `bid_size`, `ask`, `ask_size` | A top-of-book row, read as a one-level book | | `book_side`, `price`, `size` | A level change (`bid` or `ask`; `buy` and `sell` work too) | | `price`, `size`, `trade_id`, `aggressor` | A trade | | `status` | A status row | | `outcome` | A resolution row | Times can be ISO 8601 strings or Unix numbers in seconds, milliseconds, microseconds or nanoseconds (the unit is read from the size of the number). A time without a zone is read as UTC. ## Venue notes - **polymarket.com:** each outcome token has its own book. Pass `tokens=` to fold both tokens into one market. Old books re-sent on reconnects are skipped. Some level removals never reach the stream, so the importer drops levels that are better than the best bid and ask the venue stamps on each change. On PMXT's files this brings rebuilt books from 88–99% to about 99% agreement with the venue's own best bid and ask, and stops them crossing. - **PMXT:** the files hold only condition ids and token ids, not slugs or results. PMXT's first schema (Feb to mid-April 2026) has no venue time, so its receive time is used. - **Kalshi:** books list bids on both sides. A NO bid at p is a YES ask at 1 − p. A snapshot has no time of its own, so it takes the time Kalshi sent it. - **Polymarket US:** every `marketData` message is the full book. A settlement comes only in `marketDataLite`'s `settlementPx`. --- # Command line `uselayer` calls the [API](https://uselayer.sh/docs/api.md) with your key, so you can try Layer from a terminal before writing any code. Every command says which Layer it's calling, ends with ✓ or ✗, and suggests the next command to run. When the API turns a request down, the reason and how to fix it are printed under the ✗. ## Install You need Node 18.17 or later. Check with `node -v`; if it's missing or older, get it from [nodejs.org](https://nodejs.org). There are two ways to run `uselayer`. Pick one: | | Run with npx | Install it once | |---|---|---| | Command | `npx uselayer login` | `npm install -g uselayer`, then `uselayer login` | | What it does | Downloads `uselayer` and runs it, without adding a `uselayer` command to your terminal. | Installs it for your whole computer (`-g` means global), so `uselayer` works in any folder. | | Good for | Trying Layer, or a one-off check. | Using it every day. | | Every command starts with | `npx uselayer …` | `uselayer …` | With npx, keep typing `npx uselayer` each time: a bare `uselayer` gives `command not found`. The CLI knows how you started it, so the next command it suggests is always one you can paste as-is. Check it's working with `uselayer --version` (or `npx uselayer --version`). ```bash npx uselayer login # paste your key from the dashboard npx uselayer matches --q nfl ``` ### If something goes wrong | What you see | Why | Fix | |---|---|---| | `zsh: command not found: uselayer` (or `bash: …`) | You ran it with npx, which doesn't install a command. Or it's installed globally, but npm's folder for global commands isn't on your `PATH`. | Use `npx uselayer …`, or run `npm install -g uselayer`. If it's installed and still not found, run `npm prefix -g`, add the `bin` folder inside it to your `PATH`, then open a new terminal. | | `404 Not Found - GET https://registry.npmjs.org/uselayer.` | The package name was typed with something extra: here, a full stop at the end. | Type exactly `uselayer`: `npm install -g uselayer`. | | `EACCES: permission denied` during `npm install -g` | npm's global folder belongs to another user on your computer. | Use `npx uselayer …` instead, or install Node with a version manager such as [nvm](https://github.com/nvm-sh/nvm), which keeps global installs in your own folder. Avoid `sudo npm install -g`. | | `npm warn EBADENGINE Unsupported engine`, or a `SyntaxError` when it starts | Node is older than 18. | Update Node, then run the command again. | - **Update:** `npm install -g uselayer@latest`. npx can reuse a copy it downloaded earlier; `npx uselayer@latest …` makes it fetch the newest. - **Uninstall:** run `uselayer logout` first if you want your saved key removed too, then `npm uninstall -g uselayer`. ## Your key `uselayer login` asks for your key from the [dashboard](https://uselayer.sh/dashboard), checks it with Layer, and saves it in `~/.config/uselayer/config.json`, readable only by you. You can also pass it: `uselayer login lyr_your_key`, or `echo $LAYER_KEY | uselayer login`. `uselayer logout` forgets it. If `LAYER_KEY` is set in your environment, it's used instead of the saved key, so scripts and CI don't need a login. ## Commands | Command | What it does | |---|---| | `uselayer matches [--q nfl] [--from 2026-10-03] [--to 2026-10-05] [--category sports] [--venue polymarket-us\|polymarket] [--limit 50] [--offset 0]` | Live matches, soonest event first, as in `GET /v0/matches`: with Polymarket US, or polymarket.com with `--venue polymarket`. Each one shows both markets' ids and who checked the match. | | `uselayer match [--venue kalshi\|polymarket\|polymarket-us] [--with polymarket]` | The same market on the other venue, as in `GET /v0/match`, with both venue links. Leave `--venue` out and it's read from the id: an upper-case ticker is Kalshi, a `:long` or `:short` side is Polymarket US, and anything else is polymarket.com. A Kalshi market's twin is looked up on Polymarket US; `--with polymarket` looks on polymarket.com. When there's no twin, it says why. | | `uselayer profit --match KXFEDDECISION-26OCT-C25 --contracts 100 --kalshi 0.42 --polymarket 0.55` | Buy one side on Kalshi and the other on Polymarket at these prices: the profit after both venues' fees, as in `POST /v0/profit`. `--match` is the match's Kalshi ticker: Layer fills in both markets' fees and how long your money is held, and says which it filled in. Use `--polymarket-us 0.55` for Polymarket US instead. The check runs on Layer from the prices you give it; Layer doesn't fetch venue prices for you. | | `uselayer login [key]`, `uselayer logout` | Save or forget your key. | | `uselayer help` | All of the above. Add `--help` to any command for its options. | ### profit options | Option | Meaning | |---|---| | `--contracts` | Contracts bought on each venue. | | `--kalshi`, `--polymarket`, `--polymarket-us` | The price you pay on each venue, in dollars (`0.42` is 42¢). Send `--kalshi` and one of the other two. | | `--match` | The Kalshi ticker of a live match. Layer fills in every fee and `--days` you leave out; anything you pass yourself is used instead. | | `--category` or `--fee-rate` | Polymarket's fee: the category's default rate, or the market's own `feeSchedule.rate`. One is needed with `--polymarket` unless you send `--match`. `--exponent` sets `feeSchedule.exponent`. | | `--fee-coefficient` | Polymarket US's fee (`0.095` by default: the rate its markets report, which takers under $10K of trailing 30-day volume pay). | | `--fee-multiplier`, `--fee-type` | The Kalshi series' `fee_multiplier` and `fee_type`. | | `--kalshi-role`, `--poly-role` | `maker` if your order rests on the book first (`taker` by default). | | `--settles-at 2026-10-04` or `--days 4` | When the money comes back; adds return per day. | ## For scripts Add `--json` to any command and it prints only the API's JSON answer, the same as calling the API directly. A failed call prints the API's error JSON and exits with status 1. ```bash uselayer matches --q nfl --json | jq -r '.matches[].kalshi.market_id' ``` `--local` calls a Layer running on `http://localhost:3100` instead of `https://uselayer.sh`; `LAYER_API_URL` points it anywhere else. --- # MCP server Layer runs an [MCP](https://modelcontextprotocol.io) server at `https://uselayer.sh/mcp`. Add it to your AI assistant and it can find matched markets, look one up and check a trade's profit after fees, without you writing any code. The tools only read. None of them places an order: orders go from the [Python SDK](https://uselayer.sh/docs/sdk.md) on your machine to each venue, with your own keys. ## Connect You need a Layer API key from [your dashboard](https://uselayer.sh/dashboard/keys). The server uses Streamable HTTP and takes your key as a header. **Claude Code** ```bash claude mcp add --transport http layer https://uselayer.sh/mcp --header "Authorization: Bearer lyr_your_key" ``` **Cursor** (`.cursor/mcp.json`) and other clients that take a URL: ```json { "mcpServers": { "layer": { "url": "https://uselayer.sh/mcp", "headers": { "Authorization": "Bearer lyr_your_key" } } } } ``` **Clients that only run local commands** can go through [`mcp-remote`](https://www.npmjs.com/package/mcp-remote): ```json { "mcpServers": { "layer": { "command": "npx", "args": ["-y", "mcp-remote", "https://uselayer.sh/mcp", "--header", "Authorization: Bearer lyr_your_key"] } } } ``` Then ask it something like "which NBA games are matched on Kalshi and Polymarket US tonight, and is any of them profitable after fees?" ## Tools | Tool | What it does | Same as | |---|---|---| | `find_matches` | Lists markets that are the same bet on Kalshi and another venue, earliest event day first, with both venues' live prices and the best trade. Filters: `q`, `series`, `category`, `venue`, `from`, `to`, `limit`, `offset`; page with `next_offset` while `has_more` is true. | [`GET /v0/matches`](https://uselayer.sh/docs/api.md#get-v0-matches) | | `lookup_match` | Finds one market's twin on another venue from its `venue` and `market_id`, with the confidence, the rule differences and live prices. Once it has settled, says how both venues settled it. | [`GET /v0/match`](https://uselayer.sh/docs/api.md#get-v0-match) | | `check_profit` | Works out a trade's profit after both venues' fees from `contracts` and each side's price. | [`POST /v0/profit`](https://uselayer.sh/docs/api.md#post-v0-profit) | Each tool answers with the same JSON as its endpoint, so the [API reference](https://uselayer.sh/docs/api.md) describes every field. ## Keys, limits and errors A tool call counts as an API call on your key: the same 60 requests per minute, and the same access to live prices as your account has. When a call fails, the tool gives your assistant the API's own error, with `detail` (what's wrong) and `hint` (how to fix it), so it can correct itself. Without a key, your assistant can still connect and see the tools; calling one answers with where to get a key. No account? An agent can also [pay per call](https://uselayer.sh/docs/pay-per-call.md) over x402, with no key. --- # How matching works A wrong match is worse than no match: a bot that trusts it could bet both sides of what it thinks is one market and lose on both. So Layer only serves a match after several independent checks. ## 1. Pull every open market Layer regularly pulls all open markets from Kalshi, Polymarket US and Polymarket (polymarket.com), Hyperliquid's outcome markets and Novig's game lines. Sports events are split by market type (winner, spread, total, side markets), and each side of a two-team Polymarket market becomes its own contract. NFL, NBA and NHL teams get their full names on both venues, since Kalshi names them by city ("Philadelphia") and Polymarket by nickname ("Eagles"). ### Markets inside a game A game is many bets, not one: the winner, spreads ("wins by more than 3.5"), totals ("over 44.5 points"), each half's and quarter's lines, team totals, and player props ("over 249.5 passing yards"). For the NFL, college football, NBA, WNBA, NHL and MLB, each is its own market type on both venues. Kalshi's `KXNFL1HSPREAD` series is a first-half spread and `KXNFLPASSYDS` is passing yards, the same as Polymarket's `first_half_spreads` and `passing_yards`. A pair must be the same type on both sides, so a first-half spread never meets the full game's spread, and a team's total never meets the game's total. Every line names who it's about and where the line is, on both venues: "Aaron Rodgers over 124.5" is Kalshi's "Aaron Rodgers: 125+", and "Cleveland Browns over 10.5" is Kalshi's team total for the Browns. Polymarket's "Anytime Touchdown" is Kalshi's "1+" touchdowns, and "2+ Touchdowns" its "2+". ## 2. Find candidates For each upcoming Kalshi event, Layer looks for Polymarket events in a compatible category, within a date window (±1 day for games, ±7 days otherwise), with similar titles. It keeps the best 5. Some Kalshi events outside sports give no date for the event itself, only when they settle by, often months later (the 2026 House race settles by 2027-02-01). For those, the window reaches back 120 days before that date, and the graders are told the date is a deadline. Polymarket pads some non-sports events with unpriced placeholder outcomes ("Party A" … "Party F") it can name later; Layer ignores them until they're priced. Once a Kalshi series has approved matches in a Polymarket series, Layer looks in that series directly, keeping only events of the same market type between the same two teams or players. A game's other markets are found through the game itself: once one of its markets is matched (usually the winner), each Kalshi market in the same game is compared only with the Polymarket market of the same type in that same game. The two venues' titles for these share few words ("Philadelphia vs Chicago: Passing Yards" and "Eagles vs. Bears - Player Props"), so a text search would miss them. ### Polymarket US Kalshi events are matched with Polymarket US (polymarket.us) the same way, as a separate set of matches: every match is Kalshi plus one other venue, and the other venues never mix. Polymarket US names its market types its own way (`football_team_full_game_spread`, `hockey_team_first_period_total`), so at ingest each is renamed to the type of the Kalshi series that is the same bet (`spreads`, `p1_totals`). The names don't always say what a type holds: `football_team_full_game_total` is the game's total and `football_team_points_full_game_total` is a team's, so the renaming was checked against the lines each type actually lists. A type with no Kalshi counterpart keeps its own name. Polymarket US lists the big leagues' games under the same event slugs as polymarket.com (`nfl-dal-hou-2026-10-04` on both). So once a Kalshi event is matched with polymarket.com, the Polymarket US event with the same slug and market type is its candidate, and a game's other markets follow as above. Everything else goes through the same text search, hard checks and graders. Ask for Polymarket US matches with `?venue=polymarket_us` (see the [API reference](https://uselayer.sh/docs/api.md)). Since the two Polymarkets are never paired with each other, a polymarket.com market's Polymarket US twin is found through Kalshi: its Kalshi match, then that Kalshi market's Polymarket US match. ### Hyperliquid Kalshi's events are also matched with Hyperliquid's outcome markets, a third separate set of matches. Games: winners (NBA, NFL, esports), soccer results (each team and the draw), and a game's totals and spreads. And, since November 2026, the rest Kalshi also lists: season winners (Super Bowl, NBA, MLB, Premier League and Champions League champions, each team its own contract; Hyperliquid lists a few teams, Kalshi every team, and only teams both list pair), crypto prices at a set time (BTC, ETH, SOL and HYPE above a price, against Kalshi's hourly price markets at the same hour; they pair only on a price level both venues list, which is rare, and they settle on different price feeds, Pyth on Hyperliquid and CF Benchmarks on Kalshi), and an IPO by a date (Anthropic). These are found by series, not by a text search, so a crypto price at the wrong hour is never offered. The Fed's rate decision is read but doesn't pair: Hyperliquid lists Decrease, No change and Increase, Kalshi lists each size of change. Hyperliquid's prices of its own perps (trade.xyz's S&P 500, oil, gold), touch markets and IPO market caps aren't matched. Hyperliquid writes every market from a template, and Layer fills the template's full rules in with the market's fields (teams, competition, start, what play counts, official source), so the graders read the same text Hyperliquid's own settlement does. Hyperliquid states every start in UTC: an NBA game Kalshi has on Oct 21 is Hyperliquid's Oct 22 at 12:30 AM UTC, so a stated start also counts on its US Eastern day. A Kalshi league only meets the Hyperliquid competition it can be (Kalshi's NHL game never pairs with Hyperliquid's NFL game of sides that read alike), and Kalshi's virtual eBasketball, eFootball and eSoccer games never pair with real ones. Each side of an outcome is its own contract, keyed by its coin (`#14960` is side 0 of outcome 1496). Ask for these matches with `?venue=hyperliquid`. Their settlements are checked like every other match's (`/v0/settlements?venue=hyperliquid`). `/v0/profit` and `/v0/size` don't take Hyperliquid yet. *One game on all four venues, Oct 22: different names, the same outcomes and rules, and one call per venue.* ### Novig Kalshi's sports events are also matched with Novig (novig.com), a US sports exchange: a fourth separate set of matches, with Kalshi only. Game lines: moneylines, spreads, totals and soccer's three-way result, where each team's win and the draw are separate Yes/No markets on Novig and Layer pairs each one's Yes. Season futures, since November 2026: the Super Bowl, Stanley Cup, NBA and college football champions, the NBA Cup, the AFC and NFC champions, each NFL division, NFL and NBA playoff qualifiers and the NFL MVP, each team or player its own contract (the Yes of its Novig market), found by the one Kalshi series that asks the same question, and only teams or players both venues list pair. First-half moneylines, spreads and totals are read only when Layer's operator switches them on, since they add a few hundred pairs a week to grade; a first-half winner doesn't pair, as Kalshi's has a tie and Novig's has two sides. No player props. Layer reads Novig's open games from its public catalog and names both sides in full from the game ("Toronto Raptors at Los Angeles Clippers"). Novig publishes no contract rules text in its API, so the rules the graders read are a short summary Layer writes from Novig's fields, and it says what it doesn't know: whether overtime or extra time counts, and how a postponed game settles. Starts are stated in UTC and US Eastern, as for Hyperliquid. A Kalshi league only meets the same Novig league (Kalshi's NHL game never pairs with Novig's NFL game), and Kalshi's virtual games never pair with real ones. Each Novig outcome is its own contract, keyed `:`. Ask for these matches with `?venue=novig`; if they're ever switched off, asking for them answers `403 venue_not_served`. `/v0/profit` and `/v0/size` don't take Novig yet. ## 3. Rule them out with hard checks Code rejects a candidate — no AI involved — if: - either venue's rules are empty, - the dates are missing or too far apart, - for a sports game, the two venues' rules name different game days (two games of one series), - one side is a women's league and the other the men's league of the same clubs (Liga F vs La Liga), - the market types differ (a game's winner vs its total points), - one side is a singles match and the other a doubles match (tennis, pickleball), - one side is a ladder of thresholds and the other a set of ranges, - the number of outcomes or the kind of line differs, - no outcome on one side has the exact same line as an outcome on the other. "At least 60" and "above 60" are different lines: they pay differently if the result is exactly 60. The same checks run again whenever a pair is approved, by the graders or by a person, so a pair that fails one by then (a check added later, or a date a venue moved) is rejected instead of going live. One exception to the outcome count: elections. Each venue lists the candidates it chooses, so the two lists rarely have the same length. A named candidate pays out the same whoever else is listed, so a politics event whose outcomes are plain names (no lines) isn't rejected for its count. Only candidates both venues name can pair. A catch-all like "Other" or "Field" never pairs, because on each venue it covers different people. Outcomes are paired the same way: a line only pairs with the same line about the same player or team. In a game's props, dozens of players share "over 0.5", so the line alone would pair the wrong players. Names count as the same when they differ only by accents ("Goias" / "Goiás EC"), length ("Atlanta" / "Atlanta Braves"), abbreviations ("Ind. Medellin", "St. Louis" / "Saint Louis") or initials ("Chicago WS" / "Chicago White Sox"). Teams that only share a word, like AC Milan and Inter Milan, never pair. A single yes/no question on each side counts as one possible pairing, whatever the two venues label it ("Yes", "Before 2027", or the whole question). The graders decide whether it's the same question. ## 4. Grade with two independent graders What survives first gets a quick screen: pairs that are clearly about different events are dropped before the full grading. Season winners on Novig and Hyperliquid skip it, since they're found by the Kalshi series that asks the same question, not by a text search. The queue is worked in order of what matters soonest: events in the core series (listed under "Measuring what Layer misses" below) come first, soonest first, so this week's NFL, NHL and top soccer games are graded before they start. Other upcoming events follow, also soonest first, and events whose date has already passed come last. An event Kalshi dates only by when it settles is placed by Polymarket's earlier date for it. How similar the two listings look only breaks ties. The rest go to two independent AI graders, each reading both venues' full rules: - **The first grader** writes a verdict (*same bet*, *same bet with differences*, *different*), a confidence, the exact rule differences, and a one-sentence explanation. - **The second grader**, built differently, answers the same questions with its own probabilities. It's asked about each outcome pairing the first grader confirmed on its own (each spread or total line, each Fed bucket), so a 25-line market is judged line by line, not all at once. **Rule differences are also checked once per league.** Every game between one Kalshi series and one series on the other venue (Kalshi's NBA games and Hyperliquid's or Novig's NBA games, say) is written from the same rules, with only teams, dates and lines changed. So a stronger grader reads one live match of each pairing for the differences that hold for every game, such as the data source or whether overtime counts, and those flags go on every match of the pairing, along with any the match has of its own. Each pairing is checked again every month. Both venues write the same rules for every line of a game, changing only the team, player and number. Layer shows the graders one line's rules, says so, and gives each pairing's own question from both venues ("Edmonton wins by over 1.5 goals", "Spread: Oilers (-1.5)"), so each line is judged by its own number. ## 5. Go live only when it's safe A match goes live when **the first grader is at least 90% sure**, or **both graders say "same bet" at 80% or more**. Only the outcome pairings the graders confirmed are served; under the two-grader rule, that means only the lines both passed. Confident mismatches are removed; everything else waits. ### Crypto windows Crypto markets repeat: a new "up or down in the next 15 minutes" window for BTC, ETH, SOL and XRP every 15 minutes, and a new set of "$X or above" and price-range lines at every hourly, daily and weekly close. Kalshi lists a 15-minute window only when it opens, so the search and graders above would find it too late. Instead, each kind of crypto market is approved once, by a person, as a template: both venues' rules with only the numbers, dates and times hidden. From then on, each new window goes live by itself if: - both venues' rules read word for word like the approved template; - the times in the rules are that window's own start and end; - the window starts and ends at the same moment on both venues. Anything else goes to a person, never live. A new 15-minute window is usually live about 30 seconds after it opens, and `match.live` goes out as soon as it is (see [webhooks](https://uselayer.sh/docs/api.md#webhooks)). Each templated match is checked against both venues' results within minutes of settling, and a single disagreement pulls every match that template made. The exception is a match marked `source_differs`: its venues read different prices, so a very close finish can settle differently without the match being wrong. It's recorded as a disagreement and stays live, with that caveat. What's matched, all with Kalshi: | | 15 minutes, up or down | "$X or above" | Price range | |---|---|---|---| | Polymarket US (`venue=polymarket_us`) | BTC, ETH, SOL, XRP | BTC, ETH, SOL, XRP: hourly, daily and weekly closes | BTC, ETH, SOL, XRP: hourly, daily and weekly closes | | polymarket.com (`venue=polymarket`) | BTC, ETH, SOL, XRP | BTC, ETH: hourly closes | Not matched | "$X or above" and price-range lines pair one by one, only at prices both venues list. Kalshi writes some lines one tick below the round price ("above $91.9999" for SOL, quoted to 4 decimals), which is the same line as Polymarket US's "$92 or above". Where the two venues use different price steps (Kalshi's daily ETH ranges are $40 wide, Polymarket US's $20), only the shared lines pair, sometimes none. How each kind settles, and what `basis` and `caveats` say about it: - **Polymarket US, 15 minutes:** both venues use CF Benchmarks' price index for the coin, averaged over the last 60 seconds. BTC, ETH and SOL are `identical`. XRP is `equivalent_with_caveats` with `rounding_differs`: Kalshi rounds XRP to 4 decimals and Polymarket US to 5, so a window that barely moves can settle Yes on Kalshi and Down on Polymarket US. - **Polymarket US, "$X or above" and ranges:** the same index, but Polymarket US rounds the closing price and Kalshi's rules don't say it does: `rounding_differs`. - **polymarket.com, 15 minutes:** polymarket.com settles on Chainlink's 60-second average, Kalshi on CF Benchmarks': `source_differs`. - **polymarket.com, "$X or above":** polymarket.com settles on Binance's 1-hour candle close (the hour's last trade), Kalshi on CF Benchmarks' 60-second average: `source_differs` and `timing_differs`. ## After a match is live If either venue edits the rules of a matched market, the match is pulled from the API (`rules_changed_pending_review`) and goes back to the two graders, who put it live again if it's still the same bet. A venue adding or dropping lines of a ladder isn't a rules edit: Polymarket US's fingerprint of an event's rules masks each line's number. Closed markets disappear on the next pull. ## Checking matches against real results Every match is checked again once both markets resolve. Both venues publish the result, so Layer compares them: - **Agreed** — both paid out the same way. The match was right. - **Disagreed** — one paid Yes, the other No. The match was wrong. Layer pulls that event pair from the API (`needs_review`), records a plain-English explanation from both venues' rules, and flags it so the cause gets fixed. The one exception is a crypto window marked `source_differs`, which stays live (see [Crypto windows](#crypto-windows)). - **Edge case** — one venue voided the market (for example, refunded a postponed game) while the other resolved it. That comes from the venues' settlement policies (see the [Overview](https://uselayer.sh/docs/index.md)), not from a wrong match, so it's counted separately. The share of settled matches that agreed is how Layer measures its own accuracy. Every settlement of a match that was live in the API is served by [`GET /v0/settlements`](https://uselayer.sh/docs/api.md#get-v0-settlements), with its totals (and, with `series`, `category` or `polymarket_series`, the record for one kind of market), and `GET /v0/match` answers a settled market with its `settlement`. ## Measuring what Layer misses Accuracy says whether live matches are right; coverage says how many should-be matches are live. Layer tracks a fixed list of core series both venues list — NFL, college football, NBA, WNBA, NHL, MLB, the top European soccer leagues, Champions League, MLS, ATP and WTA tennis, UFC, Fed decisions, CPI and jobs reports, and control of the House and Senate — and, for every upcoming event in them (next 14 days for sports, 60 for the rest; for an event dated only by when it settles, when the event itself could fall in that window), records the furthest it got: live, waiting to be graded, rejected by the graders, screened out, vetoed, no Polymarket look-alike found, or not searched yet. Not every event in those series is on Polymarket, so the share that's live is a floor. --- # How we measure sports gaps When the same game trades on **Kalshi** and **Polymarket US**, the two prices should match. Sometimes, for a moment, they don't: both sides of a game can be bought for less than $1 after both venues' fees. People measure these moments in different ways and get very different answers. This page sets out exactly how Layer measures them, what we found, and what the data can't tell you. ## What we record Layer's recorders keep every order book update that Kalshi and Polymarket US send for the sports markets Layer has matched across the two venues. Each update is stamped with the time it reached Layer's server. Since 11 November 2026, the files also carry a heartbeat every second, so a gap in the recording shows up as a gap rather than as a quiet market. Each night, the books are rebuilt from those files and replayed on one clock, game by game, from an hour before each game starts to the end of play. ## A gap, and a spread At every book change, the replay asks: can both sides of the game be bought for under $1, after both venues' taker fees, at the prices and sizes actually on the books? Both ways round are checked: YES on Kalshi with NO on Polymarket US, and the reverse. - A **gap** runs from the first book change where at least one whole contract locks a profit to the first where none does. - One price move often opens, closes and reopens a gap within a few milliseconds, so gaps on one game less than 250 ms apart count as one **spread**. - For each spread we keep how long it stayed open, the best profit per contract during it, and the number of contracts that would have locked the most profit. ## A move, and who moved first A **move** is either venue's price (the midpoint of its best bid and ask) moving 3¢ or more within 2 seconds. Each move is counted once: - The **leader** is the venue whose price got 2¢ past where it was 2 seconds earlier first. - The **lag** is how much later the other venue got there, within 30 seconds. - Under 1 second counts as **together**: updates from the two venues can reach Layer up to about 0.1 seconds apart. - The next 10 seconds of the same game are part of the same move. ## Why our gaps look shorter than other people's Most tools check prices every quarter-second to half-second. A tool that checks every half-second can only see a gap that happens to be open at one of its checks, and it will measure that gap as one check long. Layer records every change instead. On Sunday 8 November 2026 (game winners), Layer recorded 298 spreads. Read back every 250 ms, the same data shows 85; read every 500 ms, it shows 40, and each one looks exactly one check long. Both methods see real gaps. Checking at intervals hides the short ones and rounds the rest up to the interval. ## What we found Game winners, 4–10 November 2026 (215 games: NBA, NFL, NHL and college football): - 1,767 spreads cleared both venues' fees, from 68 to 782 a day. - Half closed within 59 ms, and 74% within 100 ms. 28 stayed open for 1 second or more. - The median profit was 0.86¢ a contract at the spread's best moment. - A stricter test: still open 250 ms after it opened, and still locking at least $1 and at least 1¢ a contract after fees at that point. At most 30 of the 1,767 passed. On 8 November, 0 of 298 did; on 1 November, 4 of 308. Who moved first, counted once per move (game winners): | Day | Moves | Both venues within 1 s | Kalshi moved first | Polymarket US followed Kalshi (median) | Kalshi followed Polymarket US (median) | |---|---|---|---|---|---| | 1 Nov 2026 | 725 | 83% | 82% | 149 ms | 352 ms | | 8 Nov 2026 | 920 | 80% | 81% | 149 ms | 363 ms | From 12 November 2026, the [nightly stats](https://uselayer.sh/docs/sports.md#get-v0-sports-lead) use this rule for every day. ## Limits - **No fills.** These numbers come from recorded order books, not trades. On 8 November, only about a third of game-winner spreads kept their quoted size for as long as they were open, so a real fill is usually smaller, or none. - **Our clock.** Times are when each update reached Layer. Polymarket US updates reach Layer a median 58 ms after Polymarket US's own timestamp, Kalshi's 14 ms after Kalshi's, so part of any Polymarket US lag is delivery time. - **Recorder outages.** When a venue's recording goes quiet for more than 5 seconds, its prices count as unknown until it resumes, so nothing is measured across the gap. On 7 November 2026 the Kalshi recorder went quiet many times that evening. - **Spread and total markets.** Some Kalshi spread and total markets stopped updating in Layer's recording while they kept trading, so their books looked frozen for minutes. Game-winner markets were not affected. Until the cause is found and fixed, the nightly stats cover game winners only, and any spread or total number Layer quotes comes from a one-off check with the frozen books removed (such as 8 November 2026, where 17 windows reached a 9.4% return or more and none lasted longer than 239 ms). ## Corrections - **11 November 2026:** the first version of the lead/lag stats scored each venue as leader separately, so one move was counted twice, and its lag counted only the slowest followers. That gave a "Kalshi catches up to Polymarket US" median of 2.1 seconds, which was wrong; counted once per move it is about 0.35 seconds. The stats were fixed the same day, and earlier days' moves are no longer served. ## Check it yourself - [`GET /v0/sports/lead`](https://uselayer.sh/docs/sports.md#get-v0-sports-lead) and [`GET /v0/sports/events`](https://uselayer.sh/docs/sports.md#get-v0-sports-events): the nightly numbers, and every spread and move with its time, to line up against your own logs. - [Stream](https://uselayer.sh/docs/stream.md): every price change on both venues as it happens. - [Layer Sports](https://uselayer.sh/sports): the live spreads and the last 7 days. --- # Feature requests Layer is small and builds what people ask for. If something is missing, slow or confusing, email [david@uselayer.sh](mailto:david@uselayer.sh?subject=Feature%20request). Every email is read. It helps to say: - What you're building, and what you'd do with the feature. - The language your code is written in. - The venues and markets you care about. - What you do today without it, if anything. ## Ideas we're weighing These aren't built yet. If you'd use one, email us and say which: the ones people ask for get built first. | Idea | What you'd get | |---|---| | Live order books | Every venue's full order book for a match, and each change to it, over one WebSocket in one format. Today the API gives the best bid and ask with their sizes, and the [Python SDK](https://uselayer.sh/docs/sdk.md) reads full books from each venue. | | Full books on one call | Both venues' full books on [`GET /v0/match`](https://uselayer.sh/docs/api.md#get-v0-match). Today [`GET /v0/quote`](https://uselayer.sh/docs/api.md#get-v0-quote) reads them for you and returns the most you can trade before the gap stops paying after fees (Kalshi with Polymarket US). | | SDKs in other languages | A JavaScript, TypeScript, Rust or Go version of the [Python SDK](https://uselayer.sh/docs/sdk.md). Say which language. | ## Found a bug? Email [david@uselayer.sh](mailto:david@uselayer.sh?subject=Bug) with the request you sent, what came back and what you expected. A wrong match is worth reporting too: send both market ids.