---
id: example-javascript
title: Sport Line API — пример на JavaScript
description: Пример подключения к Sport Line API из Node.js с использованием встроенной функции fetch.
version: v1
language: ru
---

# Sport Line API — пример на JavaScript


## Требования

Пример рассчитан на Node.js 18 или новее, где функция `fetch()` доступна без установки
дополнительной библиотеки.

Проверить версию:

```bash
node --version
```

## Где выполнять запросы

Пример предназначен для backend-приложения на Node.js. Не помещайте ключ Sport Line API
в JavaScript, который выполняется в браузере: пользователь сайта сможет увидеть его в
исходном коде и сетевых запросах.

Если данные нужны браузерному интерфейсу, браузер обращается к backend клиента, а backend
выполняет запрос к Sport Line API.

## Переменные окружения

Код использует две переменные:

| Переменная | Значение |
|---|---|
| `SPORTAPI_BASE_URL` | Базовый URL, полученный от менеджера SportAPI |
| `SPORTAPI_PACKAGE_KEY` | Персональный API-ключ |

Не записывайте рабочий ключ непосредственно в исходный код и не добавляйте файл с
секретами в публичный репозиторий.

## Минимальный запрос

```js
const baseUrl = process.env.SPORTAPI_BASE_URL;
const apiKey = process.env.SPORTAPI_PACKAGE_KEY;

const response = await fetch(`${baseUrl}/v1/menu/live/ru`, {
  headers: {
    Package: apiKey,
    Accept: 'application/json'
  }
});

const payload = await response.json();
console.log(payload);
```

API-ключ передаётся в HTTP-заголовке `Package`, а не в URL.

## Общая функция запроса

Следующая функция:

- добавляет заголовок `Package`;
- проверяет HTTP-статус;
- разбирает JSON;
- распознаёт `error_code` и `error_message`;
- проверяет общую оболочку `status`, `page`, `body`.

```js
const rawBaseUrl = process.env.SPORTAPI_BASE_URL;
const apiKey = process.env.SPORTAPI_PACKAGE_KEY;

if (!rawBaseUrl) {
  throw new Error('SPORTAPI_BASE_URL is not configured');
}

if (!apiKey) {
  throw new Error('SPORTAPI_PACKAGE_KEY is not configured');
}

const baseUrl = rawBaseUrl.replace(/\/$/, '');

async function sportApiGet(path) {
  const response = await fetch(`${baseUrl}${path}`, {
    method: 'GET',
    headers: {
      Package: apiKey,
      Accept: 'application/json'
    }
  });

  const responseText = await response.text();
  let payload;

  try {
    payload = JSON.parse(responseText);
  } catch {
    throw new Error(
      `Sport Line API returned invalid JSON. HTTP ${response.status}`
    );
  }

  if (
    payload &&
    typeof payload === 'object' &&
    !Array.isArray(payload) &&
    payload.error_code !== undefined
  ) {
    const error = new Error(
      `Sport Line API error ${payload.error_code}: ${payload.error_message}`
    );
    error.code = payload.error_code;
    error.payload = payload;
    throw error;
  }

  if (!response.ok) {
    const error = new Error(`Sport Line API returned HTTP ${response.status}`);
    error.httpStatus = response.status;
    error.payload = payload;
    throw error;
  }

  if (
    !payload ||
    typeof payload !== 'object' ||
    Array.isArray(payload) ||
    payload.status === undefined ||
    typeof payload.page !== 'string' ||
    !('body' in payload)
  ) {
    const error = new Error('Unknown Sport Line API response format');
    error.payload = payload;
    throw error;
  }

  return payload;
}
```

`fetch()` сам по себе не выбрасывает исключение при HTTP-ответах `4xx` и `5xx`, поэтому
`response.ok` проверяется отдельно. При этом ошибку API нужно искать и в JSON, а не
определять только по HTTP-статусу.

## Классификация `body`

В обычных методах `body` содержит массив или объект данных. В методе `event` там также
может находиться служебное сообщение.

