Разделы документации

API v1 · Практика

Интеграция, которой можно доверять

Как читать данные полностью, сохранять область магазина и точки, учитывать изменения и восстанавливаться после временных ошибок.

Три практических сценария

1. Каталог для вашего сайта

Создайте серверный ключ catalog:read + stock:read. Получите /locations, затем все страницы /products и /recipes. Остатки запрашивайте через /stock отдельно по каждой нужной точке и связывайте с товарами по productId. На сайте публикуйте только выбранные владельцем карточки. Доступность готовых букетов и расчёт сборки требуют специализированного каталога интеграции: /recipes их не вычисляет.

2. Учёт заказов и внешняя аналитика

С ключом orders:read загружайте /orders и сохраняйте записи по id. isPosSale отделяет мгновенную кассовую продажу от заказа на исполнение. Берите totalAmount как итог, paidAmount как записанные оплаты; не восстанавливайте сумму заказа только сложением строк. Эта сводка не заменяет финансовый журнал, расчёт возвратов или проверку фискальных документов.

3. Сверка остатков по сети

Для каждого ID из /locations загрузите все страницы /stock. Сохраняйте область «магазин + точка + inventoryItemId», а связь с каталогом — отдельно по productId/variantId. Не объединяйте разные точки под одним числом наличия для конкретного салона.

Полная загрузка и пагинация

  1. Зафиксируйте время начала загрузки. Для /products, /recipes, /stock и /orders начинайте с page=1 и limit=100.
  2. Обработайте items текущей страницы, сохраняя объекты по устойчивому ID. Для /stock дополнительно сохраняйте locationId.
  3. Если pagination.hasMore=true, запросите следующую страницу. Не считайте первые 50 или 100 строк полным каталогом.
  4. Отметьте загрузку завершённой только после всех страниц. При ошибке сохраните предыдущую контрольную отметку и повторите безопасную загрузку.

Пределы: page 1–10000, limit 1–100; по умолчанию 50 строк. Списки сортируются по ID и не образуют неизменяемый снимок: данные могут измениться между страницами. Повторная обработка одного ID должна обновлять запись, а не создавать дубль.

Загрузка изменений

Передавайте updatedAfter в ISO 8601 с часовым поясом. Граница включена. Для следующего цикла используйте время НАЧАЛА предыдущего успешного цикла с небольшим перекрытием, например 1–2 минуты. Обновляйте записи по ID. Пауза и повтор одной границы не должны создавать дубли.

Фильтр изменений
GET /api/v1/orders?updatedAfter=2026-10-20T00%3A00%3A00%2B07%3A00&page=1&limit=100

Знак + в часовом поясе кодируйте как %2B; используйте URLSearchParams или urlencode. Поле updatedAfter относится к записи конкретного метода. Изменение связанного объекта не обещает обновление всех остальных ресурсов: каталог и склад синхронизируйте независимо.

Ленты удалений и отдельного журнала изменений в v1 нет. Для снятых с публикации и архивных объектов выполняйте периодическую полную сверку. Убирайте отсутствующие записи из локальной витрины только после завершённой полной загрузки в той же области магазина и точки.

Как правильно понимать остатки

physicalStock

10

Фактически на точке

reservedStock

3

Занято резервами

freeStock

7

Свободно: 10 − 3

Резерв не прибавляется к физическому наличию. Если physicalStock=2 и reservedStock=5, freeStock=−3. Это дефицит относительно резервов; API сохраняет отрицательное значение. Решение о том, что показывать покупателю, принимает ваша интеграция с учётом правил магазина.

При trackStock=false количество не определяет доступность услуги. inventoryItemId и productId — разные идентификаторы. /products с точкой отбирает товары по наличию учётной записи, а не гарантирует положительный свободный остаток.

Денежные поля — в основных единицах валюты из /me, например рублях, а не копейках. Даты передаются с часовым поясом; для отображения используйте timezone салона. Значения null обрабатывайте отдельно от 0 и пустой строки.

Ошибки и лимиты

Лимит внешнего контракта — 60 запросов в минуту на ключ. Общие защитные ограничения платформы тоже могут применяться. Собирайте запросы в одну очередь на вашем сервере; не запускайте одинаковый опрос из каждой вкладки.

HTTPКодДействие
400LOCATION_REQUIRED, VALIDATION_ERRORДля остатков нужна точка. Проверьте UUID, диапазоны page/limit и ISO 8601; не повторяйте без исправления.
401API_KEY_INVALIDПроверьте ключ, отзыв, срок и активность выдавшего владельца. Повтор с тем же ключом проблему не устранит.
403API_PLAN_REQUIREDВладелец должен продлить Энтерпрайз или выбрать его снова. Не запускайте цикл повторов.
403API_SCOPE_REQUIREDСоздайте отдельный ключ с нужным разрешением.
403API_LOCATION_FORBIDDENКлюч закреплён за другой точкой. Используйте его точку либо другой ключ.
404LOCATION_UNAVAILABLE, ORDER_NOT_FOUNDОбъект не найден в области ключа. Это также может быть архив или изменение доступности.
429API_RATE_LIMIT, RATE_LIMIT_EXCEEDEDСоблюдайте Retry-After, если он есть. Ограничьте общую очередь запросов на ключ.
500 / 503INTERNAL_ERROR, API_TEMPORARILY_UNAVAILABLEПовторите GET с увеличением паузы и ограничением попыток. Не отмечайте незавершённую загрузку успешной.

