Cursor-пагинация, polling vs webhook, audit-логи: три паттерна чтения из публичного API
TL;DR: «чтение из API» — это не одна задача, а три, и каждая требует своего паттерна. Пройти большую коллекцию — cursor, не offset; ловить события в реальном времени — webhook, не polling. Разобрать вчерашний инцидент — журнал запросов в кабинете, не тикет в поддержку. Ниже — почему у offset/polling/тикетов есть предел, где именно они ломаются и что стоит в BOTIX, с кодом на каждый паттерн.
Коротко
- Bulk read → cursor, не offset. Offset на миллионах записей даёт таймауты (БД проходит и отбрасывает миллион строк) и race condition при изменении коллекции во время обхода. Cursor — O(log N) на любой странице и предсказуемый снимок.
- Real-time → webhook, не polling. Polling прост, но платит лагом до периода опроса и пустыми запросами в rate-limit. Webhook даёт задержку ≈ сотни мс ценой публичного endpoint, HTTPS и проверки HMAC. В BOTIX webhook — основной паттерн, polling через cursor — fallback.
- Post-mortem → audit-лог в кабинете, не выгрузка из БД. Без публичного журнала запросов любая отладка идёт через тикет: диагностика растягивается с 15 секунд до полудня.
request_idиз лога интегратора находит запись в журнале за один взгляд.
Чтение из публичного API распадается на три задачи, и они требуют разных паттернов. Ошибка рынка в том, что «дай клиенту читать данные» проектируют как одну функцию — листалку списков, — а на проде выясняется, что задач три и они не сводятся друг к другу:
- Bulk read — пройти большую коллекцию от начала до конца. Например, синхронизировать локальную копию контактов в свою CRM или прогреть поисковый индекс.
- Real-time updates — получать новые события сразу, как только они произошли. Например, триггерить SMS в момент изменения статуса заказа или обновлять дашборд диалогов.
- Post-mortem investigation — разобрать инцидент постфактум. Например, понять, почему контакт не создался вчера в 14:37, когда в проде уже стоит очередь недовольных.
Разберём каждую задачу отдельно: суть, правильный паттерн, набор тупиков и цену ошибки. Смежные вещи — контракт ошибок, rate-limit-заголовки и версионирование — разбираем в соседних материалах: «Мелочи» API: контракт ошибок, cursor и rate-limit и 5 правил API-клиента, который не сломается в проде.
Тезис 1. Bulk read — cursor, не offset
В большинстве учебных материалов по проектированию API пагинация показана как ?limit=20&offset=100: «дай страницу, начиная с сотого элемента, размером 20». Это интуитивно, фреймворки поддерживают такой формат из коробки, ORM транслирует его в SQL одной строкой. На этом плюсы заканчиваются.
Первая проблема offset — производительность. Запрос
SELECT * FROM contacts
WHERE project_id = ?
ORDER BY created_at DESC
LIMIT 20 OFFSET 1000000;для базы означает «отсортируй миллион записей, отбрось их и верни следующие 20». Даже с правильным индексом по (project_id, created_at) БД вынуждена пройти миллион строк, чтобы их пропустить. На 10 миллионах записей offset физически перестаёт работать: запрос либо отваливается по таймауту, либо съедает память, либо блокирует читающие реплики.
Вторая проблема — race condition при изменении коллекции во время обхода. Клиент запросил первую страницу: записи 1–20. Между запросами в начало коллекции (сортировка created_at DESC) добавились 5 новых записей. Клиент запрашивает страницу 2 с offset=20 — сервер отдаёт то, что теперь занимает позиции 26–45. Записи 21–25 (бывшие 16–20, сдвинутые новыми) клиент получит дважды. Записи 26–30 (бывшие 21–25) он не получит вообще. На лентах, листингах товаров, журналах сообщений это известный артефакт offset, и от него нельзя избавиться, пока вы остаётесь в парадигме offset.
Cursor решает обе проблемы. Вместо «пропусти N записей» клиент говорит «дай записи после такого-то значения», где значение — непрозрачный курсор, выданный сервером в предыдущем ответе. Формат курсора — деталь реализации, клиент относится к нему как к чёрному ящику. Внутри обычно закодированная пара (значение_сортировки, идентификатор), например (created_at, id). Сервер декодирует курсор и строит запрос:
SELECT * FROM contacts
WHERE project_id = ?
AND (created_at, id) < (?, ?) -- из курсора
ORDER BY created_at DESC, id DESC
LIMIT 20;Здесь критичны две вещи. Индекс по (project_id, created_at, id) делает выборку O(log N) независимо от номера страницы — сервер не «пропускает» записи, он сразу прыгает в нужную точку индекса. Пара (created_at, id) гарантирует уникальность позиции, даже когда created_at не уникален: будь в курсоре только created_at, две записи с одинаковым временем создания могли бы попасть на стык двух страниц.
В BOTIX API v1.1.0 все list-эндпоинты перешли на cursor-based пагинацию. Фрагмент схемы из openapi.yaml:
# openapi.yaml — Public API BOTIX v1.1.0, фрагмент схемы listResponse
/public/v1/contacts:
get:
parameters:
- name: cursor
in: query
schema:
type: string
example: "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0yMlQxNDozNyIsImlkIjoxNTQyfQ=="
- name: cursor_limit
in: query
schema:
type: integer
minimum: 1
maximum: 200
default: 20
responses:
'200':
content:
application/json:
schema:
type: object
required: [data, has_more]
properties:
data:
type: array
items: { $ref: '#/components/schemas/Contact' }
next_cursor:
type: string
nullable: true
has_more:
type: booleanФормат ответа предсказуем: data, next_cursor и булев has_more. Клиент запрашивает следующую страницу так:
GET /public/v1/contacts?cursor=eyJjcmVhdGVkX2F0Ijo...&cursor_limit=50Полный обход коллекции — маленький цикл. Пример на официальном SDK:
# pip install botix==1.1.0
# Полный обход коллекции контактов через cursor.
from botix import Client
client = Client(api_key=XXXXXXXX['BOTIX_API_KEY']) # btx_live_XXXX… из окружения, не в коде
cursor = None
total = 0
while True:
page = client.contacts.list(cursor=cursor, cursor_limit=200)
for contact in page.data:
# Идемпотентная обработка — на случай повторного обхода.
process_contact(contact)
total += 1
if not page.has_more:
break
cursor = page.next_cursor
print(f"Processed {total} contacts")cursor_limit управляет размером страницы (по умолчанию 20, максимум 200). На практике 200 — хороший компромисс для batch, для UI-листингов — 20–50.
Почему race condition при cursor почти исчезает. Если коллекция отсортирована created_at DESC и в момент обхода добавляются новые записи (с более свежим created_at), у них меньший курсор-приоритет, чем у уже выданных, и в текущий обход они не попадают — клиент получает предсказуемый снимок коллекции на момент старта. Если записи удаляются — тоже не страшно: курсор указывает на «значение, после которого начинаем», и удаление записи на этой позиции не ломает следующий запрос, БД просто пропустит её.
Фильтры поверх cursor применяются до курсора. Если клиент меняет фильтры посреди обхода, курсор от предыдущей выборки невалиден — он относился к другому множеству. Сервер обязан это поймать и вернуть 400 INVALID_CURSOR, а не произвольный кусок отфильтрованной коллекции.
Минусы cursor перечислю честно:
- Нельзя «прыгнуть на страницу 47». В cursor-парадигме нет случайного доступа к произвольной странице, только последовательный проход. Для UI с номерами страниц это проблема — решение либо «бесконечная лента» (стандарт мобильного UX), либо offset для UI с пониманием его ограничений.
- Нельзя показать «всего 12 354 записи».
COUNT(*)на больших таблицах сам по себе тяжёлый, и при cursor его смысла нет. Нужен общий счётчик — держите его в отдельной материализованной таблице с инкрементами. - Реализация на сервере чуть сложнее — кодировать/декодировать base64 от JSON, валидировать, защищать от подмены HMAC-подписью курсора, чтобы клиент не мог манипулировать им и получить доступ к чужим данным.
Тем не менее на любых масштабах cursor выигрывает: на 50 записях оба подхода равны, на 10 миллионах offset уже не работает физически, а cursor продолжает. Делать сначала offset, потом мигрировать на cursor — это breaking change. В BOTIX было ровно так: версия 1.0.0 использовала offset, после первого же стресс-теста на проекте с миллионом контактов стало ясно, что таймауты неизбежны, и 1.1.0 перешла на cursor с явными cursor_limit и has_more. Миграция болезненна для интеграторов — детали в разборе Публичный API v1: breaking changes, cursor и SDK из OpenAPI и Миграция клиентов с v1 на v2 без downtime. Урок: если вы на старте проектирования публичного API — закладывайте cursor сразу, чтобы не платить за миграцию потом.
Тезис 2. Real-time updates — webhook, не polling
Когда интеграции нужно реагировать на события на стороне сервиса — сообщения, оплаты, обновления заказов, — есть две принципиально разные опции: самому периодически дёргать API («что нового?») или подписаться на webhook и получать события push'ем. Внешне разница небольшая, в проде — разная нагрузка, разная задержка и разные требования к своей инфраструктуре.
Polling — запрос «дай всё, что появилось после такой-то отметки». В простейшем виде GET с курсором, отвечающий списком новых событий; клиент запоминает позицию последнего обработанного, ждёт N секунд, повторяет. Достоинство — простота: polling работает без публичного endpoint, без HTTPS-сертификата, без обработки повторных доставок. Воркер polling — это маленький while-loop с requests.get и курсором, он запустится на localhost, в Docker, в Lambda без настройки сети. Недостаток — обратная сторона простоты: период опроса задаёт лаг. Опрос раз в секунду — лаг до 1 с, но 86 400 запросов в сутки на ключ. Раз в минуту — лаг до 60 с, для интерактивного бота уже неприемлемо. Удержать малый лаг и запас по rate-limit одновременно нельзя.
Webhook — HTTP-запрос, который платформа отправляет на endpoint клиента в момент события. Клиент регистрирует URL, выбирает типы событий, дальше платформа сама бьёт в этот URL. Задержка минимальная: сетевой round-trip плюс обработка на стороне платформы, обычно десятки–сотни миллисекунд, никаких пустых запросов. Цена — инфраструктура: публичный endpoint, HTTPS с валидным сертификатом, обработка повторных доставок (платформа имеет право ретраить при таймауте), проверка подписи на каждом запросе, ответ в пределах нескольких секунд.
sequenceDiagram
participant Source as Источник
participant BOTIX
participant Client as Сервер интегратора
Note over Source,Client: Polling — клиент дёргает каждые N сек
loop каждые 30 сек
Client->>BOTIX: GET /messages?cursor=...
BOTIX-->>Client: [] (пусто)
end
Source->>BOTIX: message.created
Client->>BOTIX: GET /messages?cursor=...
BOTIX-->>Client: [event]
Note over Client: лаг до 30 сек
Note over Source,Client: Webhook — платформа толкает сразу
Source->>BOTIX: message.created
BOTIX->>Client: POST /webhook (HMAC)
Client-->>BOTIX: 200 OK
Note over Client: лаг ≈ 100 мсКогда какой способ:
- Низкая нагрузка + инфраструктуры нет (только cron-job раз в час) — polling. Сходить раз в час, забрать накопленное, обработать, забыть.
- Высокая нагрузка + нужна низкая задержка (тысячи событий в час) — только webhook. Polling здесь требовал бы десятков запросов в секунду или постоянного long-polling, что по сложности уже сравнимо с webhook, а по эффективности хуже.
- Промежуточный режим (несколько событий в час, задержка до минуты допустима) — оба; решает готовность инфраструктуры клиента к webhook.
- Гибрид (зрелые интеграции): основной поток — webhook (задержка важна, закрывает 99% случаев), резервный — periodic reconciliation через cursor раз в час: клиент сверяет своё локальное состояние с платформой и добирает редкие потерянные webhook'и. Особенно полезно, когда последствия пропущенного события дорогие — например, не отправили триггерное сообщение по платёжному статусу.
Что в BOTIX. Webhook — основной паттерн доставки событий: message.created, chat.opened, contact.created, payment.confirmed и ещё около 15 типов. Получатель регистрируется через POST /public/v1/webhooks либо в кабинете на app.botix.pro/developers. Доставка идёт через очередь в БД (btx_api_webhook_deliveries) и cron-диспетчер раз в минуту; retry-расписание — 30 секунд / 5 минут / 30 минут / 2 часа / 6 часов, после 5 неудач попытка помечается failed и видна в delivery dashboard. Подпись передаётся в заголовке X-Botix-Signature как sha256=… от тела запроса на общем секрете подписки; рядом идут X-Botix-Event и X-Botix-Request-Id. Полную механику приёма разбираем в Архитектуре доставки webhook: подпись, retry, идемпотентность.
Polling доступен как fallback через cursor-пагинацию: GET /public/v1/chats, GET /public/v1/messages, GET /public/v1/contacts принимают cursor и возвращают next_cursor. Клиент сохраняет последний cursor, опрашивает периодически, идёт вперёд до пустого next_cursor. Это покрывает сценарии, где webhook поднять нельзя (локальная разработка без туннеля, jobs из CRM по своему расписанию). Но в чистом виде polling в BOTIX не рекомендуется как основной способ и явно помечен в документации developers.botix.pro/best-practices как fallback: при росте числа клиентов один опрашивающий воркер быстро упрётся в rate-limit (X-RateLimit-Limit per minute зависит от тарифа), а растягивать период опроса нельзя без потери качества.
Простой алгоритм на старте: есть стабильный публичный endpoint и нужна задержка < 5 с — webhook, без раздумий. Разработка на локальной машине, публичный endpoint поднять сложно — polling через cursor с периодом 30–60 с на время разработки, переход на webhook при выходе в прод. Запуск из cron раз в час — polling, webhook здесь бессмысленен. И отдельный пункт: никогда не сравнивайте polling с webhook по «удобству на старте», не замерив ожидаемый трафик и допустимую задержку. Polling кажется проще на MVP, но при масштабировании ломается первым.
Тезис 3. Post-mortem — audit-логи в кабинете, не выгрузка из БД
Рано или поздно случается простая сцена: команда, встроившая API в свой продукт, обнаруживает, что что-то пошло не так. Контакт не создался вчера в 14:37, заявка не привязалась, webhook не пришёл. В своих логах команда видит, что запрос ушёл. Дальше нужно понять — дошёл ли до API, что ответил сервер, какой была ошибка. Если у поставщика нет публичного журнала запросов, единственный путь — тикет в поддержку «у нас что-то не работает, посмотрите со своей стороны». Один такой тикет растягивает диагностику с пятнадцати секунд (взгляд в журнал) до полудня (тикет, переписка, эскалация, ответ), и всё это время в проде стоит боевая нагрузка. Журнал API-запросов в кабинете — не диагностическая функция поставщика, а обязательный инструмент самообслуживания интегратора.
Что должно быть в журнале. Минимальный набор полей, без которого журнал бесполезен:
timestamp_utc: 2026-05-22T14:37:08.421Z
http_method: POST
path: /public/v1/contacts
query: (без секретных значений)
status_code: 422
response_size: 178
latency_ms: 43
request_id: req_01HXY8Z2K5VQGN3PMAB7DEFGHJ
client_ip: 185.71.x.x
api_key_id: key_..a3f7 (last4 или хеш — НЕ сам ключ)
api_version: 1.1.0
idempotency_key: 3e6d7f... (если был задан)request_id — тот же, что возвращается клиенту в заголовке X-Request-Id. Этого набора хватает на 90% задач отладки: интегратор пишет «не создался контакт, request_id вот этот», поддержка одной командой находит запрос и одной строкой говорит, какой код ответа выдан и почему.
Чем это отличается от server-side логов. Серверный лог поставщика — лог инфраструктуры: запросы всех клиентов, внутренние вызовы между сервисами, периодические задачи; доступ к нему только у сотрудников поставщика, там пересекаются данные разных тенантов и метаданные внутренней архитектуры. Журнал в кабинете — это вид, отфильтрованный по project_id, с удалёнными чувствительными полями: тела запросов и ответов целиком обычно не хранятся, чтобы исключить утечку персональных данных через интерфейс. В BOTIX доступны фильтры по HTTP-методу, по статусу ответа (2xx / 4xx / 5xx по отдельности), по пути с подстановкой через звёздочку и поиск по request_id. Журнал лежит на app.botix.pro/developers в разделе владельца проекта; доступ — только владельцу, не агентам и не операторам, потому что метаданные чувствительны для безопасности. Retention — 7 дней горячего хранения; этого достаточно для отладки, но мало для compliance-аудита — об этой границе ниже.
Webhook delivery — отдельная вкладка. Когда endpoint не получает событий, причины разные: платформа не сгенерировала событие; сгенерировала, но не положила в очередь; положила, но получила timeout при отправке; получила 5xx и поставила в retry; получила 2xx, но интегратор перепутал тело и не обработал. Для исходящей попытки доставки в журнале входящих запросов места нет — поэтому webhook delivery вынесен в отдельный дашборд. Для каждой попытки видны: event-type, endpoint, HTTP-код ответа интегратора, длительность, тело ответа (первые 4 KB, чтобы прочитать сообщение об ошибке), номер попытки, время следующего retry. Плюс кнопки «Повторить отправку» (новый request_id) и «Перенаправить» на webhook.botix.pro. Интегратор открывает свой endpoint, видит, что вернул 500 на седьмое событие подряд, и понимает, что ошибка на его стороне — без этого дашборда тот же интегратор пишет в поддержку «вы не присылаете webhook'и», и цикл занимает день вместо минуты.
Retention и compliance. Под SOC 2, ISO 27001, 152-ФЗ, GDPR журнал на 7 дней не годится — энтерпрайз требует минимум 90 дней, иногда год. Причина не в том, что аудитор каждый день смотрит на старые запросы, а в том, что инцидент может всплыть через два месяца, и тогда журнал должен быть на месте. Тема упирается в стоимость хранения: журнал на 7 дней при 10 RPS на проект — это 5–6 миллионов записей, на 90 дней — 70–80 миллионов. Индустриальное решение типовое: горячий журнал 7–30 дней в основной БД с быстрой фильтрацией + архивный лог на нужный срок в холодном хранилище (S3-совместимое), доступный по запросу. В BOTIX на текущей фазе только горячий участок на 7 дней; архивный режим включим под первого клиента с требованием compliance — пометка появится на developers.botix.pro/best-practices. Отдельный нюанс — кто вправе скачать архивную выгрузку: по 152-ФЗ она содержит персональные данные субъектов (как минимум IP-адреса), поэтому «Скачать журнал за прошлый год» одной кнопкой не работает — нужен контролируемый flow с подтверждением владельца, записью подтверждения в audit-trail платформы и ограничением скачивания одним разом. Где физически лежат эти данные и что отвечать нетехническому заказчику про 152-ФЗ — в разборе для владельца бизнеса Где хранятся данные клиентов: серверы, 152-ФЗ и доступ.
Журнал как сигнал, а не только инструмент отладки. Он сразу отвечает на вопросы, которые иначе требуют отдельного мониторинга: сколько запросов в день делает интеграция, какие эндпоинты используются чаще, где 4xx превышает 1%. В BOTIX KPI-блок над журналом показывает четыре числа — запросов за 24 часа, доля 2xx, 4xx, 5xx. Это базовая обсервабилити, которую интегратор раньше строил у себя в Datadog или Grafana; если поставщик отдаёт её — с интегратора снимается одна задача.
Чего не делать в audit-логе: показывать полные тела запросов и ответов (типичный источник утечек ПД); делать журнал real-time с поллингом каждую секунду (в 99% хватает обновления по кнопке); прятать журнал за платным тарифом (в BOTIX доступен с тестового триала — это инструмент, без которого интеграция страдает на любом тарифе).
Главный тест зрелости API
Три контрольных вопроса, по одному на каждый паттерн:
- *«Как клиенту пройти 5 миллионов записей?»* — cursor с
has_moreиnext_cursor, не offset. - *«Как получить событие через 100 мс после возникновения?»* — webhook с HMAC-подписью, не polling каждую секунду.
- *«Что ответить разработчику, у которого вчера в 14:37 что-то не работало?»* —
request_idиз его лога находит запись в журнале за 15 секунд.
Если на любой из трёх вопросов ответ «у нас этого нет» — это не отдельная техническая дыра, а структурный пробел в продукте, который потом дорого закрывать: миграция offset→cursor — breaking change, отсутствие webhook упирается в rate-limit при росте, отсутствие журнала превращает всю отладку в тикеты и репутационный удар. Все три стоит планировать с самого начала. Как эти паттерны укладываются в общий набор production-ready требований — в разборах Три кита production-ready API: idempotency, cursor, retry и Production-ready POST: idempotency, retry и rate-limit.
Попробуйте API BOTIX
Получите ключ, отправьте первый запрос и поймайте webhook — прямо в браузере, без боевого канала.
Попробовать API бесплатноЧитайте также
«Мелочи» публичного API: ошибки, cursor, rate-limit
Публичный API v1: breaking changes, cursor и SDK из OpenAPI
Как надёжно доставлять webhook: подпись, retry, симулятор
Три грабли в webhook-receiver: HMAC, replay, audit-лог
Три кита production-ready API: idempotency, cursor, retry
Production-ready POST: idempotency, retry и rate-limit