```js
function classifyBody(payload) {
  const { body } = payload;

  if (
    body &&
    typeof body === 'object' &&
    !Array.isArray(body) &&
    typeof body.message === 'string'
  ) {
    return {
      type: 'message',
      message: body.message
    };
  }

  if (Array.isArray(body) && body.length === 0) {
    return {
      type: 'empty',
      data: []
    };
  }

  return {
    type: 'data',
    data: body
  };
}
```

Возможные результаты:

| `type` | Значение |
|---|---|
| `data` | API вернул данные метода |
| `empty` | В текущей выборке нет данных |
| `message` | Метод `event` вернул `Game not found` или `Game id finished` |

## Выбор актуальной ветки из `menu`

Следующая функция выбирает первый вид спорта, страну и турнир, присутствующие в текущем
меню. В реальном интерфейсе соответствующую ветку выбирает пользователь.

```js
function findFirstTournament(menuBody) {
  for (const sport of menuBody) {
    for (const country of sport.sub ?? []) {
      for (const tournament of country.sub ?? []) {
        return {
          sportId: sport.id,
          sportName: sport.name,
          countryId: country.id,
          countryName: country.name,
          tournamentId: tournament.id,
          tournamentName: tournament.name
        };
      }
    }
  }

  return null;
}
```

ID не записываются статически: функция получает их из актуального ответа `menu`.

## Выбор матча из ответа `events`

Метод `events` группирует матчи по турнирам. Чтобы получить первый доступный матч,
нужно пройти через `events_list`:

```js
function findFirstGame(eventsBody) {
  for (const tournament of eventsBody) {
    const game = tournament.events_list?.[0];

    if (game) {
      return game;
    }
  }

  return null;
}
```

## Полный пример `menu → events → event`

Сохраните следующий код в файле `sportapi-example.mjs`:

```js
const rawBaseUrl = process.env.SPORTAPI_BASE_URL;
const apiKey = process.env.SPORTAPI_PACKAGE_KEY;

if (!rawBaseUrl) {
  throw new Error('SPORTAPI_BASE_URL is not configured');
}

if (!apiKey) {
  throw new Error('SPORTAPI_PACKAGE_KEY is not configured');
}

const baseUrl = rawBaseUrl.replace(/\/$/, '');
const lineType = 'live';
const language = 'ru';

async function sportApiGet(path) {
  const response = await fetch(`${baseUrl}${path}`, {
    method: 'GET',
    headers: {
      Package: apiKey,
      Accept: 'application/json'
    }
  });

  const responseText = await response.text();
  let payload;

  try {
    payload = JSON.parse(responseText);
  } catch {
    throw new Error(
      `Sport Line API returned invalid JSON. HTTP ${response.status}`
    );
  }

  if (
    payload &&
    typeof payload === 'object' &&
    !Array.isArray(payload) &&
    payload.error_code !== undefined
  ) {
    const error = new Error(
      `Sport Line API error ${payload.error_code}: ${payload.error_message}`
    );
    error.code = payload.error_code;
    error.payload = payload;
    throw error;
  }

  if (!response.ok) {
    const error = new Error(`Sport Line API returned HTTP ${response.status}`);
    error.httpStatus = response.status;
    error.payload = payload;
    throw error;
  }

  if (
    !payload ||
    typeof payload !== 'object' ||
    Array.isArray(payload) ||
    payload.status === undefined ||
    typeof payload.page !== 'string' ||
    !('body' in payload)
  ) {
    const error = new Error('Unknown Sport Line API response format');
    error.payload = payload;
    throw error;
  }

  return payload;
}

function classifyBody(payload) {
  const { body } = payload;

  if (
    body &&
    typeof body === 'object' &&
    !Array.isArray(body) &&
    typeof body.message === 'string'
  ) {
    return { type: 'message', message: body.message };
  }

  if (Array.isArray(body) && body.length === 0) {
    return { type: 'empty', data: [] };
  }

  return { type: 'data', data: body };
}

function findFirstTournament(menuBody) {
  for (const sport of menuBody) {
    for (const country of sport.sub ?? []) {
      for (const tournament of country.sub ?? []) {
        return {
          sportId: sport.id,
          sportName: sport.name,
          countryId: country.id,
          countryName: country.name,
          tournamentId: tournament.id,
          tournamentName: tournament.name
        };
      }
    }
  }

  return null;
}

function findFirstGame(eventsBody) {
  for (const tournament of eventsBody) {
    const game = tournament.events_list?.[0];

    if (game) {
      return game;
    }
  }

  return null;
}

async function main() {
  const menuPayload = await sportApiGet(
    `/v1/menu/${lineType}/${language}`
  );
  const menuResult = classifyBody(menuPayload);

  if (menuResult.type === 'empty') {
    console.log('No sections are currently available in the selected line.');
    return;
  }

  if (menuResult.type !== 'data' || !Array.isArray(menuResult.data)) {
    throw new Error('The menu method returned an unexpected body.');
  }

  const selection = findFirstTournament(menuResult.data);

  if (!selection) {
    console.log('No tournament is currently available.');
    return;
  }

  console.log('Selected current navigation branch:', selection);

  const eventsPayload = await sportApiGet(
    `/v1/events/${selection.sportId}/${selection.tournamentId}` +
      `/sub/50/${lineType}/${language}`
  );
  const eventsResult = classifyBody(eventsPayload);

  if (eventsResult.type === 'empty') {
    console.log('The selected tournament currently has no matches.');
    return;
  }

  if (eventsResult.type !== 'data' || !Array.isArray(eventsResult.data)) {
    throw new Error('The events method returned an unexpected body.');
  }

  const game = findFirstGame(eventsResult.data);

  if (!game) {
    console.log('No match is currently available.');
    return;
  }

  console.log('Selected current match:', {
    gameId: game.game_id,
    firstOpponent: game.opp_1_name,
    secondOpponent: game.opp_2_name
  });

  const eventPayload = await sportApiGet(
    `/v1/event/${game.game_id}/group/${lineType}/${language}`
  );
  const eventResult = classifyBody(eventPayload);

  if (eventResult.type === 'message') {
    console.log(`The match is unavailable: ${eventResult.message}`);
    return;
  }

  if (eventResult.type !== 'data' || Array.isArray(eventResult.data)) {
    throw new Error('The event method returned an unexpected body.');
  }

  console.log('Detailed match:', {
    gameId: eventResult.data.game_id,
    firstOpponent: eventResult.data.opp_1_name,
    secondOpponent: eventResult.data.opp_2_name,
    outcomes: eventResult.data.game_oc_counter,
    subgames: eventResult.data.sub_games?.length ?? 0
  });
}

main().catch((error) => {
  console.error(error.message);

  if (error.code !== undefined) {
    console.error('SportAPI error code:', error.code);
  }

  if (error.httpStatus !== undefined) {
    console.error('HTTP status:', error.httpStatus);
  }

  process.exitCode = 1;
});
```

Запуск:

```bash
SPORTAPI_BASE_URL='https://YOUR_API_DOMAIN' \
SPORTAPI_PACKAGE_KEY='YOUR_API_KEY' \
node sportapi-example.mjs
```

Не используйте эту форму с рабочим ключом в общей истории команд или в демонстрации
экрана. В рабочем окружении храните ключ в системе секретов проекта.

## Получение всех турниров выбранного спорта

Если конкретный турнир выбирать не нужно, передайте `tournamentId=0`:

```js
const eventsPayload = await sportApiGet(
  `/v1/events/${sportId}/0/sub/50/live/ru`
);
```

Значение `sportId` всё равно должно быть получено из актуального Live-меню.

## Запрос субматча

После получения подробного основного матча выберите элемент из `sub_games`:

```js
const match = eventResult.data;
const subgame = match.sub_games?.[0];

if (subgame?.game_id) {
  const subgamePayload = await sportApiGet(
    `/v1/event/${subgame.game_id}/group/${lineType}/${language}`
  );

  const subgameResult = classifyBody(subgamePayload);

  if (subgameResult.type === 'data') {
    console.log('Selected subgame:', subgameResult.data.game_dop_name);
  }
}
```

