# SportFeeds — Agent Integration Guide

You are integrating **SportFeeds**, a B2B API that returns real-time, verified
sports posts from X (Twitter), enriched with league, sport, team, player, game,
media, engagement-rating and news-classification metadata.

This document is written to be dropped directly into an AI coding agent's
context (Claude Code, Cursor, Codex, Copilot, a custom GPT, or your own
tool-calling loop). Everything an agent needs to make a correct first call —
and never make an invalid one — is below.

- Base URL: `https://api.sportfeeds.com/v1`
- Docs (human): https://sportfeeds.com/docs
- OpenAPI: https://sportfeeds.com/openapi.json
- Short index: https://sportfeeds.com/llms.txt
- Full text reference: https://sportfeeds.com/llms-full.txt
- Get an API key: https://sportfeeds.com/dashboard

---

## 0. 60-second quick start

```bash
export SPORTFEEDS_KEY="sk_live_..."   # from https://sportfeeds.com/dashboard

# Newest NFL posts
curl -s -H "Authorization: Bearer $SPORTFEEDS_KEY"   "https://api.sportfeeds.com/v1/posts?leagues=NFL&limit=10" | jq

# Top-rated video highlights across all of soccer, last hour
curl -s -H "Authorization: Bearer $SPORTFEEDS_KEY"   "https://api.sportfeeds.com/v1/posts?sports=football&media_type=video&min_score=8&limit=20" | jq
```

If that returns `{"pagination":…,"items":[…]}` you are done wiring. Everything
else in this document is refinement.

---

## 1. Authentication

Every request needs an `Authorization` header. Both forms are accepted:

```http
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxx
Authorization: sk_live_xxxxxxxxxxxxxxxx
```

Rules for agents:

- **Never** put the key in a query string, a URL, a log line, or client-side code.
  Read it from an environment variable (`SPORTFEEDS_API_KEY`) on the server.
- One key per environment. Rotate from the dashboard; old keys 401 immediately.
- There is no OAuth, no refresh token, no session. The key is the whole auth story.

---

## 2. The mental model

SportFeeds has **one retrieval endpoint** and **two support endpoints**.

| Endpoint | Purpose |
|---|---|
| `GET /posts` | Every content query. Mode is inferred from the parameters you pass. |
| `GET /ids` | Resolve/translate team, player, league and post IDs between providers. |
| `GET /account/*` | Your plan, usage, custom sources, and X account IDs. |

> **Do not call** `/posts/leagues`, `/posts/teams`, `/posts/players`, `/posts/games`, `/posts/nearby`, `/posts/trending`.
> These legacy split routes are deprecated and return **HTTP 410 ENDPOINT_DEPRECATED**
> for accounts created after the unification. Always use `GET /posts` with query params.

### Modes of `GET /posts`

The mode is chosen for you by which parameters are present:

| Mode | Trigger |
|---|---|
| trending | no filters, or `sort=trending` |
| league / sport | `leagues=` and/or `sports=` |
| teams | `teams=` (+ `id_type=`) |
| players | `players=` (+ `id_type=`) |
| sources | `sources=` |
| game | `match=<teamA>,<teamB>` + `start_time=<iso>` |
| nearby | `lat=` + `lng=` (+ `radius_km=`) |

Modes compose. `leagues=NFL&media_type=video&min_score=8` is a perfectly valid
single request — you do not need to fan out.

---

## 3. `GET /posts` — full parameter reference

All parameters are optional. All values are **case-insensitive**. All list
parameters are **comma-separated** and **plural**.

### Content selectors

| Param | Type | Notes |
|---|---|---|
| `leagues` | CSV | League codes (see §4). `ALL` is accepted. |
| `sports` | CSV | Parent sports (see §4). Returns every league under them. |
| `teams` | CSV | Up to 10 team IDs. Requires `id_type` unless using `sportfeeds` IDs. |
| `players` | CSV | Up to 100 player IDs. Requires `id_type`. |
| `sources` | CSV | Up to 500 X handles (no @) or numeric X account IDs. |
| `id_type` | string | ID system used by `teams`/`players`. See §5. |
| `match` | string | `teamA,teamB` — must be paired with `start_time`. |
| `start_time` | ISO 8601 | Kickoff/tipoff time of the game. |
| `include_pregame` | bool | Include pregame chatter for a `match` query. |
| `lat`, `lng` | float | Decimal degrees; triggers geo mode. |
| `radius_km` | int | Default 145, max 4830. |

