# HelixOdds Public API v1

Base URL: `https://api.helixodds.com/api/v1`
Machine-readable specification: [`openapi-v1.yaml`](openapi-v1.yaml)

Read-only. `GET` and `OPTIONS` only; anything else returns `405`.

## The shape of every response

A successful response is an envelope:

```json
{
  "data": { },
  "pagination": { "limit": 50, "next_cursor": "...", "has_more": true },
  "meta": { }
}
```

`data` is the resource or the page. `pagination` appears on collections. `meta`
carries freshness and counts. An error replaces all three with `error` — see
[Errors](errors.md).

## Endpoints

| | |
|---|---|
| `GET /status` | service identity; with a token, your account health |
| `GET /schema` | the vocabularies and limits this API enforces |
| `GET /fixtures` | a page of your processed fixtures |
| `GET /fixtures/{id}` | one fixture |
| `GET /fixtures/{id}/markets` | its canonical markets and selections |
| `GET /fixtures/{id}/markets/{market_id}` | one market |
| `GET /changes` | the incremental feed |
| `GET /live` | fixtures in play |
| `GET /live/{id}` | one live fixture |
| `GET /live/{id}/timeline` | its state changes |
| `GET /live/{id}/momentum` | its momentum |
| `GET /live/{id}/stats` | per-minute breakdown (`advanced_stats` add-on) |
| `GET /account` | products, features, expiry, health |
| `GET /account/provider` | your provider connection status |
| `GET /account/usage` | request and provider-call counters |

`/status` works without a token and then reveals nothing about any account.
Everything else requires one.

## Bootstrap, then follow changes

**Do not poll `/fixtures` in a loop.** The intended pattern is two phases:

```
1. page through GET /fixtures            → your initial picture
2. GET /changes?cursor=<last seq>        → everything since
```

`/changes` returns one event per fixture the engine refreshed, with a monotonic
`seq`. Store the last `seq` you processed and pass it back as `cursor`. When
nothing has happened you get an empty page and the same cursor — that is the
steady state, not an error.

A socket transport delivers these same events, with these same sequence
numbers — see [Realtime](realtime.md). It is an accelerator: polling `/changes`
is a complete integration on its own, and a client whose socket cannot connect
loses latency and nothing else.

## Pagination

```
?limit=50&cursor=<opaque>
```

Default `50`, maximum `200`. A larger `limit` is refused with
`INVALID_PARAMETER` rather than silently capped, so you never believe you
received more than you did.

`next_cursor` is opaque — base64 over a small object whose shape may change. Do
not construct one, parse one, or carry one between collections: a `/fixtures`
cursor passed to `/changes` returns `INVALID_CURSOR`.

Fixture pagination is **keyset** on the opaque fixture id, not an offset. The
order is stable across processing cycles, so a cursor held while the dataset is
rewritten still resumes in the right place instead of skipping fixtures.

`/changes` retention is bounded. A cursor older than the retained window returns
`INVALID_CURSOR` with a message saying to re-bootstrap. We would rather tell you
there is a gap than serve a page that quietly omits it.

## There is no full-snapshot endpoint

The processed dataset for a busy account is several hundred megabytes. No
endpoint returns it, and no endpoint parses it: fixture lists are served from an
index, and one fixture is read from its byte range in the file. A request costs
the same whether your account holds a thousand fixtures or a million.

If you want a bulk export for analysis, that is a different product and not part
of v1.

## Identifiers

```
fx_…   fixture      stable forever
mk_…   market       stable; unique within a fixture
sl_…   selection    stable; unique within a market
```

Opaque and deterministic. Your provider's own identifiers appear under
`provenance`:

```json
{
  "fixture_id": "fx_0011bd8f26e2625aae7576f3",
  "provenance": { "source_provider": "betsapi", "provider_fixture_id": "202419381" }
}
```

Use `fixture_id`. `provenance` is there so you can reconcile with your own
upstream account when you need to, and so a support conversation can name a
match. An integration keyed on `provider_fixture_id` is an integration keyed on
one provider's identity, and it will break the day you change provider.

## Freshness

Four fields, on every data response:

```json
{
  "provider_fetched_at": "2026-10-08T08:21:33.261Z",
  "processed_at":        "2026-10-08T08:22:04.703Z",
  "published_at":        "2026-10-08T08:22:11.004Z",
  "age_ms": 184000
}
```

`provider_fetched_at` is when the data was retrieved from your provider. It moves
**only** after a real retrieval. Reading an endpoint does not move it, a
re-publication does not move it, and nothing in the system touches it to make
data look newer than it is. If it is three hours old, your data is three hours
old and we say so.

`processed_at` is when the engine produced the dataset; `published_at` is when it
became readable here; `age_ms` is derived from `provider_fetched_at`.

## Filters

On `/fixtures`:

```
?league=Premier
?starts_after=2026-10-10T00:00:00Z
?starts_before=2026-10-11T00:00:00Z
```

On `/fixtures/{id}/markets`:

```
?market=TOTAL_GOALS
?period=FT
```

A malformed timestamp is `INVALID_PARAMETER`, not an empty page.

## Conditions that are not errors in your code

| Code | Status | What to do |
|---|---|---|
| `PROVIDER_NOT_CONNECTED` | 409 | Connect your provider credential in the panel. |
| `DATA_NOT_READY` | 503 | Your first processing cycle has not finished. Retry in minutes, not seconds. |
| `RATE_LIMITED` | 429 | Wait for `Retry-After`. |
| `UPSTREAM_UNAVAILABLE` | 502 | Your provider could not be reached. Retry with backoff. |

See also: [Authentication](authentication.md) ·
[Data contract](data-contract-v1.md) · [Realtime](realtime.md) ·
[Live Intelligence](live-intelligence.md) · [Quickstart](integration-quickstart.md)