Коэффициенты ответа относятся только к выбранному субматчу.

## Поиск матчей

Пользовательский текст обязательно кодируется через `encodeURIComponent()`:

```js
const searchText = encodeURIComponent('Манчестер Сити');
const searchPayload = await sportApiGet(
  `/v1/search/line/ru/${searchText}`
);
const searchResult = classifyBody(searchPayload);
```

Не добавляйте необработанную пользовательскую строку непосредственно в URL.

## Необязательные дополнительные методы

Методы ниже не являются обязательной частью основной интеграции. Используйте их только
по необходимости, если соответствующая возможность нужна интерфейсу или логике проекта.

Основная рекомендуемая цепочка остаётся прежней:

```text
menu → events → event
```

Назначение дополнительных запросов:

- `sports`, `countries` и `tournaments` — пошаговая альтернатива `menu`;
- `topmatches` — готовая подборка топ-матчей по всем видам спорта;
- `toplist` — Prematch-подборка одного выбранного вида спорта;
- `cybersport=true` — отдельная выборка киберспортивных данных.

Не выполняйте все эти запросы автоматически только потому, что они существуют в API.

```js
// Виды спорта Live
const sports = await sportApiGet('/v1/sports/live/ru');

// Страны актуального вида спорта
const countries = await sportApiGet(
  `/v1/countries/${sportId}/live/ru`
);

// Турниры актуальных спорта и страны
const tournaments = await sportApiGet(
  `/v1/tournaments/${sportId}/${countryId}/live/ru`
);

// Топ-матчи Live с расширенными карточками
const topmatches = await sportApiGet(
  '/v1/topmatches/live/ru?full=true'
);

// Топ-матчи выбранного спорта, только Prematch
const toplist = await sportApiGet(
  `/v1/toplist/${prematchSportId}/ru?full=true`
);

// Киберспортивное Live-меню
const cybersportMenu = await sportApiGet(
  '/v1/menu/live/ru?cybersport=true'
);
```

Переменные `sportId`, `countryId` и `prematchSportId` в этих фрагментах должны быть
получены из актуальных ответов соответствующего типа линии.

## Обработка `Game id finished`

```js
const payload = await sportApiGet(
  `/v1/event/${gameId}/group/live/ru`
);
const result = classifyBody(payload);

if (result.type === 'message') {
  if (result.message === 'Game id finished') {
    console.log('Stop updating this game_id and refresh the Live events list.');
  } else {
    console.log('SportAPI event message:', result.message);
  }
}
```

`Game id finished` не позволяет определить, завершился матч, был отменён или перешёл из
Prematch в Live. После сообщения прекратите запросы по старому ID и обновите `events`.

## Пустой список — не ошибка

```js
const payload = await sportApiGet(
  '/v1/events/SPORT_ID_FROM_MENU/0/sub/50/live/ru'
);
const result = classifyBody(payload);

if (result.type === 'empty') {
  console.log('There are currently no matches in this selection.');
}
```

Не заменяйте пустой актуальный ответ ранее сохранёнными матчами.

## Что логировать при ошибке

Можно записывать:

- время запроса;
- путь метода без ключа;
- HTTP-статус;
- `error_code` и `error_message`;
- `body.message`;
- используемые ID и тип линии.

Не записывайте значение заголовка `Package` в логи.

## Что не делает пример

Код не является готовым SDK и не реализует:

- периодическое обновление;
- автоматические повторные запросы;
- кэш приложения;
- типы TypeScript;
- пользовательский интерфейс;
- систему приёма и расчёта ставок.

Рекомендуемая частота запросов описана отдельно:
[«Рекомендации по обновлению данных»](../getting-started/update-guidelines.md).

## Связанные документы

- [Примеры cURL](curl.md)
- [Быстрый старт](../getting-started/quick-start.md)
- [Общий формат ответа](../data-models/response-format.md)
- [Обработка ошибок](../getting-started/error-handling.md)
- [Методы Sport Line API](../api-reference/README.md)