### Content filters

| Param | Type | Notes |
|---|---|---|
| `media_type` | string | `video`, `photo`, `gif`, `animated_gif`, `none` |
| `aspect_ratio` | string | `landscape`, `portrait`, `square` |
| `min_duration` | int (ms) | Minimum video duration. |
| `highlight` | bool | Only highlight clips. |
| `has_player` | bool | Only posts with ≥1 tagged player. |
| `min_score` | int 0–10 | Engagement rating floor. See §6. |
| `custom_sources` | bool | Include your private custom sources (Enterprise). |
| `user_location` | ISO-3166-1 alpha-2 | Only content whose source is cleared for that region. See §8. |

### News classification (opt-in)

| Param | Type | Notes |
|---|---|---|
| `category` | string | `news`, `highlight`, `other` |
| `news_type` | CSV | `injury, trade, signing, cut, suspension, contract_extension, roster_move, return_from_injury, retirement, coaching_change, draft, other` |
| `news_status` | string | `verified` or `rumor` |
| `news_origin` | string | `breaking` (first to report) or `relaying` |
| `include_news` | bool | Adds the news fields to every item. Auto-on when any news filter is used. |

### Ordering & pagination

| Param | Type | Notes |
|---|---|---|
| `sort` | string | `recent` (default), `top`, `trending` |
| `since` | ISO 8601 | Posts published after this time. **Use this for polling.** |
| `since_id` | string | Posts with an id greater than this. |
| `limit` | int | Default 20, max 50. |
| `page` | int | 1-based. |

### Unknown parameters

