# Tantu TV Fixtures

[API reference](./) · [OpenAPI](openapi.yaml) · [Plain Markdown](guide.md)

## What you get

An API for fixtures, kickoff times, competitions and TV channels across every available sport.
There is no viewing app, video or right to rebroadcast. The backoffice manages keys and billing.
The `api` plan has a seven-day free trial with no payment details. Prices are sent by invoice or shown by the reselling partner.
It includes every language available, a rolling window from yesterday through ten days ahead,
60 requests per minute, no streams, and up to three live keys. Check `/tv-fixtures/v1/plans`
for the current scope and `/tv-fixtures/v1/status` for data freshness before importing.
Availability of a channel listing does not guarantee reception in your region.

The primary API origin is `https://dev.feed.sportlive.hr`, with product prefix `/tv-fixtures/v1`.
The old `https://dev.feed.sportlive.hr/tv-fixtures/v1` is a deprecated alias, served without redirects.
On a partner account use the API origin returned with your credentials.

```bash
BASE=https://dev.feed.sportlive.hr
curl "$BASE/tv-fixtures/v1/plans"
curl "$BASE/tv-fixtures/v1/status"
```

## Get a key

You can [sign up on the offer page](https://store.dev.tantu.stream/o/tv-fixtures/) or use HTTP on the
creator's store. For the following two calls set `BASE=https://store.dev.tantu.stream`.
First find the offer whose `slug` is `tv-fixtures`. Read its `terms.url`, price, plan,
trial length and availability. Set `TERMS_VERSION` to its exact `terms.version`;
only accept terms you have read. Replace the example email with your own.

<!-- check: offers signup -->
```bash
BASE=https://store.dev.tantu.stream
curl "$BASE/api/v1/offers"
curl "$BASE/api/v1/offers/tv-fixtures/signups" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: tv-fixtures-signup-20261021-01" \
  -d '{"email":"buyer@example.com","termsAccepted":true,"termsVersion":"'$TERMS_VERSION'"}'
```

Keep the same `Idempotency-Key` and body when retrying this signup. Use a fresh key for a
new signup. Do not fill the optional `website` field. When `next.type` is `check_inbox`,
confirm your email first and follow the returned next step. A `checkout` next step needs
payment in the browser; `thanks` has no further action. Do not assume every offer starts a trial.

For `next.type=welcome`, request the returned `next.url` with `Accept: application/json`.
The following URL illustrates its shape: set `HANDOFF` to the last segment of that URL,
and keep its exact host, slug and path. A handoff expires after 30 minutes.
The JSON answer has `welcome.state` and `welcome.revealUrl`. POST to that exact reveal URL
with the handoff in the body. The example shows the same creator store host.

<!-- check: handoff reveal -->
```bash
curl "$BASE/o/tv-fixtures/start/$HANDOFF" -H "Accept: application/json"
curl "$BASE/o/tv-fixtures/welcome/reveal" \
  -H "Content-Type: application/json" -H "Accept: application/json" \
  -d '{"handoff":"'$HANDOFF'"}'
```

Save `login`, `password`, `apiKey`, `apiBaseUrl` and `docsUrl` securely: credentials are revealed
once. Do not log this response or put the key in source control. A consumed handoff cannot
reveal credentials again. Partner storefronts use the browser handoff; credentials are
revealed on the creator's page, never through a partner frontend.

## First call

Set `BASE` to the origin of the returned `apiBaseUrl` (normally `https://dev.feed.sportlive.hr`),
removing a trailing `/tv-fixtures` product path if present. Set `API_KEY` to the revealed
`apiKey` in your local environment. The examples below append the product path themselves.

<!-- check: me -->
```bash
BASE=https://dev.feed.sportlive.hr
curl "$BASE/tv-fixtures/v1/me" -H "X-API-Key: $API_KEY"
```

Then read one fixture:

<!-- check: fixtures -->
```bash
curl "$BASE/tv-fixtures/v1/fixtures?limit=1" -H "X-API-Key: $API_KEY"
```

The answer includes `tenant`, `product`, `plan`, `status`, `trialEndsAt`, `payUrl`, `limits`
and `scope`. Use the returned limits rather than assuming the plan will never change.
The keyless `/tv-fixtures/v1` index links to this guide, OpenAPI, offer and status.

## Fixtures

```bash
curl "$BASE/tv-fixtures/v1/fixtures?limit=100&sport=Soccer&lang=de" \
  -H "X-API-Key: $API_KEY"
```

`fixtures` is an array; `nextCursor` is a string or `null` on the last page. Fixtures are
ordered by kickoff then stable `id`. Pass the previous `nextCursor` as `cursor`, URL-encoded,
with identical filters. Page size is 1..2000 (default 500). Treat the cursor as opaque.
`from` and `to` are inclusive kickoff instants; use ISO-8601 UTC values ending in `Z`.
`updatedSince` is inclusive and refers to `updatedAt`, not kickoff. `sport` is the original
sport label, case-insensitive, and does not follow `lang`.

`lang` selects the single names: requested language, else English, else German, else any
known language. `homeNames`, `awayNames`, `sportNames`, `competition.names` and channel
`names` retain every known translation. Never join by a translated name.

For incremental sync, fix `from` and `to` for an entire paginated pass, keep the prior
watermark unchanged while paging, and upsert each fixture by `id`. Only after all pages
succeed persist the largest `updatedAt` seen. Begin the next pass with that inclusive
watermark, so repeats at the boundary are expected. On failure retry the same pass;
never advance the saved watermark after a partial import. Delete locally on `status=removed`.
Removed fixtures remain available for 24 hours. If offline longer, rebuild the rolling
window; expire local fixtures outside it, including when the window advances.

```bash
curl "$BASE/tv-fixtures/v1/fixtures?updatedSince=2026-10-21T00:00:00Z&limit=100" \
  -H "X-API-Key: $API_KEY"
```

This Python standard-library example replaces the separate demo client. Set `FROM` and `TO`
to a fixed window inside the current plan scope, and optional `UPDATED_SINCE` to your last
completed watermark. It prints pages; replace printing with durable upserts before saving
the watermark. URL encoding also handles opaque cursors safely.

```python
import json
import os
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from datetime import datetime

base = os.environ['BASE'].rstrip('/')
params = {'from': os.environ['FROM'], 'to': os.environ['TO'], 'limit': 500, 'lang': 'en'}
watermark = os.environ.get('UPDATED_SINCE')
if watermark:
    params['updatedSince'] = watermark
while True:
    url = base + '/tv-fixtures/v1/fixtures?' + urlencode(params)
    request = Request(url, headers={'X-API-Key': os.environ['API_KEY']})
    with urlopen(request, timeout=30) as response:
        page = json.load(response)
    for fixture in page['fixtures']:
        print(fixture)
        # Compare parsed instants if your stored watermark uses a different precision.
        stamp = fixture['updatedAt']
        if watermark is None or datetime.fromisoformat(stamp) > datetime.fromisoformat(watermark):
            watermark = stamp
    if page['nextCursor'] is None:
        break
    params['cursor'] = page['nextCursor']
print('Completed watermark:', watermark)
```

## Channels

```bash
curl "$BASE/tv-fixtures/v1/channels?lang=en" -H "X-API-Key: $API_KEY"
```

`channels` contains channel names, language maps, regions, types, satellite positions and
receiver references where known. A nullable receiver field is unknown, not an empty string.
Use each fixture's `channels` for its actual listings; the directory is not a fixture filter.

## Ids

Ids are reference data for joining your schedule. A missing id never removes a fixture.
Every fixture has an `id`: the Betradar match id (`sr:match:61301157`) when it is known, else
our own id (`tvf:fixture:1042`). Both stay the same for that fixture; use it as your local key.
Never infer an id from a name or fabricate one.

Every other id is in `ids`, one map per subject: the match, the home team, the away team and the
competition. Each map is `{system: id}`. A system is in a map only when the id is known; it is
never `null`. The keyless `GET /tv-fixtures/v1/id-systems` lists the systems served now, what they
have ids of, their format, an example and the sports that have ids in them.

```json
{"ids": {
  "match":       {"betradar": "sr:match:61301157", "soccerdonna": "168515"},
  "home":        {"betradar": "sr:competitor:42",  "soccerdonna": "2061"},
  "away":        {"betradar": "sr:competitor:30",  "soccerdonna": "1150"},
  "competition": {"betradar": "sr:tournament:17",  "soccerdonna": "WMQR"}
}}
```

| System | Format | Coverage |
| --- | --- | --- |
| `betradar` | `sr:match:<n>`, `sr:competitor:<n>`, `sr:tournament:<n>` | Match, team and competition ids where known across sports |
| `soccerdonna` | Bare: digits (match, team), a code (competition) | Some women's football matches, teams and competitions |

`GET /tv-fixtures/v1/id-systems` is the authority: a system it does not list is not served, and
other systems may appear there later. Do not assume coverage for every fixture or subject.
An id can arrive after the fixture: the fixture's `updatedAt` moves, so an `updatedSince` poll
gets it. An id can also be corrected or removed the same way. These labels do not grant rights
to third-party data.

```bash
curl "$BASE/tv-fixtures/v1/id-systems"
curl "$BASE/tv-fixtures/v1/fixtures/by-id/betradar/sr:match:61301157" -H "X-API-Key: $API_KEY"
curl "$BASE/tv-fixtures/v1/fixtures?system=betradar&team=sr:competitor:42" -H "X-API-Key: $API_KEY"
```

`by-id` answers one fixture, or 404 `fixture_not_found`. `system` with `team` (home or away)
and/or `competition` keeps the fixtures of that team or competition. To look up many at once,
`POST /tv-fixtures/v1/fixtures/lookup` takes up to 500 `{system, id}` pairs; the answer's
`fixtures` is aligned with them, `null` where no fixture has the id:

```bash
curl "$BASE/tv-fixtures/v1/fixtures/lookup" -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"ids":[{"system":"betradar","id":"sr:match:61301157"}]}'
```

## Limits and Retry-After

The plan permits 60 requests/minute shared by your tenant's keys, zero streams and three live keys.
On HTTP 429 read `Retry-After` (seconds) and `retryAfterSeconds`, pause at least that long,
then retry with backoff and jitter. A new key does not reset the quota. Bound retries for
503 responses, respecting `Retry-After` when supplied. There is no streaming endpoint.

## Keys over HTTP

Use the API origin with `/account/v1`; this path has no product prefix. Lists contain metadata
and `current`, never plaintext. Create and rotate return `plaintext` once; securely replace
your application's key before discarding that response. Retrying with the same idempotency key
within 24 hours returns the same result. Use a different idempotency key for a different action.
Set `KEY_ID` to an `id` from the key list. The delete example is for an unused key, never your last key.

<!-- check: keys rotate -->
```bash
curl "$BASE/account/v1/keys" -H "X-API-Key: $API_KEY"
curl -X POST "$BASE/account/v1/keys/$KEY_ID/rotate" \
  -H "X-API-Key: $API_KEY" -H "Idempotency-Key: rotate-key-20261021-01"
```

```bash
curl -X POST "$BASE/account/v1/keys" \
  -H "X-API-Key: $API_KEY" -H "Idempotency-Key: create-key-20261021-01"
curl -X DELETE "$BASE/account/v1/keys/$KEY_ID" -H "X-API-Key: $API_KEY"
```

Rotation leaves the old key valid for 24 hours (`expiresAt`), then it stops working.
An expiring rotated key does not count against the three live keys. Revocation takes effect
on the next request. The last live key cannot be revoked. Account home provides the same controls.

## Errors

Product and account errors use `{error, code, message, next}`; `error` equals `code`.
`next` is an array of actions with an optional `href`. Follow a supplied URL only after
checking its destination. Product parameter errors also name `field` where applicable.
For example, `limit=0` is outside 1..2000 and answers 400 `invalid_parameter` with `field: limit`:

<!-- check: bad_parameter -->
```bash
curl "$BASE/tv-fixtures/v1/fixtures?limit=0" -H "X-API-Key: $API_KEY"
```

| `code` | Meaning | Fix | `next` action |
| --- | --- | --- | --- |
| `invalid_key` | Missing, expired or invalid key | Supply a valid key | `get_key` |
| `invalid_parameter` | Invalid query value or lookup body (`field`, e.g. `system`, `ids[3].system`) | Fix `field` and retry | Inspect `next` |
| `fixture_not_found` | No fixture served has this id in this system | Check the system and the subject: a team's id is not a match's | None |
| `invalid_cursor` | Cursor cannot be read | Restart pagination with identical filters | Inspect `next` |
| `range_too_large` | Kickoff range exceeds scope | Narrow `from` and `to` | Inspect `next` |
| `payment_required` | Access needs payment | Open `payUrl` or the provided link | `pay` |
| `suspended` | Account suspended | Contact support | `contact_support` |
| `not_entitled` | Product not on this account | Contact support | `contact_support` |
| `rate_limited` | Request quota reached | Wait for `Retry-After` | `retry_after` |
| `unavailable` | Service temporarily unavailable | Retry with bounded backoff | `retry_after` |
| `not_a_tenant` | Calling identity is not a tenant | Use a tenant key | None |
| `permission_required` | Key action not permitted | Ask the account owner for permission | `contact_support` |
| `tenant_inactive` | Account key changes disabled | Contact support | `contact_support` |
| `key_not_found` | No such key on your account | Refresh key metadata | `list_keys` |
| `key_limit` | Maximum live keys reached | Revoke an unused key | `revoke_key` |
| `last_key` | Cannot revoke the last live key | Create a replacement first | `create_key` |
| `key_rotating` | Key already has a replacement | Use its replacement or create another | `list_keys` |
| `key_revoked` | Key already revoked | Create a replacement | `create_key` |
| `idempotency_key_required` | Missing or invalid idempotency key | Send a fresh printable key | `send_idempotency_key` |
| `idempotency_key_reused` | Key used for a different request | Use a new key | `use_new_idempotency_key` |
| `idempotency_in_progress` | The first signup with this key still runs | Retry after `Retry-After` seconds | Inspect response |
| `not_found` | Offer or handoff not found | Check the returned URL and offer slug | Inspect response |
| `validation_failed` | Signup fields invalid | Fix all reported `fields` | Inspect response |
| `full` | Trial capacity reached | Try later or contact support | Inspect response |
| `origin_not_allowed` | Browser origin refused | Use the creator storefront | Inspect response |

Storefront errors currently use `{error, fields, retryAt}` rather than the product error body.
For signup 429, use `Retry-After` or `retryAt`. A reused signup idempotency key with a changed
body returns 409; a spent reveal cannot disclose the credentials again. Do not retry a
consumed reveal in a loop. Contact the offer's support address to recover account access.

## Trial, payment and pause

The seven-day trial starts on the server without payment details. We request payment before it ends, by invoice
or card through `payUrl`. Starting the trial does not authorize a charge; no automatic charge is made without
your action and consent. Prices are shown or sent with your invoice or by the reselling partner.
If unpaid at trial end, access is suspended. While paused,
requests return 402 with `next: [{action: "pay", href: "…"}]`; payment restores access.
A complimentary grant needs no payment while active and keeps the same plan limits.
It may end on a stated date; after that, normal trial/payment rules apply again.
An unlimited grant has no trial end. Account home shows billing, grants, limits and keys;
there are no other product screens. Support is listed on the offer and in your welcome message.

## Network

If your code runs in a hosted sandbox with an outbound allowlist, allow `api.example.test`,
`dev.feed.sportlive.hr`, `store.dev.tantu.stream` and `docs.dev.tantu.stream`.
Keep HTTPS certificate validation enabled. The primary API is served directly over HTTPS.

## Changelog

- 2026-10-21: First complete HTTP setup guide, account key operations and shared API origin.
- 0.3: one `ids` map per match, team and competition replaces the `betradar`, `transfermarkt`
  and `soccerdonna` blocks; our own `tvf:fixture:<n>` id when no Betradar match id is known;
  `by-id`, `lookup`, team/competition filters and the keyless `id-systems`.
