# 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="sf_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 sf_live_xxxxxxxxxxxxxxxx
Authorization: sf_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.

### Key delivery modes

Each key is fixed to one delivery mode when it is created, so one account can
run a Direct key and a Sync key side by side.

| Mode | Who | Behaviour |
|---|---|---|
| `direct` | Any plan | Live, per-end-user requests. No cadence gate. `limit` max 100. |
| `sync` | Paid plans only; the Sync terms are accepted when the key is created | Scheduled pulls into your own store. Paced to your plan's sync interval — an early poll returns `429 SYNC_INTERVAL_NOT_ELAPSED`. `limit` max 1000. Can request `include_geo`. |
| `grandfathered` | Every key created before delivery modes shipped | Flexible. Behaviour is unchanged and still follows your account's original delivery setting. Nothing to migrate. |

Pick the mode when you create the key in the dashboard. The mode cannot be
changed afterwards — create a second key instead.


---

## 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**. They are still served for existing
> integrations, but new integrations must not use them. 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. Unmatched IDs return an empty list (HTTP 200). |
| `players` | CSV | Up to 100 player IDs. Requires `id_type`. Unmatched IDs return an empty list (HTTP 200). |
| `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 (paid plans, within your source allowance). |
| `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 100 on Direct and grandfathered keys; max 1000 on keys issued as Sync. Page through with `page` for more. |
| `page` | int | 1-based. On a Sync key, page 1 (or no `page`) starts a poll and stamps your cadence; page 2+ requested inside your sync interval continues that poll and is **not** cadence-checked. A continuation sent after the interval has elapsed counts as a new poll. |


### 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).

Every successful `/posts` response also carries `X-SportFeeds-Mode: direct` or
`X-SportFeeds-Mode: sync`, telling you which delivery mode the key is configured
for. It is informational — the payload is identical either way.



**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":   [ {
        "url": "https://pbs.twimg.com/amplify_video_thumb/…/img/….jpg",
        "preview_image_url": "https://pbs.twimg.com/amplify_video_thumb/…/img/….jpg",
        "media_key": "13_2096226426907230210",
        "width": 1080, "height": 1440, "duration_ms": 24000,
        "video_url_low":    "https://video.twimg.com/amplify_video/…/320x568/….mp4",
        "video_url_medium": "https://video.twimg.com/amplify_video/…/720x1280/….mp4",
        "video_url_high":   "https://video.twimg.com/amplify_video/…/2160x3840/….mp4"
      } ]
    }
  ]
}
```

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.
- `score` is the engagement rating (0–9, one decimal) on every post. It is both
  readable and filterable via `min_score`; 7+ is top performing.
- **Media:** for a video, `media[].url` and `preview_image_url` are the still
  image (poster). The playable mp4s are `video_url_low` / `video_url_medium` /
  `video_url_high`. For a photo, `url` is the image itself and no
  `video_url_*` fields are present. `media_key` is X's stable asset id.
- `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.

### Which countries a source is cleared for (`include_geo`, Sync keys only)

If you cache posts and decide regional visibility yourself, ask for the
clearance list instead of filtering per request:

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

Each item then carries a `geo` object:

```json
"geo": { "allowed_countries": ["US", "GB"], "blocked_countries": [], "updated_at": "2026-09-01T12:00:00Z" }
```

- Codes are ISO 3166-1 alpha-2. Re-check `updated_at` — clearances change.
- Omitted unless you ask for it, and ignored on Direct and grandfathered keys,
  so no existing payload changes.

### Post analysis (`include_analysis`, any key type)

Ask for AI analysis alongside the posts you already fetch:

```bash
curl -H "Authorization: Bearer $KEY"   "https://api.sportfeeds.com/v1/posts?leagues=NFL&include_analysis=true"
```

Each analysed item then carries an `analysis` object alongside `media`:

```json
"analysis": {
  "sentiment": {
    "label": "negative",
    "intensity": 7.5,
    "entities": [
      { "type": "team", "name": "Cincinnati Bengals", "sentiment": "negative" },
      { "type": "player", "name": "Ja'Marr Chase", "sentiment": "negative" }
    ]
  }
}
```

- `label` is `positive`, `negative` or `neutral`; `intensity` is 0-10 tone intensity.
- Omitted unless you ask for it, and omitted on posts not yet analysed — so no
  existing payload changes.

---

## 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` | Reserved for retired endpoints | **Terminal.** Rewrite to `GET /posts`. |
| 429 | `USAGE_LIMIT_REACHED` | Monthly call cap reached (hard stop, no overage billing) | Stop the loop until the next billing period. |
| 429 | `SYNC_INTERVAL_NOT_ELAPSED` | Sync key polled too early | Sleep `retry_after_seconds` (also in the `Retry-After` header), then retry. Paging through a poll you already started is never blocked. |
| 200 | — | Team/player/source filters that match nothing | **Not an error.** You get `items: []`. Do not treat it as a 404. |

| 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.

### Which media field actually plays

For a video post, `media[].url` and `media[].preview_image_url` are the still
image. Pointing a `<video>` at either one gives you a poster frame that never
plays. The playable mp4 is `video_url_high` (or `video_url_medium` /
`video_url_low` for smaller renditions).

### Why direct video URLs can still fail in production

Those mp4s are X's raw assets. X hotlink-protects them, so a direct
`<video src={media.video_url_high}>` usually works on localhost but can 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 a proxy that you host yourself.

### Host your own video proxy

SportFeeds does not operate a customer-facing media proxy. Render video through
a lightweight proxy on YOUR domain that forwards the request with the headers
X expects. This keeps playback reliable in production and puts bandwidth and
caching under your control.

Minimal reference implementation (any server/edge runtime):

```js
// GET /video-proxy?url=<encoded-video_url_high>  — on YOUR domain
const upstream = new URL(params.url);
if (!/(^|\.)video\.twimg\.com$/.test(upstream.hostname)) return 400;
const res = await fetch(upstream, {
  headers: { Referer: "https://x.com/", "User-Agent": req.headers["user-agent"] },
});
return new Response(res.body, {
  status: res.status,
  headers: { "Content-Type": res.headers.get("content-type") ?? "video/mp4" },
});
```

Proxy the `video_url_*` value, never `media[].url`.
Cache proxied responses aggressively (CDN or object storage) so repeat views
don't re-fetch from X:

```html
<video controls playsinline muted poster={media.preview_image_url}>
  <source
    src={`https://your-domain.com/video-proxy?url=${encodeURIComponent(media.video_url_high)}`}
    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"
```

Account endpoints are never billed: `/account/information`,
`/account/sources`, `/account/x-ids` and
`/account/x-id-deleted` do not consume monthly calls, so run deletion
sweeps as often as you need.


Paid plans include a custom source allowance (see the pricing page for how many).
Custom X sources are added and mapped to a sport/league from the dashboard, and
country availability is set by SportFeeds after per-source verification.
`/account/sources` is available on paid plans and lists your custom
sources with their sport/league mapping and geo availability; enterprise
accounts additionally receive the full default source catalogue in the same
response. Sandbox accounts get `403`. All custom sources are honoured
by `/posts`.

`/account/information` returns `usage.current_calls`,
`usage.call_cap` and `usage.remaining_calls`, plus
`usage.direct_calls` and `usage.sync_calls` — the same period
total split by the delivery mode of the key that made each call.


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: 100, 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-100, 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 — deprecated, existing integrations only.
- '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=sf_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
