# Padel data feed — integration

```
Base URL   https://api.padelgod.io
Auth       Authorization: Bearer <your key>
```

**Store the base URL as configuration, not in code** — a habit that costs nothing now
and saves a release later.

## Endpoints

```
GET /v1/roster?asOf=<iso>&category=men|women&limit=200
GET /v1/rankings?asOf=<iso>&category=men|women
GET /v1/pairs?asOf=<iso>&category=men|women&window=90d
GET /health
```

| Parameter | Required | Notes |
|---|---|---|
| `asOf` | yes | ISO 8601 instant **with an explicit offset**. The cut-off. |
| `category` | yes | `men` or `women` |
| `limit` | no | roster and rankings; default 200, max 500 |
| `window` | no | pairs only; default `90d`, max `365d` |

## Scopes

A key carries a list of scopes and a route refuses anything outside it. The names match
the routes: `rankings`, `roster`, `pairs`. `pairs` is the paid tier.

A key missing the scope for a route gets the same `401` as a key that does not exist —
see the error table.

## Example

```bash
curl -H "Authorization: Bearer $KEY" \
  "https://api.padelgod.io/v1/roster?asOf=2026-11-16T18:00:00Z&category=men"
```

```jsonc
{
  "meta": {
    "version": "1.0",
    "asOf": "2026-11-16T18:00:00.000Z",
    "rankingWeek": "2026-46",
    "generatedAt": "2026-11-16T18:04:12.001Z",
    "count": 200
  },
  "data": [
    {
      "externalId": "P000010",
      "legalName": "Arturo Coello",
      "displayName": "Arturo Coello",
      "country": "ES",
      "birthdate": "2002-03-08",
      "ranking": 1,
      "points": 20909,
      "rankingMove": 0,
      "elo": null,
      "coaches": ["Gustavo Pratto", "Martin Canali"],
      "photoUrl": "https://…/avatars/….png"
    }
  ]
}
```

`/v1/rankings` returns the same envelope with entries shaped:

```jsonc
{
  "externalId": "3f0c1e88-…",
  "playerId": "3f0c1e88-…",
  "ranking": 1,
  "points": 20909,
  "rankingMove": 0
}
```

**`externalId` does not mean the same thing on both endpoints.** On `/v1/rankings` it is
our internal player id, because that route does not join the players table at all — that
is what lets a client who only needs positions avoid pulling personal data. On
`/v1/roster` it is the FIP id. Joining the two on `externalId` will match nothing.

`/v1/pairs` returns the same envelope with entries shaped:

```jsonc
{
  "externalId": "P000010",
  "partners": [
    { "externalId": "P000285", "matches": 32, "lastPlayedAt": "2026-08-09T15:00:00.000Z" }
  ]
}
```

## Four things worth knowing before you start

**`asOf` must carry an explicit UTC offset** — `2026-11-16T18:00:00Z` or
`2026-11-16T18:00:00+01:00`. A bare date (`2026-11-16`) is accepted and read as UTC
midnight. A datetime *without* an offset is rejected with `400`.

This is deliberate. `new Date('2026-11-16T18:00:00')` parses in the host's local
timezone, so without this rule the same cut-off would resolve to different instants
depending on which region our server happened to run in — and a hash published over
that would prove nothing.

**Hash `data`, not the whole response.** `meta.generatedAt` changes on every call and
lives outside the payload for exactly that reason. Everything else is byte-identical
for a given `asOf`, forever: rows are sorted by `externalId` byte-wise, nulls are
explicit, and numbers are numbers.

Responses carry an `ETag` computed over `data` alone. A cut-off in the past is
`Cache-Control: public, max-age=31536000, immutable` and revalidates with
`If-None-Match` → `304`.

**`elo` is `null` today.** The field is in the response so you can code against its
final shape; values arrive later. It will never be silently backfilled with a
cold-start prior derived from the ranking — that would be a function of the ranking,
not an independent signal, and presenting it beside the ranking as if it were one
would mislead you.

**`/v1/pairs` returns evidence, not a verdict.** For each player: who they contested
main-draw matches with, how many, and when they last did. Qualifying rounds are
excluded.

It is symmetric by construction — A played with B exactly as often as B played with A.
Measured on the live endpoint: **2,306 of 2,306 directed entries agree at 90 days,
4,652 of 4,652 at 365 days**, and nothing is truncated.

We deliberately do not name a single "partner of record". An earlier draft did — most
shared main-draw matches in 90 days, minimum three — and measured against production it
was symmetric in only 81% of cases, which cannot decide a pair award. More importantly
the threshold is a policy of whoever publishes it, not a fact about padel. Take
`partners[0]` and apply your own floor, or run a greedy match over the evidence for
mutually-exclusive pairs.

## Errors

| Status | Meaning |
|---|---|
| `400` | Bad parameter, with a reason in `error` |
| `401` | Any auth failure. Wrong key, absent key, malformed header, revoked key and missing scope are deliberately indistinguishable |
| `422` | `no_snapshot_before` — the cut-off predates our ranking history, with `earliest` in the body. History starts **2026-05-04** |
| `503` | We could not reach the database |

## Coverage

Rankings and player identity cover the full FIP field. Two limits worth knowing:

- **Photographs** are present for roughly 64% of the men's top 200 and 54% of the
  women's. `photoUrl: null` is an ordinary answer — render a monogram; we will not send
  a placeholder image.
- **Per-player Elo** does not exist yet. See above.

Re-host any photograph you intend to display. This endpoint returns a URL that resolves
at the moment of the call and carries no obligation after that.