Поддерживайте обе формы тела ошибки: прикладную ошибку API и общую ошибку проверки запроса. Сначала учитывайте HTTP-статус, затем код; не привязывайте логику к русскому тексту.

{
  "error": {
    "code": "API_SCOPE_REQUIRED",
    "message": "У ключа нет разрешения на этот раздел"
  },
  "message": "У ключа нет разрешения на этот раздел"
}

Для поддержки сохраните время запроса с часовым поясом, метод, адрес без секретов, HTTP-статус и код ошибки. Заголовок Authorization и полный ключ не передавайте в логи или сообщения.

Пример клиента с повторами и обходом страниц

Готовые учебные файлы для Node.js 18+ и Python 3 со стандартной библиотекой. Они читают данные на вашем сервере, ограничивают число попыток, учитывают Retry-After и не повторяют 401/403. Ключ берётся из переменной окружения. По умолчанию выводятся товары, по одной JSON-записи на строку.

// Node.js 18+. Run only on your server, with BLOOMHUB_API_KEY in its environment.
import {pathToFileURL} from 'node:url';
const base = 'https://bloomhub.ru/api/v1';
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
function waitTime(header, attempt) {
  if (header) {
    const seconds = Number(header);
    const milliseconds = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(header) - Date.now();
    if (Number.isFinite(milliseconds)) return Math.max(1000, milliseconds);
  }
  return 500 * 2 ** attempt;
}
export async function apiGet(path, query = {}) {
  if (!['/me','/locations','/products','/recipes','/stock','/orders'].includes(path)) {
    throw new Error('This example supports only documented list methods.');
  }
  const key = process.env.BLOOMHUB_API_KEY;
  if (!key) throw new Error('Set BLOOMHUB_API_KEY on your server.');
  const url = new URL(base + path);
  for (const [name, value] of Object.entries(query)) {
    if (value !== undefined && value !== '') url.searchParams.set(name, String(value));
  }
  for (let attempt = 0; attempt < 5; attempt++) {
    let response;
    try {
      response = await fetch(url, {
        headers: {Authorization: `Bearer ${key}`, Accept: 'application/json'},
        signal: AbortSignal.timeout(15_000),
      });
    } catch (error) {
      if (attempt === 4) throw error;
      await pause(waitTime(null, attempt));
      continue;
    }
    if ((response.status === 429 || response.status >= 500) && attempt < 4) {
      await response.body?.cancel();
      await pause(waitTime(response.headers.get('Retry-After'), attempt));
      continue;
    }
    const body = await response.json();
    if (!response.ok) {
      const code = typeof body.error === 'object' ? body.error?.code : body.code;
      throw new Error(`BloomHub HTTP ${response.status}: ${code || 'REQUEST_FAILED'}`);
    }
    return body;
  }
  throw new Error('No response after retry limit.');
}
export async function* apiPages(path, query = {}) {
  if (!['/products','/recipes','/stock','/orders'].includes(path)) {
    throw new Error('Pagination applies to products, recipes, stock and orders.');
  }
  for (let page = 1; page <= 10000; page++) {
    const data = await apiGet(path, {...query, page, limit: 100});
    if (!Array.isArray(data.items) || typeof data.pagination?.hasMore !== 'boolean') {
      throw new Error('Unexpected response. Do not advance the synchronization watermark.');
    }
    yield* data.items;
    if (!data.pagination.hasMore) return;
  }
  throw new Error('Page limit reached. Do not mark a partial sync complete.');
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  const resource = '/' + (process.env.BLOOMHUB_RESOURCE || 'products').replace(/^\/+/, '');
  const query = {
    locationId: process.env.BLOOMHUB_LOCATION_ID,
    updatedAfter: process.env.BLOOMHUB_SINCE,
  };
  try {
    for await (const item of apiPages(resource, query)) console.log(JSON.stringify(item));
  } catch (error) {
    console.error(error.message);
    process.exitCode = 1;
  }
}

Дополнительные переменные: BLOOMHUB_RESOURCE — products, recipes, stock или orders; BLOOMHUB_LOCATION_ID — нужная точка; BLOOMHUB_SINCE — отметка updatedAfter. Файлы служат примером клиента, а сохранение в вашу базу и расписание синхронизации вы добавляете в своём приложении.

Уведомления и совместимость

Вебхуки — отдельный модуль BloomHub с выбором событий и журналом доставки; внешний ключ v1 не управляет ими. Нативная amoCRM подключается владельцем в своём кабинете. Партнёрские API площадок и административные методы не являются публичным контрактом v1.

Версия указана в пути /api/v1 и заголовке X-BloomHub-API-Version. Обрабатывайте незнакомые поля и строковые статусы без падения. Сохраняйте UUID как строки; короткий номер orderNumber предназначен для человека и может повториться со временем. Изменения контракта фиксируются в документации и журнале обновлений.

Сценарии интеграции — BloomHub API | BloomHub