# Data contract v1

What the fields mean, what they promise, and what they deliberately do not.

## What HelixOdds is

HelixOdds does **processing**, not data supply. You hold the relationship with
your data provider; we take their payloads and turn them into something you can
build on:

- normalisation of one provider's shapes into one model
- canonical market and selection identity, stable across cycles
- deterministic, versioned derivation where we derive at all
- live intelligence over the in-play feed
- API infrastructure: pagination, cursors, identity, freshness, isolation

We do not resell provider data, and we do not claim to own it. See
[Provider setup](provider-betsapi.md) for what that means for your licence.

## Identity

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

All three are deterministic functions of the provider's identity plus the
provider's name. Consequences worth knowing:

- The same fixture has the same `fixture_id` in every response, forever.
- Two different providers' fixture `1001` get different ids, so adding a provider
  later cannot collide with your stored data.
- You cannot derive a provider id from an opaque id.

When a provider sends two markets with the same canonical definition on one
fixture, the second gets a suffixed `market_id`. Both are returned: dropping one
would hide data you paid for, and sharing an id would make
`/markets/{market_id}` ambiguous.

## Provenance

```json
{ "origin_type": "MAPPED", "derivation": { "version": "canonical_v4" } }
```

| `origin_type` | Meaning | Emitted in v1 |
|---|---|---|
| `MAPPED` | A canonical market identity was established from a provider payload. | yes |
| `RAW` | The market exists upstream; no canonical identity was proven for it. | yes |
| `DERIVED` | Computed from other markets by a versioned rule. | **no** |
| `COMPOSED` | Assembled from several inputs by a versioned rule. | **no** |

`DERIVED` and `COMPOSED` are in the vocabulary because the architecture supports
them. v1 emits neither, because v1 derives no markets. A field that claimed a
provenance we had not performed would be worse than no field.

**We never fabricate a market.** If it is in the response, a provider payload
contained it. `RAW` is how we say "this exists and we could not canonicalise it"
instead of quietly dropping it or guessing a name.

## Quality

```json
{ "quality": { "status": "ok", "publishable": true, "reasons": [] } }
```

`publishable: false` means we do not vouch for that selection — the shape was
ambiguous, a line could not be read, or a qualifier did not resolve.
`quality.reasons` says which. We return it rather than hiding it, because a
missing selection looks like a market that does not exist.

If you display prices to end users, filter on `origin_type === 'MAPPED'` and
`quality.publishable === true`.

## Odds

```json
{ "odds": { "fractional": "11/10", "decimal": 2.1 } }
```

Both forms come from the provider payload. `decimal` is not recomputed from
`fractional` in a way that could round differently from the upstream value.
`odds_raw` is immutable through the whole pipeline: pricing policy is applied
above the data, never inside it.

## Freshness

```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` — when the payload was retrieved from your provider.
  **Moves only after a real retrieval.**
- `processed_at` — when the engine produced the dataset.
- `published_at` — when it became readable here.
- `age_ms` — derived from `provider_fetched_at`.

Nothing in the system touches these to make data look fresher. A re-publication
with no new retrieval leaves `provider_fetched_at` where it was. If your data is
stale, the timestamps say so and you can act on it.

## Missing is not zero

Across the whole product, three states are kept distinct and never merged:

| State | Meaning |
|---|---|
| absent | the provider did not send the field |
| `null` | the provider sent the field with no value |
| `0` | the provider sent a real zero |

A counter that reads `0` means zero happened. A counter that is absent means we
do not know. Collapsing the two would turn "no information" into "nothing
happened", which is the kind of error that is invisible until it matters.

## Changes

```json
{
  "seq": 418,
  "type": "fixture_markets_updated",
  "fixture_id": "fx_0011bd8f26e2625aae7576f3",
  "published_at": "2026-10-08T08:22:11.004Z",
  "cycle_id": "cyc-2026100808"
}
```

| `type` | Meaning |
|---|---|
| `fixture_markets_updated` | The engine refreshed this fixture's markets in this cycle. |
| `fixture_removed` | The fixture left the active surface. |

`fixture_markets_updated` means **refreshed**, not **changed**. It does not claim
a price moved. Asserting that would require diffing markets, which this feed does
not do — so it does not say it. Re-read the fixture to see what it holds now.

`seq` is monotonic per account. The window is bounded; a cursor older than
retention returns `INVALID_CURSOR` rather than a page with a silent gap.

## Football only

The product processes association football. Other sports are not fetched, not
processed and not served. eSoccer is filtered at the entry to the live pipeline:
its clock is accelerated and its counters behave differently enough that mixing
it in would corrupt every comparison.

## What is frozen in v1

These will not change without a new API version:

```
identifier shapes and stability     error codes
envelope shape                      pagination and cursor semantics
freshness field names and meaning   provenance vocabulary
change event types and sequencing   product and feature names
```

Descriptions, messages and added optional fields may change within v1. An
integration that tolerates unknown fields will not notice.

## Schema at runtime

`GET /api/v1/schema` returns the live vocabularies — products, features,
statuses, capabilities, provenance types, change event types, error codes and the
pagination limits actually enforced. It is generated from the same source as the
server's own behaviour, so it cannot drift from it.

See also: [Public API](public-api-v1.md) ·
[Live Intelligence](live-intelligence.md) · [Provider setup](provider-betsapi.md)