Unsupported parameters are currently **ignored**, and the response carries an
`X-SportFeeds-Warning` header plus a `warnings[]` array naming each unknown
parameter and the suggested correction. A future release will reject them with
`400 INVALID_PARAMETER` (30 days' notice will be given).

**Agents must treat `warnings[]` as an error during development.** If it is
non-empty, your query is not doing what you think it is. Common offenders:
`league=` (use `leagues=`), `sport=` (use `sports=`), `team_id=`, `league_name=`, `q=`, `query=`.

---

## 4. League and sport codes

These are SportFeeds' universal codes. **Do not** pass provider IDs or free-text
names here — only the codes below (any casing).

### Sports (`sports=`)

```
americanfootball  football  basketball  baseball  hockey  golf
cricket  rugby  mma  tennis  boxing  motorsport  athletics  cycling  wrestling
```

> `football` means **association football (soccer)**. The NFL/NCAA game is
> `americanfootball`. `soccer` is accepted as an alias for `football`.

### Leagues (`leagues=`)

| Sport | Codes |
|---|---|
| americanfootball | `NFL`, `NCAAFB` |
| basketball | `NBA`, `WNBA`, `NCAAMB` |
| baseball | `MLB` |
| hockey | `NHL` |
| golf | `PGA`, `GOLF`, `DPWORLDTOUR`, `SUNSHINETOUR` |
| football (soccer) | `EPL`, `MLS`, `FIFA`, `SOCCER`, `LALIGA`, `BUNDESLIGA`, `SERIEA`, `LIGUE1`, `UCL`, `SAPREMIERSHIP`, `ISL` |
| cricket | `CRICKET`, `IPL`, `SA20` |
| rugby | `RUGBY`, `URC`, `SUPERRUGBY`, `SIXNATIONS`, `RWC` |
| mma | `MMA`, `UFC`, `PFL` |

Accepted aliases (auto-mapped): `ChampionsLeague→UCL`, `PremierLeague→EPL`, `PSL`/`DStvPremiership→SAPREMIERSHIP`, `WorldCup`/`FootballWc26→FIFA`, `FootballNcaa→NCAAFB`, `NCAA→NCAAMB`, `EuropeanTour→DPWORLDTOUR`, `RugbyWorldCup→RWC`.

Live coverage depth per league is published at https://sportfeeds.com/coverage.
Some codes are accepted but thinly covered — check coverage before promising a
user complete data for a league.

---

## 5. IDs — bring your own, or use ours

You do not have to migrate to SportFeeds IDs. Pass `id_type` and use the IDs you
already have.

### Supported `id_type` values

```
sportfeeds  code  espn_guid  espn_uid  espn_slug  espn
sportradar  sportsdataio  sportsstack  grid  apisports  nba
yahoo  yahoo_dfs  rotowire  draftkings  fanduel
```

### SportFeeds IDs

Every league, team, player and post also carries a stable **8-digit numeric
`sportfeeds_id`** (10000000–99999999), unique across all entity types. Every
post item returns `sportfeeds_id` alongside whichever `id_type` you requested,
so you can join both ways.

### `GET /ids` — the translation endpoint

```bash
# All NFL teams with their Sportradar IDs
curl -H "Authorization: Bearer $KEY"   "https://api.sportfeeds.com/v1/ids?entity=teams&league=NFL&reference_id=sportradar_id"

# All NFL players with ESPN GUIDs
curl -H "Authorization: Bearer $KEY"   "https://api.sportfeeds.com/v1/ids?entity=players&league=NFL&reference_id=espn_guid"

# Reverse lookup: which SportFeeds team is this Sportradar id?
curl -H "Authorization: Bearer $KEY"   "https://api.sportfeeds.com/v1/ids?entity=teams&league=NFL&reference_id=sportradar_id&lookup=<their-id>"

# Every active league and its IDs
curl -H "Authorization: Bearer $KEY"   "https://api.sportfeeds.com/v1/ids?entity=leagues&reference_id=sportsstack_id"

# Single post by id
curl -H "Authorization: Bearer $KEY"   "https://api.sportfeeds.com/v1/ids?entity=posts&lookup=<post-id>"
```

| Param | Notes |
|---|---|
| `entity` | **required** — `teams`, `players`, `leagues`, `posts` |
| `league` | required for `teams` and `players` |
| `reference_id` | the provider ID column to include in the response |
| `lookup` | filter to one row; matched against `reference_id`, or `sportfeeds_id` when `reference_id` is omitted |

**Recommended agent pattern:** cache the `/ids` mapping for your leagues once at
startup (or nightly). Rosters change slowly; do not call `/ids` per post.

---

## 6. The engagement rating (`min_score`)

Each post gets a **0–10 engagement rating** computed *relative to that author's
own rolling 100-post baseline* — not against the whole firehose. A 10 from a
small club account and a 10 from ESPN are both genuinely exceptional for that
account.

| Band | Meaning |
|---|---|
| 0–3 | Below the author's norm (often promos/ads) |
| 4–5 | Below average |
| 6 | Average for that author |
| 7–8 | Highly engaged |
| 9 | Rare — top of the author's year |
| 10 | Unicorn (~50x the author's average) |

The rating is driven ~85% by interaction ratio (retweets, likes, quotes,
replies) and ~15% by views, and it is refreshed on a snapshot ladder at
1m, 5m, 15m, 30m, 60m, 6h and 12h after publication. A post's rating can
therefore move upward for ~12 hours after you first see it.

Practical guidance:
- Feed/product surfaces: `min_score=7`.
- "Best of" or push notifications: `min_score=8` or `9`.
- Never use `min_score` as an absolute popularity measure across authors.

---

## 7. Response shape

```json
{
  "pagination": { "page": 1, "limit": 20, "has_next": true, "has_prev": false },
  "warnings": [],
  "items": [
    {
      "id": "1750000000000000000",
      "sportfeeds_id": 41822317,
      "post_url": "https://x.com/espn/status/1750000000000000000",
      "text": "…",
      "created_at": "2026-08-19T20:00:00Z",
      "sport": "americanfootball",
      "league": "NFL",
      "score": 8.4,
      "highlight": true,
      "media_type": "video",
      "author": {
        "username": "espn",
        "name": "ESPN",
        "profile_image_url": "https://…",
        "verified": true
      },
      "teams":   [ { "sportfeeds_id": 20481933, "name": "Kansas City Chiefs", "sportradar_id": "…" } ],
      "players": [ { "sportfeeds_id": 63920114, "name": "Patrick Mahomes", "sportradar_id": "…" } ],
      "media":   [ { "type": "video", "url": "https://…", "width": 1280, "height": 720, "duration_ms": 24000 } ]
    }
  ]
}
```

Field notes for agents:

- `created_at` is the **publication** time on X, not our ingest time. Sort and
  de-dupe on it.
- `id` is the native X post id and is globally unique — use it as your primary key.
- `teams[]`/`players[]` always include `sportfeeds_id`, plus the `{id_type}_id`
  column you asked for. A null provider id means we have no mapping for that entity.
- News fields (`category`, `news_type`, `news_status`, `news_origin`, `news_attribution`)
  only appear when `include_news=true` or a news filter is present.
- Always render the post's author and link back to `post_url` (see §10).

---

## 8. Geo availability (`user_location`)

Some sources are only cleared for viewing in certain countries (rights
restrictions). Pass the end user's country:

```bash
curl -H "Authorization: Bearer $KEY"   "https://api.sportfeeds.com/v1/posts?leagues=EPL&media_type=video&user_location=ZA"
```

- Value is an ISO 3166-1 alpha-2 code (`US`, `ZA`, `GB`, `IN`…). Invalid codes → 400.
- Sources not marked viewable in that region are excluded from results.
- If you serve users in multiple countries, pass `user_location` on **every**
  request and key your cache by it.

---

## 9. Errors, quotas and retries

| Status | `error_code` | Meaning | Agent action |
|---|---|---|---|
| 400 | `INVALID_PARAMETER` | Bad/missing param value | **Terminal.** Fix the request; do not retry. |
| 401 | `UNAUTHORIZED` | Missing/invalid/revoked key | **Terminal.** Surface a config error. |
| 402 | `SUBSCRIPTION_REQUIRED` | No active subscription | **Terminal.** Tell the operator to fix billing. |
| 410 | `ENDPOINT_DEPRECATED` | You hit a split `/posts/{mode}` route | **Terminal.** Rewrite to `GET /posts`. |
| 429 | `QUOTA_EXCEEDED` | Monthly call cap reached | Stop the loop. Do not hammer. |
| 503 | `QUERY_TIMEOUT` | Query too broad for its time budget | Narrow it (add `since`, fewer leagues, lower `limit`) and retry once. |
| 5xx | — | Transient | Retry ≤3x with exponential backoff + jitter. |

Error body shape:

```json
{ "ok": false, "error_code": "INVALID_PARAMETER", "error_message": "Unsupported league code: PREMIER" }
```

**Never** retry a 400/401/402/410 with the same request — the answer will not change.

### Quota hygiene for agents

Your plan has a monthly call cap (check `GET /account/information`). An agent
loop can burn a month of calls in minutes. Required practices:

1. Poll with `since=<created_at of newest post you have>` — never re-page history.
2. Responses are cached for 60s; polling faster than once per minute buys nothing.
3. Ask for the largest `limit` you'll actually use rather than paging repeatedly.
4. Put a hard request budget in the agent loop and fail closed when it's hit.

---

## 10. Display, playback & design practices

Content originates on X and must be presented accordingly. This section also
explains how to make video play reliably in production and the minimum design
patterns we expect on every post surface.

### Why direct video URLs often fail in production

The `media[].url` you receive is X's raw video asset URL. X hotlink-protects
those assets, so a direct `<video src={media.url}>` will usually work in the
Lovable preview and on localhost, but will frequently fail in:

- Incognito / private browsing windows
- Third-party domains and customer embeds
- Mobile webviews and in-app browsers
- Regions where the underlying asset is restricted

You will see silent failures, 403s, or a frozen first frame with the center
play button doing nothing. This is platform-level protection, not a SportFeeds
bug. The fix is to route playback through the SportFeeds proxy.

### Always use the SportFeeds video proxy

Use the proxy endpoint for every video playback URL:

```
https://api.sportfeeds.com/v1/video-proxy?url=<encoded-media-url>
```

The proxy preserves the origin headers X expects, so the same video that fails
as a raw URL will play in incognito, embeds, and customer apps. Treat
`media[].url` as the identifier; the proxied URL is what you render.

```html
<video controls playsinline muted poster={media.poster_url}>
  <source
    src={`https://api.sportfeeds.com/v1/video-proxy?url=${encodeURIComponent(media.url)}`}
    type="video/mp4"
  />
</video>
```

If you autoplay, start `muted` and try to unmute after the first successful
`play()` promise. If the browser rejects unmuted autoplay (common in Safari and
Chrome incognito), fall back to muted autoplay rather than leaving a dead player.

### Required design practices

These are not optional polish — they are part of the attribution contract that
keeps the content usable in customer products.

- **X icon in the top-right corner of every post card.** Link it to `post_url`.
- **Author header on every card.** Show the author's profile image, display name,
  and handle. Tapping the header opens the author's profile on X.
- **"View on X" link.** Make the timestamp and/or a text button link to
  `post_url` so users can always reach the original conversation.
- **Do not hide native controls.** If you add a custom overlay play button, keep
  it large, centered, and obvious; do not suppress the native `<video>` controls.
- **Do not crop or cover attribution.** Keep X/Twitter watermarks, reply
  indicators, and platform chrome intact.
- **Respect geo-availability.** When `user_location` is set, the API already filters
  blocked sources. Do not try to work around a blocked video on the client; if
  the proxy returns a restriction, surface it as unavailable.
- **Surface engagement context.** Show the `score` (e.g., "8.7") near the media or at
  the card footer so users understand why a highlight was selected.
- **Remove deleted posts.** If you cache posts long-term, re-check them via
  `GET /ids?entity=posts&lookup=<id>` and drop anything that has been removed from X.

---

## 11. Account endpoints


```bash
# Plan, period, usage, cap
curl -H "Authorization: Bearer $KEY" "https://api.sportfeeds.com/v1/account/information"

# Your custom sources, including their sport/league mapping and geo availability
curl -H "Authorization: Bearer $KEY" "https://api.sportfeeds.com/v1/account/sources"

# X account IDs attached to your account
curl -H "Authorization: Bearer $KEY" "https://api.sportfeeds.com/v1/account/x-ids"
```

Enterprise accounts can add custom X sources, map them to a sport/league, and
edit their allowed/blocked country lists from the dashboard. Those mappings are
reflected in `/account/sources` and honoured by `/posts`.

A source's `type` can change from `custom` to `default` over time: when a
customer-added handle proves broadly useful, SportFeeds promotes it into the
standard catalog. Nothing breaks — the response shape is unchanged, posts keep
flowing, and the handle stops counting toward the account's custom source
limit. After promotion its sport, league and geo settings are SportFeeds-managed
and can no longer be edited by the customer. Do not assume `type` is stable
across calls.


---

## 12. Reference implementations

### TypeScript client (server-side)

```ts
const BASE = "https://api.sportfeeds.com/v1";

type SportFeedsPost = {
  id: string;
  sportfeeds_id: number;
  post_url: string;
  text: string;
  created_at: string;
  league: string | null;
  sport: string | null;
  score: number | null;
  media_type: string | null;
  author: { username: string; name: string; profile_image_url: string | null; verified: boolean };
  teams: Array<Record<string, unknown>>;
  players: Array<Record<string, unknown>>;
  media: Array<Record<string, unknown>>;
};

export async function sportfeeds(
  params: Record<string, string | number | boolean | undefined>,
  path = "/posts",
): Promise<{ items: SportFeedsPost[]; pagination: Record<string, unknown>; warnings?: string[] }> {
  const qs = new URLSearchParams();
  for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== "") qs.set(k, String(v));

  const res = await fetch(BASE + path + "?" + qs, {
    headers: { Authorization: "Bearer " + process.env.SPORTFEEDS_API_KEY! },
  });

  const warn = res.headers.get("X-SportFeeds-Warning");
  if (warn) console.warn("[sportfeeds] " + warn); // your params are wrong — fix them

  if (!res.ok) {
    const body = await res.json().catch(() => ({}));
    throw new Error("SportFeeds " + res.status + " " + (body.error_code ?? "") + ": " + (body.error_message ?? ""));
  }
  return res.json();
}

// Incremental polling — the only loop you should write.
let watermark: string | undefined;
export async function pollNfl() {
  const { items } = await sportfeeds({ leagues: "NFL", limit: 50, since: watermark });
  if (items.length) watermark = items[0].created_at; // items are newest-first
  return items;
}
```

### Python

```python
import os, requests

BASE = "https://api.sportfeeds.com/v1"
S = requests.Session()
S.headers["Authorization"] = "Bearer " + os.environ["SPORTFEEDS_API_KEY"]

def posts(**params):
    r = S.get(f"{BASE}/posts", params={k: v for k, v in params.items() if v is not None}, timeout=20)
    if w := r.headers.get("X-SportFeeds-Warning"):
        raise ValueError(f"bad params: {w}")
    r.raise_for_status()
    return r.json()

for p in posts(sports="football", media_type="video", min_score=8, limit=20)["items"]:
    print(p["score"], p["author"]["username"], p["post_url"])
```

### Tool / function-calling schema

Give your LLM exactly this one tool. It covers every mode.

```json
{
  "name": "sportfeeds_get_posts",
  "description": "Search real-time sports posts from X. Returns posts enriched with league, sport, teams, players, media and a 0-10 engagement rating. Use ONE call with combined filters rather than several calls.",
  "parameters": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "leagues":      { "type": ["string", "null"], "description": "CSV league codes, e.g. 'NFL,NBA'. Codes only, never names." },
      "sports":       { "type": ["string", "null"], "description": "CSV parent sports. 'football' = soccer; NFL is 'americanfootball'." },
      "teams":        { "type": ["string", "null"], "description": "CSV team IDs; requires id_type." },
      "players":      { "type": ["string", "null"], "description": "CSV player IDs; requires id_type." },
      "id_type":      { "type": ["string", "null"], "description": "ID system for teams/players, e.g. 'sportfeeds', 'espn_guid', 'sportradar'." },
      "sources":      { "type": ["string", "null"], "description": "CSV X handles without @." },
      "media_type":   { "type": ["string", "null"], "enum": ["video", "photo", "gif", "animated_gif", "none", null] },
      "min_score":    { "type": ["integer", "null"], "description": "0-10 engagement rating floor. 7=strong, 8+=viral for that author." },
      "highlight":    { "type": ["boolean", "null"] },
      "category":     { "type": ["string", "null"], "enum": ["news", "highlight", "other", null] },
      "news_type":    { "type": ["string", "null"], "description": "CSV: injury, trade, signing, suspension, ..." },
      "user_location":{ "type": ["string", "null"], "description": "ISO-3166-1 alpha-2 country of the end user." },
      "sort":         { "type": ["string", "null"], "enum": ["recent", "top", "trending", null] },
      "since":        { "type": ["string", "null"], "description": "ISO 8601; only posts published after this." },
      "limit":        { "type": ["integer", "null"], "description": "1-50, default 20." }
    },
    "required": ["leagues","sports","teams","players","id_type","sources","media_type","min_score","highlight","category","news_type","user_location","sort","since","limit"]
  }
}
```

---

## 13. Drop-in setup for coding agents

### Claude Code

1. Save this file into the repo:

```bash
mkdir -p docs && curl -s https://sportfeeds.com/agents.md -o docs/sportfeeds-agents.md
```

2. Add to your `CLAUDE.md`:

```md
## SportFeeds API
Full integration guide: @docs/sportfeeds-agents.md

