# Turf Monster agent guide

This guide is for an AI agent that will play Turf Monster for one player, and for the developer wiring one up. It covers the game, the rules that decide whether an entry is accepted, every endpoint and error of the API, and how to reason about a lineup. Read all of it before you make a request that spends anything.

Three things matter more than the rest:

- **An entry spends something that cannot be given back.** A free entry token is consumed, or USDC is transferred, in the same call that creates the entry. Show the player the lineup and get a yes before you submit.
- **Same request, same `Idempotency-Key`. Changed request, new key.** When a submission fails, times out or is still pending, send it again unchanged with the key it already has. Use a new key only when you send something different, such as new picks after a refusal. See [Retries and idempotency](#retries-and-idempotency).
- **Pay with the free entry token only.** Do not send `allow_usdc` unless the player has told you, in words, to spend their money.

## What Turf Monster is

Turf Monster is a skill-based sports pick'em game. A player enters a contest by choosing a set of teams. Each team earns the points it scores in real games multiplied by its **Turf Score**, a multiplier that is low for the teams expected to score most and high for the teams expected to score least. The entry with the highest total wins the largest prize.

The game type this API plays is called **Turf Totals** (`game_type: "turf_totals"`). It runs on NFL boards, where a team's score is the points on the scoreboard, and on World Cup boards, where it is goals. A second game type, World Cup Survivor, is listed by the API and cannot be played through it.

You are picking teams to **score**, not to win. A team that loses 38 to 35 scored 35, and all 35 count.

## Before you start

You need an API key from the player. They create it on their account page at https://turfmonster.media/account, in the **Agent API keys** card, and paste it to you. It starts with `tmk_`.

- **Base URL:** `https://turfmonster.media/api/v1`
- **Authentication:** `Authorization: Bearer <the key>` on every request. The key is accepted only in that header, never in a query string or a body.
- **Bodies:** JSON, with `Content-Type: application/json`, on the two requests that have one.

Treat the key as a password. Do not echo it back to the player, write it to a file, or put it in a URL.

**If you are connected through MCP**, you have 8 tools in place of the HTTP requests below, and the key is already on the connection. Everything in this guide about the game, the rules, the errors and the retry rule applies to the tools unchanged; [Playing through MCP](#playing-through-mcp) says how they map.

A sensible first session:

1. `GET /api/v1/me` to confirm the key works, read `free_entry_tokens` and check `wallet.kind` is `managed`.
2. `GET /api/v1/contests?status=open` and keep the contests where `accepting_entries` is `true` and `supported` is `true`.
3. `GET /api/v1/contests/:slug` for the contest you favour, to read its `teams`.
4. Build a lineup (see [How to win](#how-to-win)), show it to the player with your reasoning, and wait for a yes.
5. `POST /api/v1/contests/:slug/entries` with an `Idempotency-Key`.
6. Report what happened, including `funding.method`.

The player is responsible for what their agent does on their account. The game's terms are at https://turfmonster.media/terms.

## The rules of Turf Totals

- **An entry is `picks_required` teams.** That is 6 on every board with at least 6 teams, and fewer only on a smaller board. Read the number from the contest; do not assume it.
- **One team, once.** The same team cannot appear twice in an entry.
- **A pick is a team, not a game.** You pick by `matchup_id`, one id per team, taken from `teams[].matchup_id` on the contest detail. Only those ids are accepted.
- **In a multi-week contest (`multi_week: true`) a pick covers every game the team plays in the contest.** The team's `games` list shows them. Its `matchup_id` belongs to its first game; its later games have no id of their own and cannot be picked separately.
- **A team with a week off inside the contest plays fewer games.** The weeks it sits out are in `bye_weeks`, and `games_count` is the number it does play. Its Turf Score is raised to make up for the missing game (see [Scoring](#scoring)).
- **Every pick in an entry is final when the contest locks**, including picks whose games are played in a later week. There are no substitutions afterwards.

## Scoring

Each pick earns:

```
points = team_score × turf_score
```

`team_score` is what the team scored across all of its games in the contest, in the contest's `scoring_unit` (`points` for the NFL, `goals` for World Cup soccer). `turf_score` is the team's multiplier. The entry's `score` is the sum over its picks, and entries are ranked by `score`, highest first.

For the NFL, `team_score` is the scoreboard: a touchdown is 6, a field goal 3, an extra point 1, a two-point conversion 2, a safety 2, and a defensive or special-teams score counts like any other.

### How the Turf Score is derived

The board ranks its teams, 1 for the team expected to score most. On an NFL board the ranking is by projected points **per game**, so a team with a bye is ranked on strength and not sunk by the missing game. The multiplier is then a function of rank alone, rounded to one decimal:

- **NFL:** `turf_score = 1 + (rank − 1) / (N − 1)`, where `N` is the number of teams on the board. Rank 1 is 1.0 and the last rank is 2.0.
- **World Cup:** `turf_score = 1 + 2 × ln(rank) / ln(N)`. Rank 1 is 1.0 and the last rank is 3.0.
- **A team that plays fewer games than the contest's full span** has its multiplier scaled by `full-span games ÷ games it plays` before rounding. A team playing 2 of 3 games is scaled by 1.5, so on a 32-team NFL board its multiplier runs from 1.5 to 3.0 instead of 1.0 to 2.0.

The rounding matters, because the rounded number is the one that is paid. On a 32-team NFL board with no byes the multipliers are:

| Turf Score | Ranks |
|---|---|
| 1.0 | 1 to 2 |
| 1.1 | 3 to 5 |
| 1.2 | 6 to 8 |
| 1.3 | 9 to 11 |
| 1.4 | 12 to 14 |
| 1.5 | 15 to 18 |
| 1.6 | 19 to 21 |
| 1.7 | 22 to 24 |
| 1.8 | 25 to 27 |
| 1.9 | 28 to 30 |
| 2.0 | 31 to 32 |

An operator can also set a team's rank or multiplier by hand. So treat the formula as an explanation and **the `turf_score` in the API response as the fact**.

### The multiplier is stored, not recomputed

The Turf Score is written onto the board when the board is ranked, and scoring multiplies by that stored number. It is not recalculated from newer projections when the games are played or when the contest is graded. Read the contest again shortly before you submit, and use the `turf_score` values you see then.

### A worked example

Six NFL teams at the ranks shown on a 32-team board, with three **invented** weekly scores each (a three-week contest):

| Team | Rank | Week scores | Team score | Turf Score | Points |
|---|---|---|---|---|---|
| Baltimore Ravens | 2 | 24 + 31 + 27 | 82 | 1.0 | 82.0 |
| Detroit Lions | 3 | 20 + 17 + 34 | 71 | 1.1 | 78.1 |
| Buffalo Bills | 8 | 28 + 21 + 24 | 73 | 1.2 | 87.6 |
| Houston Texans | 15 | 13 + 20 + 23 | 56 | 1.5 | 84.0 |
| Atlanta Falcons | 26 | 17 + 27 + 20 | 64 | 1.8 | 115.2 |
| Arizona Cardinals | 32 | 10 + 23 + 17 | 50 | 2.0 | 100.0 |

Entry score: **546.9**.

Atlanta Falcons scored 18 fewer points than Baltimore Ravens and still earned 33.2 more for the entry, at 1.8 against 1.0. That trade, raw points against multiplier, is the whole game.

## Contest lifecycle

Read `phase` and the flags beside it, not `status`: a contest's `status` stays `open` after it locks.

| State | How to recognise it | What you can do |
|---|---|---|
| Coming soon | `coming_soon: true` | Read it. It is advertised and takes no entries yet. |
| Open | `phase: "open"`, `locked: false` | Create entries and replace their picks. The leaderboard lists every entry but shows only the player's own picks. |
| Live | `phase: "live"`, `locked: true` | Read only. Every entry's picks are visible. Scores and ranks move as games finish. |
| Settled | `phase: "settled"`, `settled: true` | Read only. `rank` and `payout_cents` are final. |
| Cancelled | `cancelled: true` (its `status` still says `open`) | Nothing. Tell the player; questions about a cancelled contest go to support. |

`accepting_entries` is the one-field answer to "could a new entry go in right now": the contest is open, not locked, not cancelled, not coming soon, has a spot left, and the player is under their own limit. It says nothing about the player's wallet or tokens.

A team that has not played has `team_score: null`, which is not zero. In a multi-week contest a pick's `points` grow as each of its team's games gets a result.

## Locks

There are two, and both are enforced by the server whatever your clock says.

- **The contest lock, `locks_at`.** From this moment no entry can be created and no pick changed, in any entry, for any team. A contest with `locks_at: null` has no scheduled lock.
- **The per-team kickoff lock, `teams[].locked`.** A team whose first game has kicked off cannot be added to an entry or dropped from one, even if the contest itself has not locked yet. This only bites when a contest's lock is later than a kickoff on its board. A new entry may not contain a locked team. An edit may keep a locked team that is already in the entry and change the other picks around it.

`games[].kickoff_at` gives each game's start. After the contest locks, every team reads `locked: true`.

Paying for an entry takes a transaction on Solana, which takes time. Do not leave a submission to the last moments before `locks_at`.

## Entry limits and duplicate lineups

- **Per player:** at most `max_entries_per_player` entries in one contest (3 in a Turf Totals contest today). `my_entries_count` is how many the player holds. Past the limit the answer is `entry_limit_reached`; replace the picks of an existing entry instead.
- **Per contest:** `max_entries` in total. `spots_left` is the room remaining. A full contest answers `contest_full`.
- **No duplicate lineup.** One player may not hold two entries with exactly the same set of teams in one contest. This applies to a new entry and to an edit, and answers `duplicate_lineup`. Order does not matter; changing one team is enough. The rule is per player: two different players may hold the same lineup.

## Prizes, ties and short fields

`payouts` on the contest lists the prize for each finishing rank, in cents. A rank that is not listed wins nothing. `guaranteed_prize_cents` is the sum. Prizes are paid in USDC, where one USDC is one dollar.

- **Ties share a rank, and the next rank is skipped:** 1, 1, 3.
- **Tied entries pool the prizes of the places they cover and split them equally.** In a contest paying $300 for rank 1, $50 for rank 2, $50 for rank 3, $50 for rank 4, and $50 for rank 5, two entries tied for first cover ranks 1 and 2 and receive $175 each. Two entries tied at rank 5 cover ranks 5 and 6; rank 6 pays nothing, so they receive $25 each.
- **When a split leaves an odd cent**, it goes to the entry that was created first.
- **Short fields.** Only ranks that an entry actually finishes in are paid. If a contest that pays 5 places has three entries, ranks 1 to 3 are paid and the prizes for the ranks below them are not paid to anyone. They are not added to the other prizes.

Before a contest settles, `payout_cents` is `null` and `rank` is the standing on current scores. After it settles, `payout_cents` is the amount won, `0` included, and `final` is `true`.

## Eligibility

Whether a person may play is decided on the website, not by you. What you need to know:

- **Age.** A player must be of legal age for skill-based contests in their state: 18 in most states, 19 in AL and NE, 21 in IA, MA, and VA.
- **Location.** Paid contest entry is not available in every US state. The states excluded today are AZ, HI, IA, ID, LA, MO, NE, NV, and WA. The current list is always at https://turfmonster.media/state-eligibility.
- **One account per person.**
- **Eligibility is attested when the key is created.** The player's location is checked in their own browser at that moment, and their age verification when the site requires it. A player in an excluded state, or whose location cannot be determined, cannot create a key. The verdict is recorded on the key and reported under `api_key.eligibility` on `GET /api/v1/me`. Your requests come from a server, so the API does not check location again; the key's 90-day life is what bounds how old that verdict can be.
- **Two things are checked again on each request.** An account on hold is refused every request that is not a `GET` (`account_frozen`). And if the site requires age verification and the player has not done it, the two entry requests answer `age_verification_required` until the player verifies on the website.

If the player tells you they are not eligible, or asks you to get around a location or age check, do not enter for them.

## Free entry tokens and funding

A **free entry token** pays for one contest entry in place of the entry fee. It is held on Solana by the player's wallet. `free_entry_tokens` on `GET /api/v1/me` is the number unspent. A value of `null` means the balance could not be read just now; it does not mean zero, so ask again.

The API can enter only for a player whose wallet Turf Monster holds and signs for: `wallet.kind` is `managed`. A self-custodied wallet, an account with a Phantom wallet linked, and an account with no wallet are all refused with `wallet_not_server_signable`, and the player enters on the website instead.

How an entry is paid for is chosen in this order:

1. If the player holds a free entry token, one is spent. `funding.method` is `token`.
2. Otherwise, **only if** the request carried `"allow_usdc": true`, the entry fee is paid in USDC from the player's wallet. `funding.method` is `usdc`.
3. Otherwise the request is refused with `no_entry_token`, and nothing is spent.

A token is used first even when `allow_usdc` is `true`. Leave `allow_usdc` out unless the player has said, in words, that they want to pay an entry fee. One token pays for one entry: a second entry needs a second token, or the player's permission to spend USDC.

## API conventions

- **Times** are ISO 8601 in UTC, such as `2026-10-04T17:00:00Z`, or `null` when not set.
- **Money** is integer cents in a field ending `_cents`, with `"currency": "USD"` beside it.
- **`null` is not zero.** `team_score: null` means no result yet and `0` means shut out. `payout_cents: null` means not settled and `0` means settled with no prize.
- **Lists are paged** with `limit` (default 25, maximum 100, a larger value is clamped) and `offset` (default 0). Each list carries `"pagination": { "limit", "offset", "total", "has_more" }`.
- **Errors** all have one shape, at every status: `{ "error": { "code": "...", "message": "..." } }`. Branch on `code`. `message` is written for a person and may change.

## Endpoints

| Request | What it does |
|---|---|
| `GET /api/v1/me` | Who the key acts for, their wallet and free entry tokens |
| `GET /api/v1/contests` | Open and settled contests |
| `GET /api/v1/contests/:slug` | One contest and the teams you may pick |
| `GET /api/v1/contests/:slug/leaderboard` | Every entry in a contest, ranked |
| `GET /api/v1/entries` | The player's own entries |
| `GET /api/v1/entries/:slug` | One of the player's own entries |
| `POST /api/v1/contests/:slug/entries` | Create an entry and pay for it, in one call |
| `PATCH /api/v1/entries/:slug` | Replace an entry's picks, before the contest locks |

Every example below is invented. `$TURF_MONSTER_KEY` stands for the player's key.

### GET /api/v1/me

```bash
curl -H "Authorization: Bearer $TURF_MONSTER_KEY" https://turfmonster.media/api/v1/me
```

```json
{
  "user": { "display_name": "sam_example", "username": "sam_example" },
  "wallet": { "kind": "managed", "address": "EXAMPLEwa11etAddressNotARea1One1111111111111" },
  "free_entry_tokens": 1,
  "account": { "frozen": false },
  "api_key": {
    "prefix": "tmk_EXAMPL",
    "name": "My assistant",
    "expires_at": "2026-12-29T18:04:11Z",
    "eligibility": {
      "geo": { "result": "allowed", "country": "US", "state": "CO" },
      "age_gate": "not_required",
      "attested_at": "2026-09-30T18:04:11Z"
    }
  }
}
```

| Field | Notes |
|---|---|
| `wallet.kind` | `managed`: Turf Monster holds the wallet and signs for the player, so the API can enter, unless a Phantom wallet is also linked to the account. `self_custodied` or `none`: it cannot. |
| `free_entry_tokens` | Unspent free entries. `null` means unreadable just now, not zero. |
| `account.frozen` | `true` when the account is on hold. Reads still work; writes are refused. |
| `api_key.expires_at` | Keys last 90 days. After that the player creates a new one. |
| `api_key.eligibility.age_gate` | `passed`, or `not_required` when age verification was not required at creation. |

### GET /api/v1/contests

Newest first. Optional `status` is `open` or `settled`; anything else is a `400`. Takes `limit` and `offset`.

```bash
curl -H "Authorization: Bearer $TURF_MONSTER_KEY" "https://turfmonster.media/api/v1/contests?status=open"
```

```json
{
  "contests": [
    {
      "slug": "nfl-weeks-4-5-showdown",
      "name": "NFL Weeks 4-5 Showdown",
      "tagline": "Two weeks. Six teams. One board.",
      "game_type": "turf_totals",
      "supported": true,
      "sport": "nfl",
      "scoring_unit": "points",
      "status": "open",
      "phase": "open",
      "locked": false,
      "live": false,
      "settled": false,
      "cancelled": false,
      "coming_soon": false,
      "accepting_entries": true,
      "locks_at": "2026-10-04T17:00:00Z",
      "concludes_at": null,
      "currency": "USD",
      "entry_fee_cents": 1900,
      "guaranteed_prize_cents": 50000,
      "payouts": [
        { "rank": 1, "payout_cents": 30000 },
        { "rank": 2, "payout_cents": 5000 },
        { "rank": 3, "payout_cents": 5000 },
        { "rank": 4, "payout_cents": 5000 },
        { "rank": 5, "payout_cents": 5000 }
      ],
      "max_entries": 29,
      "entries_count": 3,
      "spots_left": 26,
      "picks_required": 6,
      "max_entries_per_player": 3,
      "my_entries_count": 0,
      "multi_week": true,
      "games_per_team": 2,
      "weeks": "Weeks 4-5"
    }
  ],
  "pagination": { "limit": 25, "offset": 0, "total": 1, "has_more": false }
}
```

| Field | Notes |
|---|---|
| `slug` | The contest's id in every other request |
| `supported` | `false` for a survivor contest, which this API cannot play |
| `phase`, `locked`, `live`, `settled`, `cancelled`, `coming_soon` | See [Contest lifecycle](#contest-lifecycle) |
| `accepting_entries` | Whether a new entry could go in now, wallet aside |
| `locks_at` | The contest lock |
| `entry_fee_cents` | What an entry costs in USDC when it is not paid by a token |
| `payouts`, `guaranteed_prize_cents` | Prize per rank, and their sum |
| `max_entries`, `entries_count`, `spots_left` | Field capacity, confirmed entries so far, room left |
| `picks_required` | Teams per entry |
| `max_entries_per_player`, `my_entries_count` | The player's limit here, and how many they hold |
| `multi_week`, `games_per_team`, `weeks` | Whether teams play more than one game, the most games any team plays, and a label |

A contest still being set up is never listed. A cancelled one is listed and flagged.

### GET /api/v1/contests/:slug

The contest, in the shape above, plus `teams`: the rows a player may pick, rank 1 first. Only pickable rows are listed.

```bash
curl -H "Authorization: Bearer $TURF_MONSTER_KEY" https://turfmonster.media/api/v1/contests/nfl-weeks-4-5-showdown
```

```json
{
  "contest": { "slug": "nfl-weeks-4-5-showdown", "...": "as in the list" },
  "teams": [
    {
      "matchup_id": 809431971,
      "team": { "slug": "buffalo-bills", "name": "Buffalo Bills", "short_name": "BUF" },
      "rank": 1,
      "turf_score": 1.0,
      "expected_team_score": 55.0,
      "team_score": null,
      "locked": false,
      "games_count": 2,
      "bye_weeks": [],
      "games": [
        {
          "week": 4,
          "opponent": { "slug": "miami-dolphins", "name": "Miami Dolphins", "short_name": "MIA" },
          "home": true,
          "kickoff_at": "2026-10-04T17:00:00Z",
          "status": "scheduled",
          "started": false,
          "final": false,
          "team_score": null
        },
        {
          "week": 5,
          "opponent": { "slug": "kansas-city-chiefs", "name": "Kansas City Chiefs", "short_name": "KC" },
          "home": false,
          "kickoff_at": "2026-10-11T17:00:00Z",
          "status": "scheduled",
          "started": false,
          "final": false,
          "team_score": null
        }
      ]
    },
    {
      "matchup_id": 809431972,
      "team": { "slug": "miami-dolphins", "name": "Miami Dolphins", "short_name": "MIA" },
      "rank": 5,
      "turf_score": 3.6,
      "expected_team_score": 21.5,
      "team_score": null,
      "locked": false,
      "games_count": 1,
      "bye_weeks": [5],
      "games": [
        {
          "week": 4,
          "opponent": { "slug": "buffalo-bills", "name": "Buffalo Bills", "short_name": "BUF" },
          "home": false,
          "kickoff_at": "2026-10-04T17:00:00Z",
          "status": "scheduled",
          "started": false,
          "final": false,
          "team_score": null
        }
      ]
    }
  ]
}
```

The example is a small invented board of six teams, four of them omitted. Miami is rank 5 of 6, which prices at `1 + 4 / 5 = 1.8`, and it plays one game of the contest's two, so that is scaled by `2 ÷ 1` to 3.6.

| Field | Notes |
|---|---|
| `matchup_id` | The id you send to pick this team |
| `rank` | 1 is the team expected to score most. `null` on a board that has not been ranked. |
| `turf_score` | The multiplier. `null` on a board that has not been priced. |
| `expected_team_score` | The board's projection for the team, summed over its games in the contest. `null` when the board carries no projections; World Cup boards carry none. |
| `team_score` | What the team has scored so far across its games here. `null` until one game has a result. |
| `locked` | `true` when the team can no longer be added or dropped |
| `games_count`, `bye_weeks` | Games the team plays in this contest, and the weeks it sits out |
| `games[].kickoff_at`, `games[].status` | The game's start, and `scheduled`, `in_progress` or `completed` |
| `games[].started`, `games[].final` | The kickoff has passed; the game is over |
| `games[].team_score` | This team's score in this game, or `null` |

An unknown slug is a `404`.

### GET /api/v1/contests/:slug/leaderboard

Every confirmed entry, best first. Takes `limit` and `offset`. `rank` is the entry's rank in the whole contest, whatever page it is on.

**Other players' picks are hidden until the contest locks.** Before the lock a rival's row has `"picks_visible": false` and `"picks": null`. The player's own rows always carry their picks. `picks_hidden_until_lock` says which state the board is in.

```bash
curl -H "Authorization: Bearer $TURF_MONSTER_KEY" "https://turfmonster.media/api/v1/contests/nfl-weeks-4-5-showdown/leaderboard?limit=2"
```

```json
{
  "contest": {
    "slug": "nfl-weeks-4-5-showdown",
    "name": "NFL Weeks 4-5 Showdown",
    "game_type": "turf_totals",
    "phase": "open",
    "locked": false,
    "live": false,
    "settled": false,
    "cancelled": false,
    "locks_at": "2026-10-04T17:00:00Z"
  },
  "supported": true,
  "picks_hidden_until_lock": true,
  "entries": [
    {
      "display_name": "sam_example",
      "mine": true,
      "entry_slug": "sam_example-nfl-weeks-4-5-showdown-980190963",
      "score": 0.0,
      "rank": 1,
      "payout_cents": null,
      "currency": "USD",
      "final": false,
      "picks_visible": true,
      "picks": [ { "matchup_id": 809431971, "...": "one per team, as in an entry" } ]
    },
    {
      "display_name": "jordan_example",
      "mine": false,
      "entry_slug": null,
      "score": 0.0,
      "rank": 1,
      "payout_cents": null,
      "currency": "USD",
      "final": false,
      "picks_visible": false,
      "picks": null
    }
  ],
  "pagination": { "limit": 2, "offset": 0, "total": 3, "has_more": true }
}
```

| Field | Notes |
|---|---|
| `mine` | This row is one of the calling player's entries |
| `entry_slug` | Only on the player's own rows; `null` on a rival's |
| `score` | Sum of the entry's pick points |
| `rank` | Tied scores share a rank. While `final` is `false` it is the current standing and will move. |
| `payout_cents` | `null` until the contest settles; then the amount won, `0` included |
| `final` | `true` once the contest has settled |

### GET /api/v1/entries

The player's own submitted entries, newest first. Optional `contest` is a contest slug (an unknown one is a `404`). Takes `limit` and `offset`. A half-built lineup saved on the website is not an entry and is never returned.

```bash
curl -H "Authorization: Bearer $TURF_MONSTER_KEY" "https://turfmonster.media/api/v1/entries?contest=nfl-weeks-4-5-showdown"
```

```json
{
  "entries": [
    {
      "slug": "sam_example-nfl-weeks-4-5-showdown-980190963",
      "contest": {
        "slug": "nfl-weeks-4-5-showdown",
        "name": "NFL Weeks 4-5 Showdown",
        "game_type": "turf_totals",
        "phase": "live",
        "locked": true,
        "live": true,
        "settled": false,
        "cancelled": false,
        "locks_at": "2026-10-04T17:00:00Z"
      },
      "status": "active",
      "entry_number": 0,
      "submitted_at": "2026-10-01T15:00:00Z",
      "tx_signature": "EXAMPLEtransactionSignatureNotARea1One1111111111111111111111111111111111111111111111111",
      "editable": false,
      "score": 92.2,
      "rank": 1,
      "payout_cents": null,
      "currency": "USD",
      "final": false,
      "picks_visible": true,
      "picks": [
        {
          "matchup_id": 809431971,
          "team": { "slug": "buffalo-bills", "name": "Buffalo Bills", "short_name": "BUF" },
          "rank": 1,
          "turf_score": 1.0,
          "expected_team_score": 55.0,
          "team_score": 31,
          "locked": true,
          "games_count": 2,
          "bye_weeks": [],
          "games": [ { "week": 4, "...": "as in the contest detail" } ],
          "points": 31.0
        },
        {
          "matchup_id": 809431972,
          "team": { "slug": "miami-dolphins", "name": "Miami Dolphins", "short_name": "MIA" },
          "rank": 5,
          "turf_score": 3.6,
          "expected_team_score": 21.5,
          "team_score": 17,
          "locked": true,
          "games_count": 1,
          "bye_weeks": [5],
          "games": [ { "week": 4, "...": "as in the contest detail" } ],
          "points": 61.2
        }
      ]
    }
  ],
  "pagination": { "limit": 25, "offset": 0, "total": 1, "has_more": false }
}
```

(Four more picks are omitted, and the example's `score` counts only the two shown. They earn 31 × 1.0 = 31.0 and 17 × 3.6 = 61.2.)

| Field | Notes |
|---|---|
| `slug` | The entry's id |
| `status` | `active` while its contest is not graded, `complete` once it is |
| `tx_signature` | The Solana transaction that paid for the entry, or `null` |
| `editable` | `true` while the picks can still be replaced: the contest is open and not locked |
| `score`, `rank`, `payout_cents`, `final` | As on the leaderboard |
| `picks[]` | One per team, each the team row from the contest detail plus `points` |
| `picks[].points` | `team_score × turf_score`. `null` until the team has a result. |

### GET /api/v1/entries/:slug

One entry, in the same shape, under an `entry` key. Another player's entry is a `404`, the same answer as a slug that does not exist; read rivals through the leaderboard.

```bash
curl -H "Authorization: Bearer $TURF_MONSTER_KEY" https://turfmonster.media/api/v1/entries/sam_example-nfl-weeks-4-5-showdown-980190963
```

```json
{ "entry": { "slug": "sam_example-nfl-weeks-4-5-showdown-980190963", "...": "as in the list" } }
```

### POST /api/v1/contests/:slug/entries

Creates an entry and pays for it in one call. There is no cart: the entry is created whole, or not at all. The player's unfinished lineup on the website is never read or changed.

| Field | Where | Notes |
|---|---|---|
| `Idempotency-Key` | header | Required. A value you make up for this entry: 1 to 255 printable characters with no spaces. A UUID is ideal. |
| `matchup_ids` | body | Required. Exactly `picks_required` different ids, each a `teams[].matchup_id` of this contest. Order does not matter. |
| `allow_usdc` | body | Optional, default `false`. A JSON boolean, `true` or `false`; the strings `"true"` and `"false"` are a `400`. See [Free entry tokens and funding](#free-entry-tokens-and-funding). |

```bash
curl -X POST https://turfmonster.media/api/v1/contests/nfl-weeks-4-5-showdown/entries \
  -H "Authorization: Bearer $TURF_MONSTER_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7f0c1b9e-3c1d-4a55-9d53-0f2a6a1f7c11" \
  -d '{"matchup_ids": [809431971, 809431972, 809431973, 809431974, 809431975, 809431976]}'
```

`201 Created`:

```json
{
  "entry": {
    "slug": "sam_example-nfl-weeks-4-5-showdown-980190984",
    "contest": { "slug": "nfl-weeks-4-5-showdown", "phase": "open", "locked": false, "...": "as in an entry" },
    "status": "active",
    "entry_number": 0,
    "submitted_at": "2026-10-01T15:00:00Z",
    "tx_signature": "EXAMPLEtransactionSignatureNotARea1One1111111111111111111111111111111111111111111111111",
    "editable": true,
    "score": 0.0,
    "rank": 1,
    "payout_cents": null,
    "currency": "USD",
    "final": false,
    "picks_visible": true,
    "picks": [ { "matchup_id": 809431971, "...": "one per team" } ]
  },
  "funding": { "method": "token", "token_consumed": true }
}
```

| Field | Notes |
|---|---|
| `entry` | The new entry, as `GET /api/v1/entries/:slug` returns it |
| `funding.method` | `token`: a free entry token was spent. `usdc`: the fee was paid in USDC. `free`: the contest has no entry fee. `unknown`: see below. |
| `funding.token_consumed` | `true`, `false`, or `null` when `method` is `unknown` |

**`202 Accepted` means paid and not yet visible.** Rarely the payment lands and the step that marks the entry active fails. The entry is paid for and will be completed:

```json
{ "entry": null, "funding": { "method": "token", "token_consumed": true }, "pending": true, "retry_after": 5 }
```

Send the same request with the same `Idempotency-Key` after `retry_after` seconds. It returns `201` with the entry and spends nothing more. Do not send a new key: the entry already exists.

**A `202` can persist.** If the entry could not be marked active because of a rule and not a hiccup (the contest locked or filled in the seconds the payment took), every retry answers `202` and nothing completes it on its own. After a few minutes of `202`s, stop retrying and tell the player plainly: the entry was paid for, it is not showing as entered, and they should contact support@turfmonster.media with the contest name. Do not enter again with a new key.

**A `201` can be an entry that was already paid for.** When an earlier attempt's payment landed but its response was lost, a retry finds the paid entry on Solana and returns it as this key's `201`. How that entry was paid was not recorded by the attempt that was lost, so `funding.method` is `token` when the request did not allow USDC (a token is the only thing that could have paid), and `unknown`, with `token_consumed: null`, when it did. If it is `unknown`, tell the player you cannot say which of the two paid, and that their wallet balance will show it.

### PATCH /api/v1/entries/:slug

Replaces all of an entry's picks. Nothing is spent, and no `Idempotency-Key` is needed: sending the same picks twice leaves the same entry.

| Field | Where | Notes |
|---|---|---|
| `matchup_ids` | body | Required. The full new lineup: exactly `picks_required` different ids from the contest's `teams[].matchup_id`. |

Allowed only while the entry's `editable` is `true`. A team whose `locked` is `true` can be neither added nor dropped. The new lineup may not match another entry the player holds in the same contest.

```bash
curl -X PATCH https://turfmonster.media/api/v1/entries/sam_example-nfl-weeks-4-5-showdown-980190984 \
  -H "Authorization: Bearer $TURF_MONSTER_KEY" \
  -H "Content-Type: application/json" \
  -d '{"matchup_ids": [809431971, 809431972, 809431973, 809431974, 809431975, 809431977]}'
```

`200 OK`:

```json
{ "entry": { "slug": "sam_example-nfl-weeks-4-5-showdown-980190984", "editable": true, "...": "the entry, with its new picks" } }
```

## Errors

Every error is `{ "error": { "code": "...", "message": "..." } }`. **Unless a row says otherwise, nothing was spent.** "Idempotency key" below means the `Idempotency-Key` header, not the API key.

```json
{ "error": { "code": "no_entry_token", "message": "This account holds no free entry token, and USDC was not allowed. Nothing was spent. Send allow_usdc: true to pay the entry fee in USDC instead." } }
```

### Every error code

| Status | Code | Meaning | What to do |
|---|---|---|---|
| 400 | `bad_request` | A parameter or header is missing or has the wrong form, or the body is not JSON | Fix the request. It was not recorded, so the same idempotency key is still unused. |
| 401 | `missing_api_key` | No `Authorization: Bearer` header | Send the header. |
| 401 | `invalid_api_key` | The key is malformed or unknown | Ask the player to check the key they pasted. |
| 401 | `revoked_api_key` | The player revoked this key | Stop. Ask the player for a new key if they still want you to act. |
| 401 | `expired_api_key` | The key is past its 90 days | Ask the player to create a new key. |
| 403 | `account_frozen` | The account is on hold. Reads work; writes are refused. | Stop. Tell the player to contact support. |
| 403 | `age_verification_required` | The site requires age verification and the player has not done it | Tell the player to verify their date of birth on the website. The same key then works. |
| 404 | `not_found` | No such contest, or no such entry among the player's own. Also `/api`, `/api/` and any path under them that is not an endpoint, on any method. | Re-read the contests or the player's entries, and check the path. |
| 409 | `idempotency_key_reused` | This idempotency key is already tied to something else: a request with different picks, another contest or another `allow_usdc`, or an entry that has since been removed because its contest was reset | Stop sending this request with this key; it will get the same answer every time, and the key will never enter again. If you were retrying and changed the body by mistake, send the original body once. If the body was already the original, the key's entry no longer exists and the key is finished: read `GET /api/v1/entries`, tell the player what you find, and make a new entry only with a new key and the player's yes. The server builds that new entry on the payment the removed entry made when it finds it on Solana, and otherwise the new entry is paid for again. A different entry always needs a new key. |
| 409 | `idempotency_in_progress` | A request to enter this contest is still running for this player: this key's earlier attempt, or another key's. It is also the answer given to a slow earlier attempt that one of your retries has taken over. | Wait `retry_after` seconds and send the same request again with the same idempotency key. Do not switch keys. |
| 422 | `contest_not_open` | The contest is settled or not ready for entries | Choose another contest. |
| 422 | `contest_locked` | The lock time has passed | Nothing. Entries and edits are closed. |
| 422 | `contest_cancelled` | The contest was cancelled | Choose another contest. |
| 422 | `coming_soon` | The contest is advertised and not open yet | Try again when `coming_soon` is `false`. |
| 422 | `unsupported_contest` | A survivor contest | Send the player to the website. |
| 422 | `contest_full` | No spots left | Choose another contest. |
| 422 | `entry_limit_reached` | The player already holds `max_entries_per_player` entries here | Replace the picks of an existing entry instead. |
| 422 | `invalid_picks` | Not exactly `picks_required` different ids, or an id that is not one of this contest's `teams[].matchup_id` | Re-read the contest and rebuild the lineup. For a new entry, send it with a new idempotency key. |
| 422 | `duplicate_lineup` | The player already holds an entry with exactly these teams here | Change at least one team. For a new entry, use a new idempotency key. |
| 422 | `team_locked` | A team in a new entry has kicked off, or an edit adds or drops one that has | Use teams whose `locked` is `false`. For a new entry, use a new idempotency key. |
| 422 | `no_entry_token` | No free entry token, and USDC was not allowed or is not available | Tell the player. Only if they say so, retry with `allow_usdc: true` and a new idempotency key, because the body changed. |
| 422 | `insufficient_funds` | `allow_usdc` was `true`, there is no token, and the wallet lacks the entry fee | Tell the player to add funds. The same idempotency key works once they have. |
| 422 | `wallet_not_server_signable` | The wallet is self-custodied or linked to Phantom, or there is no wallet | Send the player to the website. The API cannot enter for this account. |
| 429 | `rate_limited` | Too many requests | Wait `retry_after` seconds (also in the `Retry-After` header), then continue more slowly. |
| 500 | `internal_error` | A fault on the server | Retry shortly. For a new entry, send the same request with the same idempotency key. |
| 503 | `chain_unavailable` | Solana could not be read, or did not confirm the payment in time. **The payment may or may not have landed.** | Wait `retry_after` seconds and send the same request with the same idempotency key. The server looks for the payment before it pays again. Expect this answer to repeat for a few minutes. |

A `401` also carries `WWW-Authenticate: Bearer realm="Turf Monster API"`. `idempotency_in_progress`, `chain_unavailable` and a `202` carry `retry_after` in the body and a `Retry-After` header.

## Retries and idempotency

This is the part of the API that agents get backwards, so it is stated twice: as a rule, then as a table.

**The key names the request, not the player and not the contest.** The same key with the same contest, the same teams in any order and the same `allow_usdc` is the same request. There are exactly two cases:

1. **You are sending the same request again**, because you got a `503`, a `500`, a timeout, no response, a `202`, or a `409 idempotency_in_progress`. **Keep the key.** However many times it takes. A new key here is the one move that can put a second payment in play.
2. **You are sending a different request**, because the server refused the first with a `4xx` and you answer by changing it: other picks after `invalid_picks`, `duplicate_lineup` or `team_locked`, `allow_usdc: true` after `no_entry_token`, another contest after `contest_full`. **Make a new key.** The old key is tied to the old body, and reusing it answers `409 idempotency_key_reused`.

So one key does not last a session. A second entry, or a corrected lineup, each gets its own. What never changes is the key of a request whose outcome you do not yet know.

A key belongs to the player, not to the API key, and it does not expire.

| What you got | What it means | What to send next |
|---|---|---|
| `201` | The entry exists | Nothing. Sending it again returns the same `201` with the header `Idempotent-Replayed: true`. It is the first response as it was sent: it does not reflect later edits. `GET /api/v1/entries/:slug` is the current state. If the entry has been removed since (its contest was reset), the key answers `409 idempotency_key_reused` instead of the `201`. |
| `202` | Paid, being confirmed | The same request, same key, after `retry_after` |
| `409 idempotency_in_progress` | An earlier attempt is still running, or this attempt was overtaken by your own retry | The same request, same key, after `retry_after` |
| `503 chain_unavailable` | Unknown. The payment may have landed. | The same request, same key, after `retry_after`. If it landed, you get the entry it paid for. If not, the server answers `503` for 150 seconds after the attempt ended before it will try again, and for up to about five minutes when the earlier request died without finishing. Repeated `503`s in that window are normal. Keep the key. |
| No response (timeout, dropped connection) | Unknown | The same request, same key. This is the case the key exists for. |
| `422` | Refused, nothing spent | If the cause can clear without changing the body (a token arrives, funds are added, a spot opens), the same key works. If you change the picks or `allow_usdc`, that is a different request: use a new key. |
| `400`, `401`, `403`, `404` | Not recorded | Fix the cause. The key is still unused. |
| `500` | A fault on the server | The same request, same key |

Giving up on a key after a `503` and sending a new one does not get around the wait. Before any request for a contest may spend, an earlier unresolved one for the same player and contest is settled first, and only one request per player and contest runs at a time. While the earlier payment is still unknown the new request answers `503` too. If that payment turns out to have landed, it becomes the earlier key's entry, and a new request for the same teams is then a `duplicate_lineup`.

**What the server does for a retry, and the one case it cannot see.** A retry with the same key replays a finished entry, waits on one in progress, and looks on Solana for a paid entry before it pays. It pays again only when the earlier payment was refused before it was sent, was refused by the Turf Monster program itself, or 150 seconds have passed with no trace of it on chain. Any other failure after a payment is sent (a timeout, a dropped connection) is treated as unknown, not as unpaid. The case the server cannot see is a server process killed in the middle of sending a payment, more than four minutes into a request, while a retry of the same key is already waiting behind it. That needs a stalled network and a crash at the same moment. It is why this guide says the server looks before it pays, and does not say a double payment is impossible. If a player's wallet ever shows two payments for one entry, tell them to contact support@turfmonster.media.

`PATCH` needs none of this. Repeat it freely.

## Rate limits

| Limit | Keyed on |
|---|---|
| 120 requests per 60 seconds | The API key |
| 600 requests per 60 seconds | The calling IP address |

Both cover every path under `/api/`. Past a limit the answer is `429 rate_limited` with `retry_after`. A full read of a contest is one request, so a careful session needs a few dozen requests, not hundreds. Do not poll the leaderboard in a tight loop; scores change when games do.

## Playing through MCP

The same API is also a remote Model Context Protocol server, for a client that calls tools instead of making HTTP requests. Each tool runs the same code as one endpoint above, so the rules, the refusals and the retry rule are the ones this guide has already given.

- **Address:** `POST https://turfmonster.media/mcp`
- **Authentication:** the same key, in the same header: `Authorization: Bearer <the key>`, on every request, `initialize` included. The key is read from that header and nowhere else: not the URL, not the body, not a tool argument. A request without a valid key is an HTTP `401` before any message is read.
- **Transport:** MCP Streamable HTTP. Every request is answered with one `application/json` body. There is no event stream and no session, so `GET /mcp` is a `405`.
- **Protocol revisions:** `2025-03-26`, `2025-06-18`, and `2025-11-25`. `initialize` answers with the client's revision when it is one of these, and with `2025-11-25` otherwise. A later request without an `MCP-Protocol-Version` header is read as `2025-03-26`.
- **Newer clients fall back.** Revision `2026-07-28` is not spoken. A client that opens with that revision's `server/discover` request is answered HTTP `400` with JSON-RPC error `-32600`, which a client that speaks both eras reads as an older server, and it then sends `initialize`. A client that speaks only `2026-07-28` cannot connect.
- **Capabilities:** tools only. The methods are `initialize`, `ping`, `tools/list` and `tools/call`.

### Which clients can connect

- **Claude Code** connects today with a key, using the command below.
- **Any MCP client that can send a request header** connects the same way: Streamable HTTP to the address above with the `Authorization` header.
- **The Claude chat app (claude.ai, desktop and mobile), as a custom connector,** cannot connect for most accounts. A custom connector there signs in with OAuth, which this server does not offer, and sending a fixed header instead is described by Anthropic as "in beta and available to a limited set of organizations". An account that has a **Request headers** section when adding a custom connector can choose **No sign-in** and add the header `authorization` with the value `Bearer`, a space, then the key. An account without that section cannot connect.

In Claude Code, the player runs this once in a terminal, with their key in place of `PASTE_YOUR_API_KEY_HERE`:

```bash
claude mcp add --transport http turf-monster https://turfmonster.media/mcp --header "Authorization: Bearer PASTE_YOUR_API_KEY_HERE"
```

That saves the key in Claude Code's own settings for the folder it was run in. `/mcp` inside Claude Code then shows the server and its tools.

### The tools

| Tool | Same as | Arguments | Changes anything? |
|---|---|---|---|
| `get_me` | `GET /api/v1/me` | none | No |
| `list_contests` | `GET /api/v1/contests` | `status`, `limit`, `offset` | No |
| `get_contest` | `GET /api/v1/contests/:slug` | **`contest_slug`** | No |
| `get_leaderboard` | `GET /api/v1/contests/:slug/leaderboard` | **`contest_slug`**, `limit`, `offset` | No |
| `list_my_entries` | `GET /api/v1/entries` | `contest_slug`, `limit`, `offset` | No |
| `get_entry` | `GET /api/v1/entries/:slug` | **`entry_slug`** | No |
| `submit_entry` | `POST /api/v1/contests/:slug/entries` | **`contest_slug`**, **`matchup_ids`**, **`idempotency_key`**, `allow_usdc` | Yes: spends a free entry token, or USDC |
| `edit_entry` | `PATCH /api/v1/entries/:slug` | **`entry_slug`**, **`matchup_ids`** | Yes: replaces the picks |

Arguments in bold are required. A tool takes no argument that is not listed: an unknown one is refused by name, with `bad_request`, so sending `slug` for `contest_slug` tells you so.

| Argument | Type | Meaning |
|---|---|---|
| `status` | `open` or `settled` | Only open contests, or only settled (finished and paid) ones. Leave out for both. |
| `limit` | integer | Page size. Default 25, maximum 100. |
| `offset` | integer | Rows to skip. Default 0. Read pagination.has_more to know if there is another page. |
| `contest_slug` | string | The contest's slug, from list_contests (contests[].slug). |
| `entry_slug` | string | The entry's slug, from list_my_entries (entries[].slug) or from the result of submit_entry. |
| `matchup_ids` | list of integers | The picks: exactly picks_required different ids (normally six), each a teams[].matchup_id from get_contest for this contest. Order does not matter. |
| `idempotency_key` | string | A unique value you make up for this one entry, such as a UUID: 1 to 255 printable characters, no spaces. Reuse it on every retry of this entry. |
| `allow_usdc` | boolean | Leave false. true lets the entry fee be paid in USDC from the player's wallet when they have no free entry token. Set it only when the player has told you to spend money. |

On `list_my_entries`, `contest_slug` is optional and narrows the list to one contest. `tools/list` returns each tool's full JSON Schema.

### Tool results

**A tool result is the REST response body.** `content[0].text` is that body as a JSON string, always. From revision `2025-06-18` the same body is also in `structuredContent`. What REST says in its status line and headers is in `_meta`: `turfmonster.media/http_status`, `turfmonster.media/retry_after` (seconds) and `turfmonster.media/idempotent_replayed`.

**A refusal is a result with `isError: true`**, and its body is the same `{ "error": { "code": "...", "message": "..." } }` with the same codes as the table under [Errors](#errors). Branch on `code` exactly as you would over HTTP. An argument of the wrong type is one of these (`bad_request`), not a protocol error, so you can read it and correct the call.

```json
{
  "content": [
    { "type": "text", "text": "{\"error\":{\"code\":\"team_locked\",\"message\":\"...\"}}" }
  ],
  "structuredContent": { "error": { "code": "team_locked", "message": "..." } },
  "isError": true,
  "_meta": { "turfmonster.media/http_status": 422 }
}
```

Two answers carry a second text block, because a model does not see a status line:

- **`PENDING: PAID, NOT YET ENTERED`** is the `202` of `POST /api/v1/contests/:slug/entries`. `isError` is `false` and `entry` is `null`. The entry is paid for and is not yet an entry. Do not tell the player it failed, and **do not tell the player they are entered until a call returns an entry**. Call `submit_entry` again with the same `idempotency_key` and the same arguments after `retry_after` seconds. If it is still pending after a few minutes, stop and tell the player what [the section on creating an entry](#post-apiv1contestsslugentries) says to tell them: paid for, not showing as entered, contact support.
- **`RETRY`** comes with `idempotency_in_progress` and `chain_unavailable`. `isError` is `true`. Call the same tool again with the same `idempotency_key` and the same arguments after `retry_after` seconds.

An account on hold, or one that still owes age verification, can call every reading tool. `submit_entry` and `edit_entry` answer `account_frozen` or `age_verification_required`.

### The idempotency key is an argument

A model cannot set a header, so `submit_entry` takes the `Idempotency-Key` as its `idempotency_key` argument. It is required, and it follows the same two-case rule as [Retries and idempotency](#retries-and-idempotency):

1. **No definite answer yet** (a timeout, an error with no result, `chain_unavailable`, `idempotency_in_progress`, or pending): call `submit_entry` again with the **same** `idempotency_key` and the **same** arguments, as many times as it takes.
2. **A definite refusal that says nothing was spent**: only then may you change `matchup_ids` or `allow_usdc`, and the changed call takes a **new** `idempotency_key`.

If you cannot tell which case you are in, call `list_my_entries` before anything else.

**One key works on both surfaces, and the idempotency record is shared.** The API key that authenticates `/mcp` is the key that authenticates `/api/v1`. An `idempotency_key` used in `submit_entry` and an `Idempotency-Key` sent to the REST endpoint are one record for the player: a value first used over MCP replays over REST, and the other way round. `edit_entry` needs no key, as `PATCH` needs none.

### Failures that are not tool results

| Failure | Answer |
|---|---|
| No key, or a malformed, revoked or expired one | HTTP `401` with the error body used everywhere else |
| Too many requests | HTTP `429` with `rate_limited` and `retry_after` |
| A body that is not JSON, or larger than 64 KB | HTTP `400`, JSON-RPC error `-32700` |
| A message that is not a JSON-RPC request, an `MCP-Protocol-Version` not listed above, or a batch at a revision without batches | HTTP `400`, JSON-RPC error `-32600` |
| A method other than the four above | JSON-RPC error `-32601` |
| An unknown tool, or `arguments` that is not an object | JSON-RPC error `-32602` |
| A fault on the server | JSON-RPC error `-32603`. For `submit_entry`, call again with the same `idempotency_key` and the same arguments. |
| `GET`, `PUT`, `PATCH` or `DELETE` on the address | HTTP `405` |
| A request carrying an `Origin` header from another site | HTTP `403` |

### MCP rate limits

| Limit | Keyed on |
|---|---|
| 120 requests per 60 seconds | The API key. This is a separate count from the key's limit on `/api/`. |
| 30 requests per 60 seconds (300 from the addresses Anthropic's connectors call from) | The calling IP address, only for requests with no key or with a key that has not authenticated here before. A key's first successful request takes it out of this limit for 24 hours. |

A player is limited by their key and not by the address their client calls from. A JSON-RPC batch (revision `2025-03-26` only, at most 10 messages) counts once per message.

## Results and payouts

- **Scores move during the games.** Re-read the leaderboard or the entry to see the standing. Until the contest settles, `rank` is provisional and `final` is `false`.
- **Grading is a manual step taken by the operators.** It does not happen automatically at the final whistle. When it has happened, the contest reads `settled: true` and every entry has a final `rank` and a `payout_cents`.
- **Paying prizes is a second manual step**, signed by more than one operator. `payout_cents` is the amount the entry won; it is not proof that the money has arrived.
- **Prizes are paid in USDC to the player's wallet**, the one in `wallet.address`.
- **Promise no timing.** You cannot know when a contest will be graded or paid. Tell the player what the API shows and that settlement is done by hand. For anything overdue, they contact support at the address on https://turfmonster.media/contact.

Report results as they are. If the entry finished out of the prizes, say so.

## What the API cannot do yet

- **Survivor contests.** World Cup Survivor (`game_type: "world_cup_survivor"`) is listed with `"supported": false` and a `note`, an empty `teams` list and an empty leaderboard. It is played on the website.
- **Wallet-signed entries.** A player whose own wallet must sign (self-custodied, or with a Phantom wallet linked), and a player with no wallet, cannot enter through the API.
- **Anything about money beyond the entry.** The API does not deposit, withdraw, buy tokens or move funds.
- **Withdraw or cancel an entry.** Once created, an entry can have its picks replaced before the lock. It cannot be removed, and what paid for it is not returned by this API.
- **Create an account or a key.** A person does both on the website.

## How to win

Nothing here guarantees a win. A contest is decided by real games, and a good lineup loses often. What follows is how to reason from the scoring rule and from the fields the API returns. Where it depends on an assumption, the assumption is named, and the judgement is yours.

### 1. Price every team: projected value

For each team on the board:

```
projected value = expected_team_score × turf_score
```

`expected_team_score` is already summed over the team's games in the contest, so it needs no adjustment for `games_count`. The projected score of a lineup is the sum of its picks' projected values. Sorting the board by projected value is the baseline every other consideration departs from.

Two cautions. `expected_team_score` is the board's own projection, made when the board was ranked; it is an estimate, and it can be stale. And it is `null` on boards without projections (World Cup boards), where `rank` and `turf_score` are all the board tells you and any estimate of scoring has to be your own.

### 2. Is the multiplier fair to favourites? Measure it on the board in front of you

On an NFL board the multiplier depends only on a team's **rank**, not on how far apart the projections are. The curve runs evenly from 1.0 at rank 1 to 2.0 at the last rank. For every team to have the same projected value, the projections would have to fall away exactly as the curve rises: the last-ranked team projected at half of the top team, the middle team at about two thirds.

Real boards are not shaped exactly like that, so projected value is not flat, and which end of the board is favoured is a fact about that board:

- Where the projections are **closer together** than the curve assumes, the higher multipliers overpay, and lower-ranked teams have the higher projected value.
- Where they are **further apart**, the favourites keep the higher projected value despite the low multiplier.

So do not carry a rule such as "always take underdogs" from one contest to the next. Compute the product for all teams and look.

Three finer points, all from the way the multiplier is built:

- **Rounding makes steps.** The multiplier is rounded to one decimal, so several neighbouring ranks share one value (see the table under [Scoring](#scoring)). Within a group that shares a multiplier and plays the same number of games, the better-ranked team has the higher projection and so the higher projected value. The first rank of the next group gets a full 0.1 more for almost the same projection.
- **Byes are priced to be neutral, then rounded.** A team playing fewer games has its multiplier scaled by exactly the ratio of games, so on projected value a bye neither helps nor hurts. What changes is that the same expected total rides on fewer games.
- **A hand-set multiplier breaks the pattern.** If a team's `turf_score` does not match its `rank`, the product still tells you what it is worth.

### 3. Your estimate against the board's

The multiplier is fixed on the board; the truth about a team is not. If you have a reasoned estimate of a team's scoring that differs from `expected_team_score`, then your projected value is `your estimate × turf_score`, and the teams where your estimate is higher than the board's are where an edge can come from. Information that arrived after the board was ranked (a starting quarterback ruled out, a forecast, a changed line) is the usual source. If you have no better estimate than the board's, say so, and use the board's.

Do not invent numbers. If you could not look something up, tell the player it is your judgement and not data.

### 4. Variance comes with the multiplier

A pick's points are `team_score × turf_score`, so the multiplier scales the swings as well as the average. If two teams' raw scores vary by a similar amount from game to game (an assumption, and yours to check), the one with the higher multiplier adds more spread to the entry's score. A team concentrated into fewer games (`games_count` below `games_per_team`) has the same expected total riding on fewer outcomes, which also widens the spread.

Two picks whose teams play each other, or one team picked across several weeks, tie part of the entry to the same games. Whether that helps depends on what you think of those games.

Whether you want spread is decided by the prizes.

### 5. Read the prize table and the field

From `payouts`, `max_entries` and `entries_count`:

- **Top-heavy prizes** (one prize, or a first prize several times the rest): finishing first is most of the value, and finishing second in a field of many is worth little or nothing. A lineup that maximises only the projected score tends to land in the middle of similar lineups. Accepting more variance to raise the chance of the very top score is rational here.
- **Flat prizes** (several ranks paying the same): the aim is to finish inside the paid ranks. Projected score matters more, extra variance less.
- **Field size.** Beating two rivals needs a good score; beating ninety needs an exceptional one. The larger `max_entries` is against the number of paid ranks, the more a lineup has to stand apart to win.
- **Short fields.** Only ranks that exist are paid. If the number of entries at the lock is no more than the number of paid ranks, every entry finishes in a paid rank. `entries_count` before the lock is a floor, not the final number: entries can arrive until `locks_at`.

A free entry token is already the stake. Once the player has decided to use it, there is nothing further to protect by playing safe, except as safety bears on finishing in a paid rank.

### 6. When to be contrarian

Before the lock, rivals' picks are hidden, so you cannot measure who holds which team. What you do know is that every rival sees the same board, the same multipliers and the same projections, and the teams with the highest projected value are visible to all of them.

An entry that holds only those teams rises and falls with the rivals who hold them too, and an identical score splits the prize. To finish alone at the top, a lineup needs at least one team that the entries around it do not have, and it needs that team to outscore its projection. That costs some projected value, and is worth more the larger the field and the more top-heavy the prizes. In a small field with flat prizes it is rarely worth much.

Being different is a means, not a goal. A pick you have no reason to like is not improved by being unpopular.

After the lock every lineup is visible on the leaderboard. Nothing can be changed then; it is useful for telling the player where they stand and what they need.

### 7. Up to 3 distinct lineups

A player may hold up to `max_entries_per_player` entries in a contest, and no two may be the same set of teams. Each entry is paid for separately: one free entry token each, or the entry fee in USDC if the player has said yes to that. With one token, there is one entry. Do not suggest spending money to add more; if the player raises it, the reasoning is:

- Lineups that share five of six teams finish close together. They cover little more ground than one.
- With top-heavy prizes, keep a core you believe in and vary the high-multiplier picks across lineups, so that different upsets help different entries.
- Your own entries compete with each other for the same prizes.

### 8. Time your moves around the locks

- **Creating an entry secures the spot; editing it is free.** `POST` takes a spot in the field and spends the token. `PATCH` replaces the picks at no cost until the lock. So when a contest may fill, the player can enter with the best lineup known now and you can refine it as information arrives, with their agreement to each change.
- **Check again before the lock.** Re-read the contest for changed `turf_score` values, `locked` teams and late news, and edit if the reasoning has changed.
- **Mind the per-team lock.** If `locks_at` is later than some kickoffs, each team freezes at its own first kickoff. Settle the early teams first; you can still change later ones afterwards.
- **In a multi-week contest everything is final at the lock**, including the later weeks. Nothing learned after the first week can be used, and less is known about the later games when you choose.
- **Leave time.** A submission needs a Solana transaction. Close to the lock a slow confirmation can mean `contest_locked`.

### 9. Talking to the player

Show the lineup before submitting: each team, its multiplier, its projected value, and the reason it is there. Say what you assumed and what you could not check. Do not claim an expected win rate you have not calculated, and do not describe a lineup as safe. Afterwards, report the standing as the API gives it.
