TailSlip API
TailSlip tracks the best traders on Polymarket and shows where they agree. This document describes the public REST API that the web app uses. You can call the same API from scripts and bots.
Analytics only. Nothing the API returns is investment advice. The portfolio model and the paper follower are experimental and unvalidated. Polymarket restricts trading in some places, including the US.
1. Basics
Base URL
https://tailslip.com/api
The Next.js frontend forwards every /api/* request to the Go backend and
removes the /api prefix. So https://tailslip.com/api/v1/ideas reaches the
backend route /v1/ideas. In local development the backend listens on
http://localhost:8080 (no /api prefix).
All responses are JSON (Content-Type: application/json), except the SSE
stream and a few browser-only auth redirects. Times are RFC 3339 in UTC.
Prices and probabilities are fractions from 0 to 1. Money is in US dollars.
Authentication
Send one of these credentials. The server checks them in this order:
Authorization: Bearer sm_...- an API key (recommended for scripts).Authorization: Bearer <JWT>- a session token.- The
sessioncookie - what the web app uses.
A request with no credential is anonymous. A request with a bad, expired
or revoked credential is also treated as anonymous (not as an error). So if a
protected endpoint returns 401 unauthorized, check your key.
Get an API key on the Account page, or with POST /v1/me/api-keys (see
Account). The server shows the plain key once. It stores only
a hash. Keys do not expire. Delete a key to revoke it.
export TS_KEY=sm_0123... # your key
curl -H "Authorization: Bearer $TS_KEY" https://tailslip.com/api/v1/me
Plans and data delay
There are three kinds of callers. Pro comes from the user's plan in the database (or a server-side allowlist of emails).
| Caller | Smart-money data | Pro-only endpoints |
|---|---|---|
| Anonymous | 1 hour delayed | 401 unauthorized |
| Free (signed in) | 1 hour delayed | 403 pro_required |
| Pro | Live | Allowed |
1-hour delay (free and anonymous). These endpoints show data as it was 1 hour ago:
| Endpoint | What is delayed |
|---|---|
GET /v1/ideas | The consensus rows and the top_drivers trades |
GET /v1/markets/{key}/consensus | latest and history_7d |
GET /v1/markets/{key}/smart-holders | Holdings, rebuilt from snapshots as of 1 hour ago |
GET /v1/markets/{key}/smart-trades | Only trades older than 1 hour |
GET /v1/events/{key} | The consensus on each market |
GET /v1/wallets/{key}/positions | Positions, rebuilt from snapshots as of 1 hour ago |
GET /v1/wallets/{key}/trades | Only trades older than 1 hour |
These responses carry two extra top-level fields:
| Field | Meaning |
|---|---|
as_of | The moment the data reflects. For pro this is now. |
delay_seconds | 3600 for free and anonymous, 0 for pro. |
A delayed consensus row must also be at most 2 hours older than the delay point (so between 1 and 3 hours old). If a market's signal stopped, it drops out of the delayed feed instead of showing its last value forever.
24-hour delay (paper book and system portfolios).
GET /v1/track-record: non-pro callers see every paper book as it stood 24 hours ago. Positions opened in the last 24 hours are hidden and counted inhidden_open_positions. Pro sees the current state.GET /v1/portfolio/recommendations/{id}for a system (paper follower) recommendation: non-pro callers see only settled positions until the recommendation is 24 hours old or fully settled.
Not delayed for anyone: tags, market and event lists, a single market, wallet profiles, the top-wallets list, score history and search. These do not contain fresh smart-money signals.
Rate limits
Fixed 1-minute windows, kept in memory on the API server.
| Who | Limit | Counted per |
|---|---|---|
| Anonymous | 120 requests/min | Client IP |
| Free | 240 requests/min | User |
| Pro | 600 requests/min | User |
| Everyone, before auth | 1200 requests/min | Client IP |
Sign-in endpoints (/v1/auth/email*, /v1/auth/magic-link) | 20 requests/min | Client IP |
POST /v1/portfolio/recommend | 30 requests/hour | User |
Every rate-limited response (after auth) has these headers:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Your limit for the window |
X-RateLimit-Remaining | Requests left in the window |
X-RateLimit-Reset | Unix time when the window resets |
Over the limit you get 429 with a Retry-After header (seconds) and
details.retry_after_seconds in the body. Wait that long, then retry.
Response envelope
A successful read looks like this:
{
"data": [ ... ],
"next_cursor": "bzo1MA=="
}
datais an array or an object. Empty lists are[], nevernull.next_cursorisnullwhen there are no more pages.- Delayed endpoints add
as_ofanddelay_seconds(see above). - Signal endpoints add
disclaimer(a text string).
A few write endpoints return a small status object instead, for example
{"status": "added"}. Each endpoint below shows its shape.
Errors
{
"error": {
"code": "pro_required",
"message": "this feature requires a pro plan",
"details": null
}
}
Use code in your program. message is for people and can change.
details is present only for some codes.
| HTTP | code | When |
|---|---|---|
| 400 | bad_request | Missing or bad parameter, or invalid JSON body |
| 400 | bad_cursor | after on the fills feed is not a non-negative integer |
| 400 | invalid_id | A path id is not a number (recommendations, bet profiles) |
| 400 | invalid_budget, invalid_risk, invalid_max_positions, invalid_min_price, invalid_horizon, invalid_tags, invalid_name | Portfolio profile validation |
| 401 | unauthorized | The endpoint needs a signed-in user |
| 401 | invalid_code | Wrong or expired email sign-in code |
| 403 | pro_required | Signed in, but the endpoint needs pro |
| 403 | session_required | API-key management needs a browser session, not an API key |
| 403 | bad_origin | A non-GET request from a browser on another site (see CSRF) |
| 404 | not_found | Market, event, wallet, recommendation or profile not found |
| 404 | no_consensus | The market has no smart-money data (yet, or any more) |
| 409 | track_stale | Recommendation too old to track (over 24 h), or a market has no price |
| 409 | track_resolved | Recommendation holds a closed or resolved market |
| 413 | body_too_large | JSON body over 64 KiB (any body over 1 MiB is cut off) |
| 415 | unsupported_media_type | Body is not application/json |
| 429 | rate_limited | Too many requests (see above) |
| 429 | too_many_streams | More than 5 open SSE streams for one user |
| 429 | email_rate_limited | Too many sign-in emails for one address |
| 429 | too_many_attempts | Too many wrong sign-in codes |
| 500 | internal, db_error | Server error |
| 502 | books_unavailable | Polymarket order books could not be read |
| 502 | email_failed | The sign-in email could not be sent |
| 503 | db_unavailable | /health only: database down |
An unknown path returns the router's plain-text 404 page not found, not the
JSON envelope.
CSRF and request bodies
For POST and DELETE:
- If the request has an
Originheader, it must be the app's own origin. Scripts and bots normally send noOrigin, so they pass. - A body must be
Content-Type: application/json(only the magic-link form usesapplication/x-www-form-urlencoded).
Pagination
Most lists use an opaque cursor. Pass next_cursor back as ?cursor= to
get the next page. Keep the other parameters the same. The cursor is an
offset, so a list that changes between calls can skip or repeat rows.
| Parameter | Meaning |
|---|---|
limit | Page size. Values over the maximum are lowered to the maximum. Values of 0 or less are ignored. |
cursor | The next_cursor from the last page. |
next_cursor is null when the page had fewer rows than limit.
Exception: the trading-bot feed GET /v1/paper/follower/fills uses an id
cursor (?after=<fill id>). See section 3.
Identifiers
- Market
{key}: the numeric TailSlip id, the market slug, or the Polymarketcondition_id. - Event
{key}: the numeric TailSlip id, the event slug, or the Polymarket event id. - Wallet
{key}: the proxy wallet address (0x..., any case) or the numeric TailSlip id.
2. Endpoint reference
Common objects first, then the endpoints by group.
Common objects
Market
| Field | Meaning |
|---|---|
id | TailSlip market id |
event_id | Parent event id (may be absent) |
condition_id | Polymarket condition id |
slug, question | Polymarket slug and question text |
outcomes | Outcome labels, for example ["Yes","No"] |
token_ids | Polymarket CLOB token id for each outcome, same order as outcomes |
current_prices | Last price for each outcome, same order |
active, closed, resolved | Market status flags |
resolved_outcome | Index of the winning outcome, when resolved |
end_date | Scheduled end |
liquidity_usd, volume_usd | From Polymarket |
Event: id, polymarket_id, slug, title, description, closed,
neg_risk (a multi-outcome "negative risk" event), end_date, tags
(list of Tag).
Tag: id, slug, label.
Consensus (what the tracked traders agree on in one market)
| Field | Meaning |
|---|---|
computed_at | When this row was computed |
outcome_index | The outcome the smart money backs (absent if none) |
strength | Share of smart stake on that outcome. This is agreement, not a probability. |
smart_wallets_count | Smart wallets with a position in the market (any side) |
backers_count | Smart wallets on the consensus side |
weighted_holdings_usd, raw_holdings_usd | Smart holdings, quality-weighted and raw |
net_flow_24h_usd, net_flow_72h_usd | Net smart buying (positive) or selling (negative) |
current_market_price | Price of the consensus outcome |
implied_prob | The model's probability for the outcome (shrunk and decayed) |
q_implied | The raw, unshrunk estimate (for transparency only) |
shrink_beta | How much the raw estimate was pulled toward the price |
divergence | implied_prob - current_market_price. Positive means the model thinks the price is too low. |
edge_remaining | max(0, divergence) |
smart_entry_price | Stake-weighted average entry of the smart money (null = unknown) |
entry_gap | Current price minus smart entry |
edge_captured | Share of the smart money's entry edge already in the price |
last_backing_at | Newest buy on the consensus side |
signal_age_seconds | computed_at - last_backing_at |
exit_signal | true when smart wallets are selling this outcome |
exit_flow_24h_usd | Size of that selling |
idea_score | Ranking score for the ideas feed |
confidence | high, medium or low |
model | shrink or calibrated |
model_version | Version of the calibrated model, if any |
model_validated | true only for a calibrated model that beat the price on held-out data |
Wallet: id, proxy_wallet, username, tier (1 = top, 2 = mid,
3 = probation; "smart" means tier 1 or 2), is_market_maker, excluded,
bankroll_usd, first_seen_at, quality_score, roi_90d,
resolved_count_90d. The last three come from the latest score and can be
absent.
Smart trade (a trade by a tier 1 or 2 wallet): ts, outcome_index,
side (0 = buy, 1 = sell), size_tokens, price, cash_usd, tx_hash,
proxy_wallet, username, tier, quality_score.
Health
GET /health
No auth. Returns {"status":"ok","version":"0.1.0"}, or 503 db_unavailable.
This response has no data wrapper.
curl https://tailslip.com/api/health
Ideas and markets
GET /v1/tags
No auth. All tags, sorted by slug. data: list of Tag.
curl https://tailslip.com/api/v1/tags
GET /v1/ideas
The main feed: markets where the smart-money consensus differs from the price. No auth. 1-hour delay for free and anonymous callers.
| Parameter | Default | Meaning |
|---|---|---|
min_divergence | 0.05 | Minimum divergence |
min_smart_wallets | 3 | Minimum backers_count (wallets on the consensus side) |
min_liquidity_usd | 10000 | Minimum market liquidity |
min_price | 0 | Minimum price of the consensus outcome |
max_price | 1 | Maximum price of the consensus outcome |
tag | none | Tag slug. Comma-separated list = any of these tags. |
sort | idea_score | idea_score, divergence, recency (newest consensus) or promising (idea_score x price) |
limit | 50 | Max 100 |
cursor | From next_cursor |
Only active markets are listed. Each data item:
| Field | Meaning |
|---|---|
market | Market |
event | {id, slug, title} of the parent event (may be absent) |
tags | Tag slugs of the event |
consensus | Consensus |
idea_score | Same as consensus.idea_score |
idea_score_percentile | Rank (0 to 1) among all active markets, before your filters |
top_drivers | Up to 3 recent smart trades in this market (delayed like the feed) |
Top level also has as_of, delay_seconds and disclaimer.
curl -H "Authorization: Bearer $TS_KEY" \
"https://tailslip.com/api/v1/ideas?tag=politics,sports&min_divergence=0.08&limit=20"
GET /v1/markets
No auth. Sorted by volume, highest first. data: list of Market.
| Parameter | Default | Meaning |
|---|---|---|
status | open | open (active), closed or all |
active | Legacy: active=false with no status means all | |
tag | none | One tag slug |
limit | 50 | Max 100 |
cursor | From next_cursor |
curl "https://tailslip.com/api/v1/markets?tag=crypto&limit=10"
GET /v1/markets/{key}
No auth. data: one Market. 404 not_found if unknown.
curl https://tailslip.com/api/v1/markets/will-it-rain-tomorrow
GET /v1/markets/{key}/consensus
No auth. 1-hour delay for free and anonymous callers.
data:
| Field | Meaning |
|---|---|
latest | The newest Consensus row |
history_7d | Consensus rows of the last 7 days, oldest first |
Returns 404 no_consensus when the market has no current consensus row.
For a market that had a signal, this means the signal is gone.
curl -H "Authorization: Bearer $TS_KEY" \
https://tailslip.com/api/v1/markets/12345/consensus
GET /v1/markets/{key}/smart-holders
No auth. 1-hour delay for free and anonymous callers. Smart wallets (tier 1 or 2, no market makers) that hold the market, largest first.
| Parameter | Default | Meaning |
|---|---|---|
limit | 20 | Max 100. There is no cursor; next_cursor is always null. |
Each item: proxy_wallet, username, tier, quality_score,
outcome_index, outcome (label), size_tokens, avg_price, cur_price,
cash_pnl_usd, size_usd (current value of the position).
curl https://tailslip.com/api/v1/markets/12345/smart-holders?limit=10
GET /v1/markets/{key}/smart-trades
No auth. 1-hour delay for free and anonymous callers. Recent smart trades in the market, newest first.
| Parameter | Default | Meaning |
|---|---|---|
limit | 50 | Max 200. No cursor. |
data: list of Smart trade.
curl https://tailslip.com/api/v1/markets/12345/smart-trades?limit=20
Events
GET /v1/events
No auth. Newest first. data: list of Event (without tags).
| Parameter | Default | Meaning |
|---|---|---|
status | open | open (not closed), closed or all |
tag | none | One tag slug |
limit | 50 | Max 100 |
cursor | From next_cursor |
curl "https://tailslip.com/api/v1/events?tag=politics"
GET /v1/events/{key}
No auth. 1-hour delay on the consensus for free and anonymous callers.
data:
| Field | Meaning |
|---|---|
event | Event, with tags |
markets | Markets of the event (by volume). Each is a Market plus consensus (Consensus, absent when there is none). |
Top level also has as_of, delay_seconds and disclaimer.
curl https://tailslip.com/api/v1/events/presidential-election-2028
Wallets
GET /v1/wallets/top
No auth. Scored wallets, not excluded ones. data: list of Wallet.
| Parameter | Default | Meaning |
|---|---|---|
sort | quality | quality or roi. With roi, wallets with 5 or more resolved markets come first; wallets with no ROI come last. |
limit | 50 | Max 100 |
cursor | From next_cursor |
curl "https://tailslip.com/api/v1/wallets/top?sort=roi&limit=25"
GET /v1/wallets/{key}
No auth. data: one Wallet. 404 not_found if unknown.
curl https://tailslip.com/api/v1/wallets/0xabc...def
GET /v1/wallets/{key}/positions
No auth. 1-hour delay for free and anonymous callers. Current positions, largest first. No paging.
Each item: market_id, market_slug, market_question, outcome_index,
outcome, size_tokens, avg_price, cur_price, cash_pnl_usd,
current_value_usd.
curl https://tailslip.com/api/v1/wallets/0xabc...def/positions
GET /v1/wallets/{key}/trades
No auth. 1-hour delay for free and anonymous callers. Newest first.
| Parameter | Default | Meaning |
|---|---|---|
limit | 50 | Max 200 |
cursor | From next_cursor |
Each item: ts, outcome_index, side (0 = buy, 1 = sell),
size_tokens, price, cash_usd, tx_hash, market_slug,
market_question.
curl "https://tailslip.com/api/v1/wallets/0xabc...def/trades?limit=100"
GET /v1/wallets/{key}/score-history
No auth. The last 90 score rows (not 90 days), newest first.
Main fields of each row: computed_at, quality_score, pnl_30d_usd,
pnl_90d_usd, roi_30d, roi_90d, volume_90d_usd, deployed_90d_usd,
trade_count_90d, distinct_markets_90d, win_rate_resolved,
resolved_count_90d, sharpe_like, consistency_score, skill_score,
skill_n_eff, brier, brier_price (lower is better; brier < brier_price
means copying the wallet beat the price), scoring_status (scored,
probation, thin, market_maker, arbitrage or quarantined),
unmatched_sell_ratio, recon_mismatch, arb_ratio.
curl https://tailslip.com/api/v1/wallets/0xabc...def/score-history
Search
GET /v1/search?q=<text>
No auth. Text match on event titles and slugs, market questions and slugs,
and wallet usernames (wallet addresses match by prefix). Up to 10 of each.
q is required (400 bad_request if empty).
data: { "events": [Event], "markets": [Market], "wallets": [Wallet] }.
Search wallets have no score fields.
curl "https://tailslip.com/api/v1/search?q=bitcoin"
Track record and paper book
The paper follower is a simulated book that runs every hour (by default)
with fake money. It buys what the portfolio model picks and sells when the
signal decays. It pays modeled fees and slippage. Two benchmark books
take the same stake sizes: favorite and placebo. The follower only shows
an edge if it beats the placebo over time.
GET /v1/track-record
No auth. Non-pro callers see every book as of 24 hours ago. Pro sees the current state.
| Parameter | Default | Meaning |
|---|---|---|
limit | 30 | Max 100. Pages the legacy entries list. |
cursor | From next_cursor |
data.summary:
| Field | Meaning |
|---|---|
book | The follower book (PaperBookSummary, null before its first run) |
benchmarks | The favorite and placebo books |
disclosure | Text that states the latency, cost and marking assumptions |
recommendations, positions, settled, voided, open, deployed_settled_usd, realized_pnl_usd, roi, hit_rate, avg_q_settled | Legacy totals from the old nightly system portfolios |
legacy_note | Why the legacy totals are not the track record |
A PaperBookSummary has:
| Field | Meaning |
|---|---|
name, description | follower, favorite or placebo |
inception_at, last_run_at | Start and last run |
starting_cash_usd, cash_usd | Cash at start and now |
positions_value_usd | Open positions at their bid-side marks |
equity_usd | Cash plus positions |
roi | equity / starting cash - 1 (realized and unrealized) |
realized_pnl_usd, unrealized_pnl_usd, fees_usd | |
max_drawdown | Largest fall from a peak, as a share of the peak |
open_positions, closed_positions, settled_positions | Closed = sold before resolution. Settled = held to resolution. |
hit_rate | Share of closed and settled positions with profit |
equity_curve | Daily points: ts, cash_usd, positions_value_usd, equity_usd, open_positions |
positions | Follower only: all open positions and the 30 newest closed or settled |
hidden_open_positions | Open positions hidden by the 24-hour delay |
params | The book's settings (interval, fees, slippage and more) |
Each paper position has market_id, outcome_index, question,
market_slug, event_slug, outcome_label, status (open, closed,
settled), shares, cost_usd, entry_price, entry_q, signal_price,
signal_at, opened_at, closed_at, exit_reason, exit_price,
mark_price, value_usd, realized_pnl_usd, unrealized_pnl_usd,
fees_usd, provisional (settled on a resolution that is not final yet).
data.entries is the legacy list of system recommendations (headers only),
newest first. Each has the Recommendation header fields (below) plus
locked and delayed_until.
Top level: data, next_cursor, disclaimer.
curl https://tailslip.com/api/v1/track-record
GET /v1/paper/follower/fills
Pro only. The follower book's trade ledger, oldest first. This is the feed for trading bots. See section 3 for the full description.
| Parameter | Default | Meaning |
|---|---|---|
after | 0 | Return fills with id greater than this |
limit | 100 | 1 to 500. A value outside this range is replaced by 100. |
curl -H "Authorization: Bearer $TS_KEY" \
"https://tailslip.com/api/v1/paper/follower/fills?after=0&limit=500"
Portfolio
The portfolio engine turns the ideas feed into stake sizes for a budget. It reads live Polymarket order books. The model is unvalidated.
POST /v1/portfolio/recommend
Pro only. Limit: 30 valid requests per hour per user. The call can take up to a few minutes (it reads up to 200 order books). Every call is saved as a recommendation.
Body (JSON). Send either profile_id (a saved bet profile; then the other
fields are ignored) or the fields inline:
| Field | Default | Rule |
|---|---|---|
profile_id | Id of one of your bet profiles | |
budget_usd | (required) | 50 to 50000 |
risk | balanced | conservative, balanced or aggressive |
tags | any | Up to 20 tag slugs, 1 to 64 characters each |
horizon_days | 0 | 0 (any) to 365. Only markets that end within this many days. |
max_positions | 8 | 3 to 20 |
min_price | 0.15 | 0 to 0.5. Send 0 to remove the floor. |
data:
| Field | Meaning |
|---|---|
id | Recommendation id |
algo_version | Engine version, for example portfolio-v2 |
consensus_batch | Time of the consensus data used |
profile | The profile that was used, after defaults |
positions | The sized positions (below) |
stats | Totals (below) |
Each position: market_id, outcome_index, question, market_slug,
event_slug, outcome_label, price_at_rec (live mid), fill_price
(average price from walking the order book; the PnL basis), implied_prob,
stake_usd, shares (whole shares), kelly_raw (Kelly fraction before
caps), binding_cap (the rule that set the final size), rationale (the
top smart trades), fee_rate_bps, fee_usd, effective_price (fill + fee
per share + haircut), edge_at_fill (implied_prob - effective_price),
limit_price (worst book level touched, on the tick grid), cluster.
stats: deployed_usd, fees_usd, e_profit_usd (expected profit under
the model), p_loss (chance of a loss under the model, Monte Carlo),
p_loss_basis, max_loss_usd, median_end, n_candidates, n_dropped
(count of refused candidates by reason, for example no_edge,
edge_after_slippage, no_book_depth, below_min_order, dust, budget,
tag_cap, cluster_cap, max_positions, confidence, horizon,
price_band, liquidity, no_token, no_book, pre_edge,
event_correlated, exit_signal, stale_signal, model_unvalidated),
model, model_validated.
An empty positions list is a valid answer. n_dropped tells you why.
curl -X POST -H "Authorization: Bearer $TS_KEY" -H "Content-Type: application/json" \
-d '{"budget_usd": 500, "risk": "balanced", "max_positions": 6}' \
https://tailslip.com/api/v1/portfolio/recommend
GET /v1/portfolio/recommendations
Signed in (any plan). Your own recommendations, newest first.
| Parameter | Default | Meaning |
|---|---|---|
limit | 20 | Max 100 |
cursor | From next_cursor |
Each item (a Recommendation header): id, system, origin (system
or user), legacy, profile, algo_version, consensus_batch,
tracked, created_at, stats, positions_count, settled_count,
voided_count, deployed_usd, realized_pnl_usd.
curl -H "Authorization: Bearer $TS_KEY" https://tailslip.com/api/v1/portfolio/recommendations
GET /v1/portfolio/recommendations/{id}
No auth gate. You can read your own recommendations and any system (paper
follower) recommendation. Other users' recommendations return
404 not_found.
data:
| Field | Meaning |
|---|---|
recommendation | Recommendation header |
positions | Positions: market_id, outcome_index, question, market_slug, event_slug, outcome_label, price_at_rec, fill_price, implied_prob, stake_usd, shares, kelly_raw, binding_cap, rationale, current_price, resolved, resolved_won, exit_price, pnl_usd |
locked | true when unsettled picks are hidden from you |
delayed_until | When the lock ends |
hidden_positions | Number of hidden positions |
A system recommendation is locked for non-pro callers until it is 24 hours old or fully settled.
curl https://tailslip.com/api/v1/portfolio/recommendations/42
POST /v1/portfolio/recommendations/{id}/track
Signed in. Marks your recommendation as "tracked" (you say you placed it).
No body. If more than 15 minutes have passed, the positions are re-priced at
the current market price. Refused with 409 track_stale after 24 hours, and
with 409 track_resolved if a market has closed. Calling it again is safe.
Returns {"data": {"id": 42, "tracked": true}}.
curl -X POST -H "Authorization: Bearer $TS_KEY" \
https://tailslip.com/api/v1/portfolio/recommendations/42/track
Bet profiles
Saved inputs for POST /v1/portfolio/recommend. Signed in (any plan).
| Method and path | Body | Returns |
|---|---|---|
GET /v1/me/bet-profiles | data: list of {id, name, budget_usd, risk, tags, horizon_days, max_positions, min_price, created_at} | |
POST /v1/me/bet-profiles | Same fields as recommend, plus name (max 64 characters, default default). Saving an existing name replaces it. | {"data": {"id", "name"}} |
DELETE /v1/me/bet-profiles/{id} | {"data": {"deleted": true}} |
curl -X POST -H "Authorization: Bearer $TS_KEY" -H "Content-Type: application/json" \
-d '{"name": "small", "budget_usd": 200, "risk": "conservative"}' \
https://tailslip.com/api/v1/me/bet-profiles
Streaming
GET /v1/stream/consensus
Pro only. A Server-Sent Events stream. The server sends an event each
time it writes a fresh consensus for a market, including when a market's
signal disappears. At most 5 open streams per user (429 too_many_streams).
retry: 5000
event: consensus
data: {"market_id":12345}
event: heartbeat
data: {"ts":"2026-11-08T12:00:15Z"}
event: unauthorized
data: {}
consensus: the market changed. ReadGET /v1/markets/{id}/consensusfor the new values. A404 no_consensusthere means the signal is gone.heartbeat: every 15 seconds. If you see none for a minute, reconnect.unauthorized: sent once, then the stream closes. The server re-checks your credential about every 60 seconds. You see this if your key was deleted, your session ended or your plan is no longer pro. Do not reconnect with the same credential.
If your client is slow, the server can drop events for you. Treat the stream as a hint to re-read, not as a complete log.
curl -N -H "Authorization: Bearer $TS_KEY" https://tailslip.com/api/v1/stream/consensus
Account
All account endpoints need a signed-in user (any plan). An API key counts as signed in.
GET /v1/me
data: {id, email, plan, created_at}. plan is free or pro.
curl -H "Authorization: Bearer $TS_KEY" https://tailslip.com/api/v1/me
API keys
| Method and path | Body | Returns |
|---|---|---|
GET /v1/me/api-keys | data: list of {id, label, last_used_at, created_at} | |
POST /v1/me/api-keys | Optional {"label": "..."} (max 100 characters) | 201 with {"data": {id, label, created_at}, "key": "sm_..."}. Save key now; it is shown only once. |
DELETE /v1/me/api-keys/{id} | {"status": "deleted"} |
Creating and deleting keys needs a signed-in browser session. A request
authenticated with an API key gets 403 session_required, so a leaked key
cannot create more keys or revoke yours. Manage keys on the Account page.
Watchlist
| Method and path | Body | Returns |
|---|---|---|
GET /v1/me/watchlist | data: list of Market, newest added first | |
POST /v1/me/watchlist | {"market_id": 12345} (numeric id) | 201 with {"status": "added"} |
DELETE /v1/me/watchlist/{marketID} | {"status": "removed"} |
curl -X POST -H "Authorization: Bearer $TS_KEY" -H "Content-Type: application/json" \
-d '{"market_id": 12345}' https://tailslip.com/api/v1/me/watchlist
Auth (web app)
These endpoints exist for the web app's sign-in page. Scripts should use an API key instead. Sign-in endpoints have a limit of 20 requests per minute per IP. A session lasts 30 days.
| Method and path | Purpose |
|---|---|
GET /v1/auth/google | Starts Google sign-in (redirects to Google) |
GET /v1/auth/google/callback | Google returns here; sets the session cookie and redirects to the app |
POST /v1/auth/email | Body {"email"}. Sends a 6-digit code and a link, valid 15 minutes. Returns {"status": "sent"}. |
POST /v1/auth/email/verify | Body {"email", "code"}. Sets the session. Returns {"data": user}. |
GET /v1/auth/magic-link?token= | Shows a confirm page (so mail scanners do not use up the link) |
POST /v1/auth/magic-link | Form field token. Sets the session and redirects to the app. |
POST /v1/auth/logout | Clears the cookie and ends all browser sessions of the user. API keys stay valid. Sent with an API key, it ends no sessions. Returns {"status": "ok"}. |
A client without a cookie store can send X-Want-Session-Token: 1 on a
sign-in request. The server then returns the JWT in the X-Session-Token
response header.
3. Building a trading bot
Read this first. The strategy is not validated. The paper follower has a short history. Treat it as unproven until it beats its
placebobenchmark onGET /v1/track-recordover a long enough period, after fees and slippage. Until then, a bot that copies it is an experiment. Use money you can lose completely. Nothing here is investment advice, and Polymarket is not available in some places, including the US.
The idea
The paper follower book trades with fake money once an hour (by default). It
records every trade in a ledger. GET /v1/paper/follower/fills gives you
that ledger. Your bot reads new rows and places the same trades on
Polymarket at a smaller or larger size. Then your live results repeat what
the paper test measured, as closely as real fills permit.
TailSlip never holds your wallet keys and never places orders. Your bot
runs on your machine. It signs orders with your own key, through Polymarket's
CLOB client (for example
py-clob-client). The
TailSlip API key only reads data.
The fills feed
GET /v1/paper/follower/fills?after=<id>&limit=<n> (pro only)
| Parameter | Default | Meaning |
|---|---|---|
after | 0 | Return fills with id greater than this. Must be a non-negative integer (400 bad_cursor otherwise). |
limit | 100 | 1 to 500. A value outside this range is replaced by 100. |
The rows come oldest first (ascending id). Each row:
| Field | Meaning |
|---|---|
id | Fill id. Use it as your cursor. Ids are shared with other paper books, so gaps are normal. |
ts | Time of the paper fill |
kind | buy, sell, settle or correction |
reason | Why (table below) |
position_id | The paper position. Several fills can share it (entry, top-ups, exit). |
market_id, market_slug, question | The market |
condition_id | Polymarket condition id |
outcome_index, outcome_label | The outcome traded |
token_id | The Polymarket CLOB token id to trade |
shares | Shares in this fill (whole shares for buy and sell) |
price | Simulated average fill per share, not an order price (can be off the tick grid). buy: average ask from the book walk plus the simulated slippage. sell: average bid minus slippage. settle and correction: the payout per share (1 = won, 0 = lost, 0.5 = 50/50). |
fee_usd | Modeled taker fee (separate from price) |
cash_delta_usd | Change to the paper book's cash (negative for a buy) |
implied_prob | The model's probability at entry (buy rows only) |
position_shares_now | The position's shares now, not just after this fill |
position_status_now | The position's status now: open, closed or settled |
Kinds and reasons:
kind | reason | What happened | What your bot does |
|---|---|---|---|
buy | entry | A new position | Buy |
buy | topup | More of a held position | Buy |
sell | signal_gone | The smart-money signal for the market went away | Sell |
sell | consensus_flip | The consensus moved to another outcome | Sell |
sell | few_backers | Too few smart wallets still back the outcome | Sell |
sell | exit_signal | Smart wallets are selling the outcome | Sell |
sell | backer_outflow | Large net selling by backers in 24 hours | Sell |
sell | take_profit | The bid reached the model's probability | Sell |
settle | resolved | The market resolved; the paper position was redeemed | No order. Redeem your winning tokens. |
correction | payout revised | A provisional resolution was corrected | No order. Bookkeeping only. |
A sell can be partial when the order book was thin. Then
position_status_now stays open and a later sell can follow.
What the feed does not give you.
priceis the simulated average fill of the paper book: the book-walk average plus slippage for abuy, minus slippage for asell. It is not an order price. It can be off the tick grid.- The row has no tick size, no minimum order size, no neg-risk flag and no
limit price. Before each order, read the fresh CLOB order book and the
market metadata for
token_id(tick size, minimum order size,neg_risk) from Polymarket. Then set your own limit price on the tick grid. position_shares_nowandposition_status_nowshow the position now. They do not tell you how much was held before a given fill. To get the share of a position that asellclosed, replay the ledger byposition_id: add thesharesof eachbuyrow and subtract thesharesof eachsellrow, inidorder. The fraction sold issell.shares / paper shares held just before the sell.settleandcorrectionrows need no CLOB order.
Paging. When a page is full (limit rows), next_cursor is the last
row's id as a string. Call again with after=<next_cursor>. When the page
is short, next_cursor is null and you are up to date. Note that this
cursor is a plain id, not the opaque cursor of the other lists.
{
"data": [
{
"id": 9812,
"ts": "2026-11-08T11:00:42Z",
"kind": "buy",
"reason": "entry",
"position_id": 377,
"market_id": 12345,
"market_slug": "example-market",
"question": "Will X happen by December 31?",
"condition_id": "0x5f...",
"outcome_index": 0,
"outcome_label": "Yes",
"token_id": "71321045679252212594626385532706912750332728571942532289631379312455583992563",
"shares": 412,
"price": 0.347,
"fee_usd": 2.33,
"cash_delta_usd": -145.3,
"implied_prob": 0.41,
"position_shares_now": 412,
"position_status_now": "open"
}
],
"next_cursor": null
}
(Example values are made up. Field names are exact.)
The polling loop
- Store the last
idyou handled, on disk. On the first run, read the whole ledger fromafter=0without trading. Build the paper share count for eachposition_id, and save the last id. Then trade only new rows. Positions that were already open when you started are not yours; ignore their later sells (your own share count for them is 0). - Poll every few minutes, for example every 5 minutes. The paper book runs about once an hour, so faster polling gains little. 12 calls per hour is far below the pro limit of 600 per minute.
- Read all pages: repeat with
after=<next_cursor>while it is notnull. - Handle each row once, in
idorder. Save the id after you handle the row. Then a crash does not repeat or skip a trade.
Mirror at a scale factor
Choose a scale factor, for example 0.05 (5% of the paper size). The paper
book starts with its starting_cash_usd (see /v1/track-record).
Keep two numbers for each position_id: the paper shares (from the ledger
replay) and your own shares (from your real fills).
- Buy:
size = floor(shares x scale). Read the fresh order book fortoken_id. Use a limit order, not a market order. Use the row'spriceonly as a guide: it is a simulated average that already includes slippage. Round your limit to the market's tick size. Skip the trade if the best ask is now abovepriceplus your tolerance. Do not chase. - Sell: compute the fraction sold from the replay
(
sell.shares / paper shares before the sell). Sell that fraction of your own shares for the position (all of them when the paper position is now fully sold). Read the fresh bid side and set a limit on the tick grid near the row'sprice. - Too small: Polymarket has a minimum order size per market and a tick size. Read both from the market metadata; do not assume them. Skip orders under the minimum. Your own share count for the position then stays lower, so later sells scale correctly.
- Neg-risk markets need the neg-risk flag when you sign the order. Read it from Polymarket's market metadata for the token.
- Settle and correction: place no order. When a market resolves, redeem
your winning tokens (in the Polymarket app or with an on-chain redeem
call).
py-clob-clientdoes not redeem.
Your fills will differ from the paper fills. You trade minutes after the paper book, and the book can move. Track your own results and compare them with the paper book.
Risk controls (build these in)
- Kill switch: a file or an environment flag that stops all new orders. Check it before every order.
- Hard limits: maximum dollars per order, per market and in total. A maximum daily loss that stops the bot.
- Price guard: refuse prices outside 0.01 to 0.99, and refuse any limit
far from the row's
price. - Stale guard: skip a
buyrow older than, for example, 2 hours (ts). - Dry run first: log the orders without sending them for a few days.
- Alerts: send yourself a message on each trade and on every error.
- Keys: keep the Polymarket private key out of source code. Use a wallet that holds only the money for this bot.
Optional: earlier exits with the stream
The paper book checks exits only once an hour. If you want to react sooner,
open GET /v1/stream/consensus (pro) as a wake-up signal. On a
consensus event for a market you hold, read
GET /v1/markets/{market_id}/consensus:
latest.exit_signalistrue: smart wallets are selling.latest.outcome_indexis not your outcome: the consensus flipped.404 no_consensus: the signal is gone.
An early exit is your own rule. The paper test did not measure it, so your results can differ from the track record. You can also simply poll the fills feed when an event arrives, instead of on a timer.
Python sketch
This is a sketch, not a working bot. It shows the structure only. The
py-clob-clientcalls are written from memory of its public API. Check them against the library's current documentation. Test with dry runs and tiny sizes first.
# SKETCH ONLY - not tested. Check every py-clob-client call against its docs.
import json, math, os, time, pathlib
import requests
API = "https://tailslip.com/api"
TS_KEY = os.environ["TAILSLIP_API_KEY"] # sm_... (read-only data key)
STATE = pathlib.Path("bot_state.json") # {"last_id": 0, "paper": {pos_id: shares}, "mine": {pos_id: shares}}
KILL = pathlib.Path("STOP") # create this file to stop trading
SCALE = 0.05 # 5% of the paper size
MAX_ORDER_USD = 50
PRICE_TOLERANCE = 0.02
DRY_RUN = True
def load_state():
return json.loads(STATE.read_text()) if STATE.exists() else {"last_id": 0, "paper": {}, "mine": {}}
def save_state(s):
STATE.write_text(json.dumps(s))
def fetch_new_fills(after):
"""Yield fills with id > after, oldest first, across all pages."""
while True:
r = requests.get(f"{API}/v1/paper/follower/fills",
params={"after": after, "limit": 500},
headers={"Authorization": f"Bearer {TS_KEY}"}, timeout=30)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", "60")))
continue
r.raise_for_status()
body = r.json()
yield from body["data"]
if body["next_cursor"] is None:
return
after = int(body["next_cursor"])
# --- Polymarket client (your key, your wallet; TailSlip never sees it) ---
# from py_clob_client.client import ClobClient
# from py_clob_client.clob_types import OrderArgs, OrderType
# from py_clob_client.order_builder.constants import BUY, SELL
# clob = ClobClient("https://clob.polymarket.com", key=os.environ["PK"], chain_id=137,
# signature_type=..., funder=os.environ.get("FUNDER"))
# clob.set_api_creds(clob.create_or_derive_api_creds())
def market_meta(token_id):
"""Fresh tick size, min order size, neg-risk flag and best bid/ask for the token.
The fills feed carries none of these. Read them from Polymarket, e.g.:
# book = clob.get_order_book(token_id) # bids/asks, tick_size, min_order_size, neg_risk (check fields)
"""
raise NotImplementedError
def to_tick(price, tick, side):
# BUY rounds down, SELL rounds up, so the limit is never worse than intended.
n = price / tick
return round((math.floor(n) if side == "BUY" else math.ceil(n)) * tick, 6)
def place_limit(token_id, side, size, guide_price):
if KILL.exists():
raise SystemExit("kill switch on")
m = market_meta(token_id)
best = m["best_ask"] if side == "BUY" else m["best_bid"]
if side == "BUY" and best > guide_price + PRICE_TOLERANCE:
print("skip: ask moved", token_id, best, guide_price)
return 0
if side == "SELL" and best < guide_price - PRICE_TOLERANCE:
print("skip: bid moved", token_id, best, guide_price)
return 0
price = to_tick(guide_price, m["tick_size"], side) # fill price is an average, off-grid
if size < m["min_order_size"] or size * price > MAX_ORDER_USD or not 0.01 <= price <= 0.99:
print("skip", side, token_id, size, price)
return 0
print(("DRY " if DRY_RUN else "") + f"{side} {size} @ {price} token={token_id}")
if DRY_RUN:
return size
# order = clob.create_order(OrderArgs(token_id=token_id, price=price, size=size, side=side),
# options=PartialCreateOrderOptions(tick_size=..., neg_risk=m["neg_risk"]))
# resp = clob.post_order(order, OrderType.GTC) # or FOK/FAK; check fills afterwards
# return <shares actually filled, from resp / clob.get_order(...)>
return 0
def handle(fill, state):
pid = str(fill["position_id"])
tok = fill["token_id"]
paper = state["paper"].get(pid, 0) # paper shares before this fill (ledger replay)
mine = state["mine"].get(pid, 0) # my real shares for this position
if fill["kind"] == "buy":
size = math.floor(fill["shares"] * SCALE)
got = place_limit(tok, "BUY", size, fill["price"])
state["paper"][pid] = paper + fill["shares"]
state["mine"][pid] = mine + got
elif fill["kind"] == "sell":
frac = 1.0 if paper <= 0 else min(1.0, fill["shares"] / paper)
if fill["shares"] >= paper - 1e-9:
frac = 1.0 # paper position fully closed: sell everything
size = math.floor(mine * frac)
got = place_limit(tok, "SELL", size, fill["price"]) if size > 0 else 0
state["paper"][pid] = max(0, paper - fill["shares"])
state["mine"][pid] = mine - got
else:
# settle / correction: no CLOB order. Redeem winning tokens separately.
print("no order:", fill["kind"], fill["reason"], fill["market_slug"])
def bootstrap(state):
"""First run: replay the paper ledger without trading (paper shares only)."""
for fill in fetch_new_fills(0):
pid = str(fill["position_id"])
if fill["kind"] == "buy":
state["paper"][pid] = state["paper"].get(pid, 0) + fill["shares"]
elif fill["kind"] == "sell":
state["paper"][pid] = max(0, state["paper"].get(pid, 0) - fill["shares"])
state["last_id"] = fill["id"]
save_state(state)
def main():
state = load_state()
if not STATE.exists():
bootstrap(state)
while True:
try:
for fill in fetch_new_fills(state["last_id"]):
handle(fill, state)
state["last_id"] = fill["id"]
save_state(state) # save after each row: no repeats after a crash
except requests.RequestException as e:
print("poll failed:", e) # alert yourself here
time.sleep(300) # the paper book runs about hourly
if __name__ == "__main__":
main()
Checklist before real money
- The follower beats
placeboon/v1/track-recordover a long enough period, after fees. - Your dry run ran for days without errors.
- Kill switch, order limits and daily loss limit work.
- Your bot keeps its own record of positions and compares it with Polymarket.
- You are allowed to trade on Polymarket where you live.