- Base URL: https://api.sportfeeds.com/v1 — the ONLY content endpoint is GET /posts.
- Auth: header "Authorization: Bearer $SPORTFEEDS_API_KEY". Server-side only.
- List params are PLURAL and comma-separated: leagues=, sports=, teams=, players=, sources=.
- Never call /posts/leagues, /posts/teams, /posts/players, /posts/games,
  /posts/nearby or /posts/trending — they return 410.
- 'football' means soccer; the NFL game is 'americanfootball'.
- Poll incrementally with since=<created_at of newest post seen>. Never re-page history.
- If a response has a non-empty warnings[] array, the query is wrong — fix it, don't ship it.
```

3. Put the key in `.env` (and `.gitignore` it): `SPORTFEEDS_API_KEY=sk_live_…`

### Cursor / Windsurf

Save the same file to `.cursor/rules/sportfeeds.md` (or `.windsurfrules`) — the
content above is already written as agent instructions.

### Custom GPT / Assistants API

Upload https://sportfeeds.com/openapi.json as the action schema and set
authentication to **API Key → Bearer**. Paste §2, §3 and §4 of this document into
the instructions so the model uses codes, not names.

### Any agent framework

Register the single tool schema from §12 and paste §3, §4 and §6 into the system
prompt. That is sufficient for correct first-call behaviour.

---

## 14. Agent checklist before shipping

- [ ] Key read from env, server-side only, never logged.
- [ ] All list params plural (`leagues`, `sports`, `teams`, `players`, `sources`).
- [ ] League/sport values are codes from §4, never free text.
- [ ] `warnings[]` and `X-SportFeeds-Warning` are checked and logged loudly.
- [ ] Polling uses `since`; no historical re-paging; ≤1 request/minute per query.
- [ ] Hard request budget in the loop; 429 stops it.
- [ ] 400/401/402/410 are terminal; only 429/503/5xx retry, with backoff.
- [ ] Posts de-duped on `id`, sorted on `created_at`.
- [ ] Author attribution + `post_url` link rendered on every post.
- [ ] Video playback uses the SportFeeds proxy; raw `media[].url` is never used as a `<video src>`.
- [ ] `user_location` passed if you serve multiple countries, and cached per country.

---

Questions or an integration that doesn't fit the above: https://sportfeeds.com/support
