API для подписчиков

версия контракта 2026-09-01 · только чтение · только https

API отдаёт пивоварне её собственные данные: всю историю чекинов, весь ассортимент, разбор отзывов на брак и на тона вкуса. Публичные рейтинги, справочники и поиск идут приложением к этому. Всё, что API умеет, — читать: ни одного метода, который что-то меняет, здесь нет и не появится.

Базовый адрес — https://tappd.ru/api/v1/. Формат ответа — JSON в UTF-8. Ключ выдаётся аккаунту с действующей подпиской в кабинете: Профиль → API.

Быстрый старт
curl -s https://tappd.ru/api/v1/my \
     -H "X-Api-Key: tpd_k7m3xp2q_9fvbn4rt8shj2wqd6yzc3mkp5xgv7bnf"

Это первый запрос, который стоит сделать: он отвечает, какая пивоварня закреплена за аккаунтом, до какого числа оплачена подписка, сколько осталось лимитов и открыты ли уже маршруты с сырыми текстами отзывов. Почти все «у меня ничего не работает» объясняются одним из этих четырёх ответов.

Ключ

Ключ выпускает владелец аккаунта в кабинете — Профиль → API. Нужна действующая подписка. Ключей на аккаунт не больше 3 — чтобы переехать на новый без простоя, хватает двух: выпускаете второй, меняете его в своей программе, отзываете первый. Полный ключ показывается ровно один раз, в момент создания — у нас он хранится только хешем, поэтому ни прислать его повторно, ни подсмотреть в поддержке невозможно. Потеряли — отзовите и выпустите новый.

Вид ключа: tpd_ + 8 символов префикса + _ + 32 символа секрета, например tpd_k7m3xp2q_9fvbn4rt8shj2wqd6yzc3mkp5xgv7bnf. Первые 12 символов (tpd_k7m3xp2q) — префикс: он виден в кабинете и им можно назвать свой ключ в письме в поддержку, не раскрывая секрета.

Как передавать
X-Api-Key: tpd_k7m3xp2q_9fvbn4rt8shj2wqd6yzc3mkp5xgv7bnf     ← основной способ

Authorization: Bearer tpd_k7m3xp2q_9fvbn4rt8shj2wqd6yzc3mkp5xgv7bnf   ← тоже работает

Основной способ — X-Api-Key: он доезжает всегда. Заголовок Authorization на части конфигураций PHP снимается веб-сервером ещё до нашего кода; мы его восстанавливаем тремя разными приёмами, но если ключ вдруг «не принимается», первое, что стоит попробовать, — перейти на X-Api-Key.

Ключ в адресе не принимается никогда. ?key=… не работает и работать не будет: адреса попадают в Referer, в историю браузера и в логи веб-сервера, а ключ — это доступ ко всей выгрузке пивоварни.
Формат ответа

У успешного ответа всегда два ключа верхнего уровня: data — то, что просили, и meta — служебное. Список meta закрыт, других полей там не появится.

{
  "data": [ … ],
  "meta": {
    "count": 50,
    "limit": 50,
    "has_more": true,
    "next": "eyJtIjoiZHQiLCJrIjoi…",
    "sync_cursor": "2026-08-31T16:20:14+03:00",
    "depth_capped": false,
    "source": "showcase",
    "stale_min": 12,
    "data_through": "2026-08-31T16:20:14+03:00",
    "data_age_min": 12,
    "analyzed_through": "2026-08-31T05:47:11+03:00",
    "generated_at": "2026-08-31T16:32:07+03:00",
    "contract": "2026-09-01"
  }
}
ПолеЧто это
countСколько строк в data этого ответа.
limitРазмер страницы, который применился (после обрезки до потолка).
has_moreЕсть ли что-то дальше. Листать надо по нему, а не по count < limit. Идти дальше при этом есть чем всегда: где стоит has_more: true, там же лежит и next, либо маршрут принимает offset. У маршрутов без листания has_more — всегда false, а про обрезку говорит depth_capped.
nextКурсор следующей страницы там, где листание курсорное. null — страниц больше нет.
sync_cursorОтметка, с которой начинать следующую догрузку по since. Снимок берётся до выборки и отдаётся в обоих режимах листания.
depth_cappedЗапрос принят не целиком: упёрлись в глубину публичного списка, в потолок окна, в 50 номеров у батча или в limit на маршруте, который не листается (/my/descriptors и …/mentions). Дальше по этому запросу хода нет — сужайте период, сорт или берите историю через /my/checkins.
sourceОткуда метрики: showcase — из витрины рейтингов, stale — витрина не пересобиралась и метрик нет ни у кого.
stale_minВозраст витрины в минутах.
data_throughВремя самого свежего чекина пивоварни в нашей базе — вашей на /my/* и той, о которой спросили, на /v1/breweries/{id}/stats. Больше нигде: остальные публичные маршруты отвечают из витрин, и свежесть у них показывает stale_min.
data_age_minСколько минут назад это было. null — чекинов у пивоварни нет вовсе.
analyzed_throughДо какого момента дошёл разбор отзывов (дефекты, тона). Отличается от data_through: разбор идёт раз в сутки.
generated_atКогда собран этот ответ.
contractДата контракта — см. Совместимость.
Карточка — объект, список — массив. /v1/beers/6487618 отдаёт объект в data, а не массив из одного элемента. Пустой список — это 200 и "data": []; ненайденная карточка — 404.

Все отметки времени — ISO-8601 с офсетом московского пояса (2026-08-31T16:20:14+03:00). Календарные даты остаются датами (2019-05-14) и во время не превращаются: у них нет точности до секунды, и обещать её незачем.

Ошибки
{
  "error": {
    "code": "bad_param",
    "message": "Дата задаётся как YYYY-MM-DD, например 2026-08-31.",
    "field": "from",
    "quota": null,
    "reset": null,
    "request_id": 918273
  }
}

Шесть ключей печатаются всегда, даже пустыми. field заполнен у всех 400: bad_param выдаётся на десяток разных причин, и без имени поля каждая опечатка превращалась бы в переписку. quota и reset — у 429. request_id совпадает с заголовком X-Request-Id: назовите его в письме в поддержку, и запрос найдётся в журнале за секунду.

ПараметрТип и значенияПо умолчаниюЧто делает
400 bad_paramЛюбая ошибка во вводеСмотрите field — там имя параметра. Повторять запрос без правки бесполезно.
401 no_keyКлюч не предъявленЗаголовка нет вовсе. Проверьте, что клиент его действительно шлёт (частая причина — редирект, теряющий заголовки).
401 bad_keyКлюч не принятОдин текст на четыре причины: не тот формат, префикса нет, секрет не сошёлся, ключ отозван. Мы намеренно не подсказываем, какая именно. Проверьте, что ключ скопирован целиком.
402 subscription_expiredПодписка кончиласьКлюч не отозван. Продлите подписку — он заработает сам, пересоздавать не надо.
403 https_requiredПришли по httpСмените схему на https.
403 ip_not_allowedАдрес вне белого списка ключаВ тексте назван адрес, который мы увидели: впишите его в кабинете или очистите поле, чтобы ключ работал с любого.
403 bind_too_freshПривязка пивоварни ещё не отлежаласьТолько у трёх маршрутов с сырыми текстами. В ответе есть поле open_at — дата, с которой они откроются. Всё остальное работает уже сейчас, см. Свои данные.
404 not_foundТакой сущности у нас нетЛибо номера нет в базе, либо мы эту сущность не отслеживаем — снаружи это одно и то же.
404 unknown_routeТакого адреса нетОпечатка в пути. Полный список — на этой странице.
405 method_not_allowedНе GET и не HEADAPI только читает.
409 no_breweryК аккаунту не закреплена пивоварняЗакрепите её в кабинете. Тот же код отвечает, если закреплённая пивоварня не отслеживается — тогда напишите в поддержку.
429 rate_limitedЛимит исчерпанВ теле — quota (какой именно) и reset, в заголовке — Retry-After в секундах. Ждите столько и повторяйте.
503 api_disabledAPI закрыт для этого аккаунтаНапишите в поддержку — включим.
503 db_unavailableНаша поломкаОбязательно повторить. Так же отвечает since, пока на сервере не прогнана миграция отметок загрузки — см. Синхронизация.
503 showcase_staleВитрина рейтингов не пересобираласьТолько у /v1/rankings/*. Карточки, списки и поиск при этом работают. Повторите позже.
5xx в первую минуту после нашей выкладки — это норма, и клиент обязан их ретраить. Файлы уезжают на сервер по одному и не атомарно: запрос, попавший в момент заливки, может прочитать наполовину залитый файл и получить 500 или 503. Держите повтор на 500, 502 и 503 с паузой (разумно: 2 с, 10 с, 60 с) — иначе ночная выкладка будет выглядеть у вас как потеря данных. На 4xx повторять бесполезно: там ошибка в самом запросе.
Лимиты
30
запросов в минуту
975
запросов в сутки
5000
строк в сутки по публичным данным
строк по своим данным

Свои данные объёмом не ограничены вовсе. У маршрутов /v1/my/* нет ни кошелька строк, ни потолка глубины, ни обязательного окна по датам: выкачать всю историю пивоварни целиком — это то, за что заплачено, и ограничение здесь било бы ровно по тому, кто платит. Тратится только сам запрос (минутный и суточный счётчики).

Кошелёк строк тратят только публичные маршруты. Сколько списывает каждый — написано значком у маршрута ниже. Три случая, где цена не равна числу отданных строк:

Ответ 304 и запрос методом HEAD стоят ноль строк.

Заголовки ответа
X-Request-Id: 918273                  ← есть ВСЕГДА, даже на 401; назовите его в поддержке
X-RateLimit-Minute-Remaining: 57      ← дальше только там, где ключ опознан
X-RateLimit-Day-Remaining: 2841
X-RateLimit-Rows-Remaining: 4180      ← на /v1/my/* здесь слово "unlimited"
X-Data-Age-Min: 12                    ← только на /v1/my/*; "unknown", если чекинов нет

Лимитные заголовки появляются только после того, как ключ опознан. На 401, 402, 403, 404 unknown_route, 405 и 409 их нет вовсе — до владельца ключа мы в этот момент ещё не дошли, а значит и остатков ничьих не знаем. Из всего перечисленного на таком ответе стоит один X-Request-Id. Разбирать отказ по отсутствию X-RateLimit-* не надо — код и error.code точнее.

На 429 добавляются Retry-After (в секундах) и X-RateLimit-Reset (unix-время). Если кончилось несколько лимитов сразу, назван тот, чьё окно закроется позже — то есть Retry-After это честное «после этого срока запрос точно пройдёт», а не «попробуйте ещё разок через пять секунд».

Читайте остатки из заголовков, а не считайте свои запросы сами. Минутное окно скользящее, а суточное закрывается в полночь по Москве — не по вашему поясу.
ETag и 304

Там, где у ответа есть ETag, он приходит в заголовке ETag. Верните его в If-None-Match — и если у нас ничего не изменилось, придёт 304 с пустым телом, бесплатно: ни строка кошелька, ни повторная передача данных за это не берутся. На регулярном опросе это главный способ не тратить лимиты впустую.

curl -s -D- -o/dev/null https://tappd.ru/api/v1/rankings/beers \
     -H "X-Api-Key: $KEY" -H 'If-None-Match: "8f14e45fceea167a5a36dedd4bea2543"'

HTTP/1.1 304 Not Modified
X-Request-Id: 918274

ETag зависит и от формы запроса тоже (сортировка, страница, период). Меняете параметры — старый ETag просто не совпадёт, и придут данные; отдельно об этом заботиться не нужно.

ETag есть не везде, и это правило, а не недоделка. Он стоит там, где версию ответа можно назвать целиком и честно. Его нет у /v1/my (в теле остатки лимитов, они меняются каждый запрос), у /v1/my/beers и /v1/my/summary, у /v1/search и /v1/meta, а также у двух маршрутов с сырым текстом — /v1/my/defects и …/mentions: текст отзыва у нас может быть переписан задним числом, не сдвинув ни одной отметки разбора, и ETag от разбора отдавал бы 304 на изменившийся текст. Врущий 304 хуже отсутствующего кэша. У /v1/meta вместо ETag есть поле version в теле.

Общие параметры

Словарь один на весь API: одинаковые имена значат одинаковое везде, где встречаются. Неизвестные параметры молча игнорируются — присылать лишнее не ошибка.

ПараметрТип и значенияПо умолчаниюЧто делает
fromдата YYYY-MM-DDзависит от маршрутаНачало периода, включительно. Пояс — московский.
toдата YYYY-MM-DDзависит от маршрутаКонец периода, включительно: to=2026-08-31 включает весь этот день целиком.
limitцелое50 (у части маршрутов иначе)Размер страницы. Потолок — 100 у публичных маршрутов и 500 у своих, но два маршрута из этого правила выпадают: у /v1/search потолок 20 (умолчание 10), а /v1/breweries/{id}/stats limit не принимает вовсе — его окно задаётся датами, и limit в ответе показывает длину окна в месяцах, а не размер страницы.
offsetцелое0Смещение там, где листание постраничное. У курсорных маршрутов не действует.
cursorнепрозрачная строкаКурсор следующей страницы. Берётся из meta.next и возвращается как есть.
sinceдата или отметка времениРежим догрузки: «что появилось и изменилось после этого момента». Принимает и YYYY-MM-DD, и значение meta.sync_cursor целиком.
beer_idцелоеСузить до одного сорта.
sortсм. маршрутсм. маршрутЕсть только у рейтингов и /v1/my/beers. У остальных порядок фиксирован.
dirasc | descdescНаправление сортировки. У sort=name умолчание — asc.
rating_gte / rating_lteчисло 0…5Порог оценки. rating_lte дополнительно отсекает чекины без оценки: ноль в базе значит «звёзд не ставили», а не «ноль баллов».
conf_gteцелое 0…100настройка раздела (обычно 50)Минимальная уверенность модели в находке дефекта.
sev_gteцелое 1…3Минимальный уровень дефекта: 1 — слабый сигнал, 2 — заметный, 3 — критично.
idsномера через запятуюБатч, до 50 номеров. Порядок ответа повторяет порядок запроса. Больше 50 — лишние молча отбрасываются, а вот строка длиннее 2048 символов — это 400 bad_param: столько номеров туда всё равно не помещается.
Две разные политики на неправильный ввод
Свои данные — /v1/my/*

Восемь маршрутов. Пивоварня берётся из привязки аккаунта, а не из параметров запроса: «чужую» пивоварню здесь запросить нечем. Кошелёк строк они не тратят, потолка объёма у них нет.

Три маршрута с сырыми текстами отзывов открываются не сразу. /my/checkins, /my/defects и …/mentions отвечают 403 bind_too_fresh, если пивоварню закрепили за аккаунтом сами меньше 7 суток назад; дата открытия приходит в поле open_at. Первая привязка аккаунта и любая привязка, сделанную администратором, работают немедленно. Метрики, счётчики, коды и тона доступны всё это время — то есть настраивать интеграцию можно, не дожидаясь открытия.
GET /v1/my
своя пивоварняцена: 0 строкETag нет

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

Параметров нет.

ПолеЧто это
login, roleЛогин аккаунта и его роль на сайте.
subscription.untilДо какого числа оплачена подписка.
subscription.activeРаботает ли ключ прямо сейчас — с учётом отсрочки в 3 суток.
breweryid, name, country, logo_url закреплённой пивоварни.
limits.*По каждому лимиту: left — остаток, limit — потолок.
bind.trustedfalse — три маршрута с сырыми текстами пока отвечают 403.
bind.open_atДата, с которой они откроются. null, если уже открыты.
ответ
{
  "data": {
    "login": "4brewers",
    "role": "user",
    "subscription": { "until": "2026-12-01T00:00:00+03:00", "active": true },
    "brewery": {
      "id": 188442,
      "name": "4BREWERS",
      "country": "Russia",
      "logo_url": "https://tappd.ru/beer_logo/brewery/188442.webp"
    },
    "limits": {
      "req_min":      { "left": 59,   "limit": 60 },
      "req_day":      { "left": 2984, "limit": 3000 },
      "rows_day_pub": { "left": 5000, "limit": 5000 }
    },
    "bind": { "trusted": true, "open_at": null }
  },
  "meta": {
    "data_through": "2026-08-31T16:20:14+03:00",
    "data_age_min": 12,
    "generated_at": "2026-08-31T16:32:07+03:00",
    "contract": "2026-09-01"
  }
}
GET /v1/my/beers
своя пивоварняцена: 0 строкETag нет

Весь ассортимент пивоварни за всё время, включая снятое с производства. from и to меняют метрики, а не состав: сорт, у которого за период не было ни одного чекина, останется в списке с checkins: 0 и rating: null. Иначе каждое сужение окна выкидывало бы из выгрузки половину ассортимента.

ПараметрТип и значенияПо умолчаниюЧто делает
from, toдатывсё времяОкно, за которое считаются метрики.
sortcheckins, rating, name, abv, first_checkincheckinsСортировка. Сорта без оценки при sort=rating уходят в конец при любом направлении.
dirasc | descdesc, у nameascНаправление.
limitцелое 1…50050Размер страницы.
offsetцелое0Смещение. Потолка глубины у своих данных нет.
ПолеЧто это
beer_idНомер сорта — он же номер сорта у источника.
name, style, abvНазвание, стиль, крепость. Название и стиль — null у сорта, чью страницу пока не удалось разобрать; пустой строки здесь не бывает.
descriptionОписание сорта простым текстом: разметка источника разобрана, абзацы сохранены.
first_checkinДата первого чекина, YYYY-MM-DD. null — чекинов не было.
logo_urlАбсолютная ссылка на этикетку или null.
checkinsЧекинов за период.
ratedИз них с оценкой.
ratingСредняя оценка по чекинам с оценкой; null, если оценок нет. Считается так же, как в рейтингах на сайте, — числа сходятся.
GET /api/v1/my/beers?from=2026-08-01&to=2026-08-31&sort=checkins&limit=2

{
  "data": [
    {
      "beer_id": 6842600,
      "name": "План Побега",
      "style": "Sour - Fruited",
      "abv": 5.5,
      "description": "Кислый эль с маракуйей и манго.",
      "first_checkin": "2026-08-14",
      "logo_url": "https://tappd.ru/beer_logo/6842600.webp",
      "checkins": 412,
      "rated": 398,
      "rating": 4.07
    },
    {
      "beer_id": 6837700,
      "name": "Зубы Города Льва",
      "style": "Wild Ale - Other",
      "abv": 5.5,
      "description": "",
      "first_checkin": "2026-08-02",
      "logo_url": "https://tappd.ru/beer_logo/6837700.webp",
      "checkins": 96,
      "rated": 91,
      "rating": 3.88
    }
  ],
  "meta": { "count": 2, "limit": 2, "has_more": true,
            "data_through": "2026-08-31T16:20:14+03:00", "data_age_min": 12,
            "generated_at": "2026-08-31T16:32:07+03:00", "contract": "2026-09-01" }
}
Коллабы, сваренные у партнёра, в этот список не попадают. Маршрут не знает параметра include_collabs: он идёт по сортам, владельцем которых числится ваша пивоварня. Сорт, который варили у партнёра, — это его сорт в справочнике, и здесь его нет. Такие чекины забираются через /v1/my/checkins?include_collabs=1, а состав участников виден в карточке сорта (collab_partners).
GET /v1/my/summary
своя пивоварняцена: 0 строкETag нет

Период против прошлого такого же: чекины, средняя оценка, отзывы с текстом, находки дефектов. Прошлый период считается от длины текущего, а не «месяц назад»: запросили 10 дней — сравниваем с предыдущими десятью. Дельты не считаем намеренно — отдаём два честных набора чисел, вычесть их вы сможете так, как принято у вас.

ПараметрТип и значенияПо умолчаниюЧто делает
from, toдатыпоследние 30 сутокПериод. Задана одна граница — прошлого периода не существует, и обе его половины придут как null.
conf_gteцелое 0…100настройка разделаПорог уверенности для счётчика находок.
ПолеЧто это
period, previous_periodfrom, to, days. У открытого края — null.
current.checkinsВсего чекинов за период.
current.ratedИз них с оценкой.
current.ratingСредняя оценка.
current.reviewsЧекинов с текстом. Это и есть «отзывы»: текстом сопровождается меньше половины чекинов.
current.defects_checkedСколько отзывов проверил разбор на брак.
current.defects_foundСколько находок. Знаменатель рядом не случайно: «3 находки» — разная новость при 30 и при 3000 проверенных.
previousТо же самое за прошлый период либо null.
{
  "data": {
    "period":          { "from": "2026-08-01", "to": "2026-08-31", "days": 31 },
    "previous_period": { "from": "2026-07-01", "to": "2026-07-31", "days": 31 },
    "current":  { "checkins": 9841, "rated": 9502, "rating": 4.02, "reviews": 3918,
                  "defects_checked": 1204, "defects_found": 71 },
    "previous": { "checkins": 8760, "rated": 8455, "rating": 3.98, "reviews": 3401,
                  "defects_checked": 1102, "defects_found": 64 }
  },
  "meta": { "data_through": "2026-08-31T16:20:14+03:00", "data_age_min": 12,
            "analyzed_through": "2026-08-31T05:47:11+03:00",
            "generated_at": "2026-08-31T16:32:07+03:00", "contract": "2026-09-01" }
}
GET /v1/my/defects/summary
своя пивоварняцена: 0 строкETag есть

Сводка разбора отзывов на брак: сколько проверено, сколько найдено, какие коды и где похоже на партию, а не на случай.

ПараметрТип и значенияПо умолчаниюЧто делает
from, toдатыпоследние 30 сутокПериод.
beer_idцелоеСузить счётчики до одного сорта. Блок clusters при этом всё равно считается по всей пивоварне — он отвечает на вопрос «где горит».
conf_gteцелое 0…100настройка разделаПорог уверенности.
compareprevДобавить в ответ прошлый такой же период.
ПолеЧто это
current.checkedСколько отзывов проверено за период.
current.foundСколько находок.
current.criticalИз них критичных.
current.unreviewedСколько ещё никто не разобрал руками.
current.codes[]code, name, group, group_name, critical, count. Полный справочник кодов — /v1/meta.
current.clusters[]«Похоже на партию»: сорт плюс код, встретившиеся несколько раз. Внутри — beer, code, code_name, count, severity, first, last.
previousТо же за прошлый период — при compare=prev.
GET /api/v1/my/defects/summary?from=2026-06-01&to=2026-08-31

{
  "data": {
    "period": { "from": "2026-06-01", "to": "2026-08-31", "days": 92 },
    "beer_id": null,
    "min_confidence": 50,
    "current": {
      "checked": 3374, "found": 210, "critical": 18, "unreviewed": 147,
      "codes": [
        { "code": "phenolic", "name": "Фенольный тон (пластик, аптека)",
          "group": "brew", "group_name": "Варка и брожение", "critical": false, "count": 16 },
        { "code": "hop_fade", "name": "Хмель выдохся",
          "group": "age", "group_name": "Возраст и хранение", "critical": false, "count": 14 }
      ],
      "clusters": [
        { "beer": { "id": 6487618, "name": "ERIS: Armagnac BA (2025)" },
          "code": "haze_film", "code_name": "Муть, хлопья, плёнка",
          "count": 4, "severity": 2, "first": "2026-07-19", "last": "2026-08-24" }
      ]
    }
  },
  "meta": { "analyzed_through": "2026-08-31T05:47:11+03:00",
            "data_through": "2026-08-31T16:20:14+03:00", "data_age_min": 12,
            "generated_at": "2026-08-31T16:32:07+03:00", "contract": "2026-09-01" }
}
GET /v1/my/descriptors
своя пивоварняцена: 0 строкETag есть

Какие тона вкуса и аромата покупатели называют в отзывах, в каком контексте и насколько ярко. Знаменатель у долей — отзывы с описанием, а не все проверенные: примерно каждый четвёртый отзыв не про вкус вовсе («вкусно, как всегда»), и доля от всех занижала бы каждую строку без объяснения.

ПараметрТип и значенияПо умолчаниюЧто делает
from, toдатывсё времяПериод.
beer_idцелоеСузить до одного сорта.
limitцелое 1…500300Сколько тонов вернуть. Порядок — по убыванию упоминаний. Листания у маршрута нет: has_more здесь всегда false, а если тонов оказалось больше limit, в meta.depth_capped стоит true.
ПолеЧто это
reviews.checkedСколько отзывов разобрано за период.
reviews.with_descriptorsИз них с описанием вкуса. Это знаменатель для share.
reviews.mentionsВсего упоминаний тонов.
descriptors[].code, name, group, group_nameКод тона и его группа.
descriptors[].kindtone — конкретный тон, profile — свойство напитка целиком (тело, питкость, баланс).
descriptors[].is_defectТон пересекается с таксономией брака (уксус, картон): одно слово, два разных вопроса.
descriptors[].echo_pctДоля упоминаний, приходящих из названия сорта, а не из вкуса: у «Грибного супа» слово «грибы» в 90% случаев эхо. null — не измерялось.
descriptors[].mentions / shareУпоминаний и доля от отзывов с описанием, в процентах.
descriptors[].positive / neutral / negativeКонтекст: тон понравился, назван нейтрально, помешал.
descriptors[].bright / faintВыраженность: ярко / фоном или «не хватает».
descriptors[].avg_ratingСредняя оценка отзывов, где тон упомянут.
descriptors[].beersУ скольких сортов встретился.
{
  "data": {
    "period": { "from": null, "to": null, "days": null },
    "beer_id": null,
    "reviews": { "checked": 3374, "with_descriptors": 2598, "mentions": 7841 },
    "descriptors": [
      { "code": "sour", "name": "Кислинка", "group": "taste", "group_name": "Вкус",
        "kind": "tone", "is_defect": false, "echo_pct": 4,
        "mentions": 365, "share": 14.0,
        "positive": 226, "neutral": 113, "negative": 26,
        "bright": 42, "faint": 24, "avg_rating": 4.05, "beers": 84 },
      { "code": "body_full", "name": "Плотное тело", "group": "body", "group_name": "Тело и текстура",
        "kind": "profile", "is_defect": false, "echo_pct": 0,
        "mentions": 176, "share": 6.8,
        "positive": 124, "neutral": 46, "negative": 6,
        "bright": 17, "faint": 9, "avg_rating": 4.16, "beers": 66 }
    ]
  },
  "meta": { "count": 2, "limit": 300, "has_more": false, "depth_capped": false,
            "analyzed_through": "2026-08-31T05:47:11+03:00",
            "data_through": "2026-08-31T16:20:14+03:00", "data_age_min": 12,
            "generated_at": "2026-08-31T16:32:07+03:00", "contract": "2026-09-01" }
}
Этот ответ не листается, и листать его не надо. Тонов не больше, чем есть в справочнике (около 230 плюс other), а умолчание limit — 300, то есть при обычном запросе вы получаете весь список целиком. Поставили limit меньше — придёт depth_capped: true: это значит «часть тонов не поместилась», поднимите limit. has_more тут всегда false, и next не бывает.
GET /v1/my/defects
своя пивоварня + сырые текстыцена: 0 строкETag нет

Сами находки: отзывы, где покупатель описал брак, вместе с текстом, которым он это описал, и разбором модели. Статусы разбора («подтверждено» / «не дефект») API показывает, но не меняет — это работа раздела на сайте.

ПараметрТип и значенияПо умолчаниюЧто делает
from, toдатывсё времяПериод по дате чекина.
codesкоды через запятуюОставить только эти коды, до 10 штук. Незнакомый код — 400. Справочник — /v1/meta.
statusnew, confirmed, rejectedвсе, кроме rejectedКак находку разобрал человек. Умолчание повторяет поведение раздела на сайте: снятое кнопкой «не дефект» уходит с глаз.
beer_idцелоеОдин сорт.
conf_gteцелое 0…100настройка разделаПорог уверенности.
sev_gteцелое 1…3Минимальный уровень.
sinceдата / отметка времениРежим догрузки по времени разбора, от старых к новым. См. Синхронизация.
cursorстрокаРаботает только вместе с since. Без него — 400.
limitцелое 1…50050Размер страницы.
ПолеЧто это
chekin_idНомер чекина. Он же ключ для upsert на вашей стороне.
datetime / checkin_dateВремя чекина и его дата.
beer, venueСорт (с этикеткой) и заведение.
usernameНик покупателя.
ratingОценка или null, если её не ставили.
textТекст отзыва: сущности раскодированы, отступы убраны. Мат не маскируется — это ваша закрытая выгрузка, а не витрина.
has_comment / has_photoПризнаки «текст есть» и «фото было». Стоят всегда.
photo_urlНаша миниатюра фото, иначе ссылка на оригинал, иначе null.
source_urlСсылка на этот чекин у источника.
code / code_nameГлавный код находки и его название.
codes[]Все коды, которые назвала модель: у одной жалобы их бывает несколько («окисление» + «картон»).
severity / severity_label1…3 и словами.
confidenceНасколько модель уверена, что это брак, а не образ, 0…100.
quoteФрагмент, который модель сочла жалобой.
noteЕё короткая пометка. Полный текст рядом не случайно: по одной цитате не понять, ошиблась она или нет.
status / status_by / status_atРазбор человеком. new — ещё не разбирали.
GET /api/v1/my/defects?codes=haze_film&sev_gte=2&limit=1

{
  "data": [
    {
      "chekin_id": 1503927744,
      "datetime": "2026-08-24T19:41:03+03:00",
      "checkin_date": "2026-08-24",
      "beer": { "id": 6487618, "name": "ERIS: Armagnac BA (2025)",
                "style": "Stout - Russian Imperial",
                "logo_url": "https://tappd.ru/beer_logo/6487618.webp" },
      "venue": { "id": 7728362, "name": "Share House" },
      "username": "beer_hunter_78",
      "rating": 3.25,
      "text": "Вкус хороший, но в этой банке какие-то хлопья и плёнка сверху. Вылил половину.",
      "has_comment": true,
      "has_photo": true,
      "photo_url": "https://tappd.ru/funny_photo/1503927744.webp",
      "source_url": "https://untappd.com/user/beer_hunter_78/checkin/1503927744",
      "code": "haze_film",
      "code_name": "Муть, хлопья, плёнка",
      "codes": ["haze_film"],
      "severity": 2,
      "severity_label": "Заметный дефект",
      "confidence": 78,
      "quote": "какие-то хлопья и плёнка сверху",
      "note": "Жалоба на конкретный экземпляр, не на стиль",
      "status": "new",
      "status_by": "",
      "status_at": null
    }
  ],
  "meta": { "count": 1, "limit": 1, "has_more": true, "next": null,
            "sync_cursor": "2026-08-31T05:47:11+03:00",
            "analyzed_through": "2026-08-31T05:47:11+03:00",
            "data_through": "2026-08-31T16:20:14+03:00", "data_age_min": 12,
            "generated_at": "2026-08-31T16:32:07+03:00", "contract": "2026-09-01" }
}
Обычный режим не листается курсором. Он отдаёт свежие находки сверху, и сужать его надо периодом (from/to). Полная выгрузка всех находок идёт через since — например since=2000-01-01: в этом режиме порядок «от старых к новым» по времени разбора, и там курсор в meta.next есть.
GET /v1/my/descriptors/{code}/mentions
своя пивоварня + сырые текстыцена: 0 строкETag нет

Сами отзывы, где упомянут этот тон, — то, ради чего в разборе хранится номер чекина: «манго, 40 упоминаний» без возможности провалиться в текст это картинка, а не разбор.

ПараметрТип и значенияПо умолчаниюЧто делает
{code}код тона в путиИз /v1/meta или из /v1/my/descriptors. Код other тоже работает — так помечено всё, чего ещё нет в справочнике.
from, toдатывсё времяПериод.
beer_idцелоеОдин сорт.
limitцелое 1…50050Сколько отзывов вернуть, свежие сверху. Листания у маршрута нет: has_more всегда false, а если отзывов оказалось больше limit, в meta.depth_capped стоит true.
ПолеЧто это
knownfalse — такого кода в справочнике нет. Так опечатка отличается от «за период таких отзывов не было»: в обоих случаях список пуст.
mentions[].chekin_id, datetime, beer, username, ratingСам чекин.
mentions[].textТекст отзыва, очищенный.
mentions[].has_comment / has_photoПризнаки. photo_url у этого маршрута нет намеренно: разбор тона читают текстом.
mentions[].sentiment−1 помешал / 0 нейтрально / 1 понравился.
mentions[].intensity0 фоном или «не хватает» / 1 обычно / 2 ярко.
mentions[].raw_wordСлово из отзыва — заполнено у кода other; у остальных кодов слово и есть код.
mentions[].source_urlСсылка на чекин у источника.
GET /api/v1/my/descriptors/sour/mentions?from=2026-08-01&limit=1

{
  "data": {
    "code": "sour",
    "name": "Кислинка",
    "known": true,
    "period": { "from": "2026-08-01", "to": null, "days": null },
    "beer_id": null,
    "mentions": [
      {
        "chekin_id": 1502881003,
        "datetime": "2026-08-15T21:07:44+03:00",
        "beer": { "id": 6842600, "name": "План Побега", "style": "Sour - Fruited" },
        "username": "beer_hunter_78",
        "rating": 4.25,
        "text": "Приятная кислинка, маракуйя на первом плане, пьётся легко.",
        "has_comment": true,
        "has_photo": false,
        "source_url": "https://untappd.com/user/beer_hunter_78/checkin/1502881003",
        "sentiment": 1,
        "intensity": 1,
        "raw_word": ""
      }
    ]
  },
  "meta": { "count": 1, "limit": 1, "has_more": false, "depth_capped": true,
            "analyzed_through": "2026-08-31T05:47:11+03:00",
            "data_through": "2026-08-31T16:20:14+03:00", "data_age_min": 12,
            "generated_at": "2026-08-31T16:32:07+03:00", "contract": "2026-09-01" }
}
Это выборка «посмотреть, что за этим тоном стоит», а не выгрузка. Маршрут не листается: has_more всегда false, next не бывает, offset он не принимает. Упёрлись в limit (это видно по depth_capped: true) — сужайте период или сорт, а всю историю отзывов целиком берите через /v1/my/checkins: там курсор, и он не теряет ни строки. Смещения тут нет намеренно — порядок «свежие сверху» стоит на дате разбора без уникального хвоста, и листание по нему молча задваивало бы одни отзывы и теряло другие.
GET /v1/my/checkins
своя пивоварня + сырые текстыцена: 0 строкETag есть

Вся история чекинов пивоварни — то, ради чего API и покупают. Два режима: курсор для первой полной выгрузки и since для ежедневной догрузки. Подробно — в разделе Синхронизация.

ПараметрТип и значенияПо умолчаниюЧто делает
from, toдатывсё времяОкно по дате чекина.
beer_idцелоеОдин сорт.
rating_gte, rating_lteчисло 0…5Порог оценки. rating_lte заодно отсекает чекины без оценки.
include_collabs1 | 00Добавить чекины коллабов, сваренных у партнёра. См. врезку ниже.
cursorстрокаСледующая страница выгрузки. Берётся из meta.next.
sinceдата / отметка времениРежим догрузки. С cursor сочетается: курсор внутри режима since листает его страницы.
limitцелое 1…50050Размер страницы. Для бэкфилла берите 500.
ПолеЧто это
chekin_idНомер чекина. Ключ для upsert на вашей стороне.
datetimeВремя чекина.
beerid, name, style, logo_url. Название и стиль — null, если страницу сорта пока не разобрали.
brewery_idВладелец сорта. Совпадает с вашей пивоварней всегда, кроме строк, пришедших по include_collabs.
is_collabtrue — это чекин коллаба, сваренного у партнёра.
venueЗаведение: id и name. Ноль и пустое имя — заведение не указано или ещё не разобрано.
usernameНик покупателя.
ratingОценка или null.
textТекст отзыва, очищенный. Пустая строка — покупатель ничего не написал.
has_comment / has_photoПризнаки. Стоят всегда.
photo_urlСсылка на фото у источника или null. Не равно has_photo: у части старых чекинов факт фото известен, а адрес не сохранён.
source_urlСсылка на этот чекин у источника.
servedИз чего пили: Can, Draft, Bottle, Taster, null.
sourceКаким путём чекин попал к нам: main — фид пивоварни, venue — фид заведения, gap — ручная дозагрузка провала.
GET /api/v1/my/checkins?limit=1

{
  "data": [
    {
      "chekin_id": 1502881003,
      "datetime": "2026-08-15T21:07:44+03:00",
      "beer": { "id": 6842600, "name": "План Побега", "style": "Sour - Fruited",
                "logo_url": "https://tappd.ru/beer_logo/6842600.webp" },
      "brewery_id": 188442,
      "is_collab": false,
      "venue": { "id": 7728362, "name": "Share House" },
      "username": "beer_hunter_78",
      "rating": 4.25,
      "text": "Приятная кислинка, маракуйя на первом плане, пьётся легко.",
      "has_comment": true,
      "has_photo": false,
      "photo_url": null,
      "source_url": "https://untappd.com/user/beer_hunter_78/checkin/1502881003",
      "served": "Can",
      "source": "main"
    }
  ],
  "meta": { "count": 1, "limit": 1, "has_more": true,
            "next": "eyJtIjoiZHQiLCJrIjoiMjAyNi0wOC0xNSAyMTowNzo0NCIsImkiOjE1MDI4ODEwMDMsImYiOiI3YjJmOWMxZDRhMGUifQ",
            "sync_cursor": "2026-08-31T16:22:00+03:00",
            "data_through": "2026-08-31T16:20:14+03:00", "data_age_min": 12,
            "generated_at": "2026-08-31T16:32:07+03:00", "contract": "2026-09-01" }
}
include_collabs=1 — единственное место во всём /v1/my/*, где в ответе законно появляется чужой brewery_id. Чекины коллаба источник отдаёт только в ленте той пивоварни, у которой варили, — поэтому коллаб, сваренный у партнёра, по вашей пивоварне не находится вовсе, хотя это ваш сорт. Такие строки помечены is_collab: true. Это не ошибка выгрузки.
Публичные данные

Здесь ровно то же, что видно на страницах сайта, и ничего сверх того. Списки режутся глубиной в 200 строк — как те же списки на сайте. Рейтинги — исключение: витрины публичны целиком и листаются до конца. Сырых отзывов по чужой пивоварне здесь нет ни на одном маршруте — их нет и на сайте.

GET /v1/rankings/breweries
публичноецена: по числу строкETag есть
GET /v1/rankings/beers
публичноецена: по числу строкETag есть
GET /v1/rankings/venues
публичноецена: по числу строкETag есть

Рейтинги за месяц — те же самые, что на главной, на «Сортах» и на «Заведениях». У пивоварен есть ещё и прошлый месяц (past_*), чтобы считать дельты.

ПараметрТип и значенияПо умолчаниюЧто делает
sortпивоварни и сорта: rating, checkins, users, venues
заведения: checkins, rating, users, beers, breweries
rating; у заведений — checkinsУмолчание совпадает с сортировкой соответствующей страницы сайта.
dirasc | descdescНаправление.
limitцелое 1…10050Размер страницы.
offsetцелое0Смещение. Глубины у рейтингов нет — листаются до конца.
GET /api/v1/rankings/breweries?sort=rating&limit=1

{
  "data": [
    {
      "brewery_id": 460729,
      "name": "Ashram Cider",
      "logo_url": "https://tappd.ru/beer_logo/brewery/460729.webp",
      "source_url": "https://untappd.com/brewery/460729",
      "metrics": {
        "rating": 4.4569, "checkins": 254, "users": 130, "venues": 24,
        "past_rating": 4.6348, "past_checkins": 92, "past_users": 51, "past_venues": 16
      }
    }
  ],
  "meta": { "source": "showcase", "stale_min": 12, "count": 1, "limit": 1,
            "has_more": true, "depth_capped": false,
            "generated_at": "2026-08-31T16:32:07+03:00", "contract": "2026-09-01" }
}
Витрина не пересобиралась — маршрут отвечает 503 showcase_stale, а не пустым списком. Считать рейтинг вживую на каждый запрос мы не будем: это секунды работы базы. Карточки, справочники и поиск при этом продолжают работать, у них метрики просто придут как null с пометкой "source": "stale". past_* тоже бывают null — это значит «в прошлом месяце пивоварня не взяла порог», а не «ноль чекинов».
GET /v1/beers?ids=…
публичноецена: по числу запрошенных idETag есть
GET /v1/breweries?ids=…
публичноецена: по числу запрошенных idETag есть
GET /v1/venues?ids=…
публичноецена: по числу запрошенных idETag есть

Батч на 50 номеров за раз. Он обязателен для любой витрины: сорок позиций иначе означали бы сорок запросов на каждое обновление и упор в минутный лимит.

ПараметрТип и значенияПо умолчаниюЧто делает
idsномера через запятую, до 50— обязателенБольше 50 — лишние отбрасываются и в meta.depth_capped стоит true. Без ids — 400: маршрута «отдай весь справочник» не существует. Молча обрезается именно перебор номеров; строка длиннее 2048 символов — это 400 bad_param, в неё 50 номеров не укладываются ни при каком раскладе.
  • Присылайте If-None-Match. Витрину опрашивают по кругу, и 304 здесь не стоит ни одной строки суточного кошелька. Версия — та же, что у карточки, плюс сам список номеров: переставили номера местами — это другой запрос и другой ETag.
  • Порядок ответа повторяет порядок запроса, и дубликаты отдаются как присланы: клиент собирает витрину по своему списку позиций, и схлопывание сдвинуло бы ему нумерацию.
  • Несуществующий номер просто отсутствует в ответе — это не 404 и не дыра. Сверяйте по beer_id в строках, а не по позиции.
  • Состав полей у строки — тот же, что у карточки ниже.
GET /api/v1/breweries?ids=460729,999999999,188442

{
  "data": [
    { "brewery_id": 460729, "name": "Ashram Cider",
      "logo_url": "https://tappd.ru/beer_logo/brewery/460729.webp",
      "source_url": "https://untappd.com/brewery/460729",
      "metrics": { "rating": 4.4569, "checkins": 254, "users": 130, "venues": 24,
                   "past_rating": 4.6348, "past_checkins": 92,
                   "past_users": 51, "past_venues": 16 } },
    { "brewery_id": 188442, "name": "4BREWERS",
      "logo_url": "https://tappd.ru/beer_logo/brewery/188442.webp",
      "source_url": "https://untappd.com/brewery/188442",
      "metrics": { "rating": 4.0189, "checkins": 9841, "users": 3204, "venues": 612,
                   "past_rating": 3.9812, "past_checkins": 8760,
                   "past_users": 2988, "past_venues": 574 } }
  ],
  "meta": { "source": "showcase", "stale_min": 12,
            "count": 2, "limit": 3, "has_more": false, "depth_capped": false,
            "generated_at": "2026-08-31T16:32:07+03:00", "contract": "2026-09-01" }
}

Запрошено три номера, отдано два: несуществующего в ответе просто нет, а списаны все три (meta.limit показывает, сколько запросили).

GET /v1/beers/{id}
публичноецена: 1 строкаETag есть
GET /v1/breweries/{id}
публичноецена: 1 строкаETag есть
GET /v1/venues/{id}
публичноецена: 1 строкаETag есть

Карточка одной сущности. Нет такого номера или мы эту сущность не отслеживаем — 404 в обоих случаях, одинаково.

ПолеЧто это
beer_id / brewery_id / venue_idНомер. Он же номер у источника — переспрашивать не нужно.
name, style, abvУ сорта. Название и стиль — null, если страницу сорта пока не разобрали.
descriptionОписание сорта простым текстом.
first_checkin_dateДата первого чекина, YYYY-MM-DD.
image_url / logo_urlАбсолютная ссылка на этикетку или логотип; null, если картинки нет. Битых ссылок не отдаём.
breweryУ сорта: brewery_id, name, logo_url и external.
brewery.externaltrue — пивоварню мы не отслеживаем, и /v1/breweries/{id} по ней ответит 404. Проверяйте до того, как соберёте ссылку.
collab_partners[]Партнёры по коллабу, без владельца (он стоит отдельным полем brewery). У каждого — brewery_id, name, external.
source_urlСтраница сущности у источника.
metricsПоказатели за месяц из витрины либо null. Для пивоварен добавлены past_*.
GET /api/v1/beers/6487618

{
  "data": {
    "beer_id": 6487618,
    "name": "ERIS: Armagnac BA (2025)",
    "style": "Stout - Russian Imperial",
    "abv": 16.5,
    "description": "Имперский стаут, выдержанный в бочках из-под арманьяка.",
    "first_checkin_date": "2026-02-11",
    "image_url": "https://tappd.ru/beer_logo/6487618.webp",
    "brewery": { "brewery_id": 329172, "name": "Midnight", "external": false,
                 "logo_url": "https://tappd.ru/beer_logo/brewery/329172.webp" },
    "collab_partners": [],
    "source_url": "https://untappd.com/beer/6487618",
    "metrics": { "rating": 4.516667, "checkins": 87, "users": 86, "venues": 29 }
  },
  "meta": { "source": "showcase", "stale_min": 12,
            "generated_at": "2026-08-31T16:32:07+03:00", "contract": "2026-09-01" }
}

Пустые metrics при "source": "showcase" означают ровно одно: сущность не взяла месячный порог в 50 чекинов. Пустые при "source": "stale" — что метрики не считались вовсе.

GET /v1/breweries/{id}/beers
публичноецена: по числу строкETag есть

Ассортимент пивоварни, страницами. Порядок фиксирован — свежие сорта впереди; сортировки у маршрута нет. Строки — те же объекты сорта, что в карточке.

ПараметрТип и значенияПо умолчаниюЧто делает
limitцелое 1…10050Размер страницы.
offsetцелое0Смещение. Дальше 200-й строки публичный список не идёт: приходит пустая страница с depth_capped: true и has_more: false.
GET /api/v1/breweries/460729/beers?limit=1

{
  "data": [
    {
      "beer_id": 6804379,
      "name": "Melomel Wild Pear Batch #2",
      "style": "Mead - Melomel",
      "abv": 14.0,
      "description": "Медовуха на диких грушах, вторая партия.",
      "first_checkin_date": "2026-07-28",
      "image_url": "https://tappd.ru/beer_logo/6804379.webp",
      "brewery": { "brewery_id": 460729, "name": "Ashram Cider", "external": false,
                   "logo_url": "https://tappd.ru/beer_logo/brewery/460729.webp" },
      "collab_partners": [],
      "source_url": "https://untappd.com/beer/6804379",
      "metrics": { "rating": 4.516038, "checkins": 53, "users": 53, "venues": 3 }
    }
  ],
  "meta": { "source": "showcase", "stale_min": 12, "count": 1, "limit": 1,
            "has_more": true, "depth_capped": false,
            "generated_at": "2026-08-31T16:32:07+03:00", "contract": "2026-09-01" }
}
Свой ассортимент берите на /v1/my/beers: там нет ни глубины, ни кошелька строк, зато есть метрики за произвольный период.
GET /v1/breweries/{id}/stats
публичноецена: 100 строк (фиксированно)ETag есть

Чекины и средняя оценка пивоварни по месяцам. Единственный живой агрегат в публичной части — отсюда фиксированная цена и настойчивая просьба пользоваться ETag: у пивоварни, которую не парсили последний час, повторный запрос обязан стоить ноль.

ПараметрТип и значенияПо умолчаниюЧто делает
from, toдатыпоследние 12 месяцевОкно. Больше 12 календарных месяцев не отдаём: окно обрезается с дальнего края, свежий сохраняется, и в meta.depth_capped стоит true.

limit и offset этот маршрут не принимает — размер ответа задают from и to. limit в meta при этом есть, и значит он другое: длину окна в месяцах, то есть потолок в 12. has_more всегда false.

GET /api/v1/breweries/188442/stats?from=2026-06-01&to=2026-08-31

{
  "data": [
    { "month": "2026-06", "checkins": 8214, "ratings": 7902, "rating": 3.99 },
    { "month": "2026-07", "checkins": 8760, "ratings": 8455, "rating": 3.98 },
    { "month": "2026-08", "checkins": 9841, "ratings": 9502, "rating": 4.02 }
  ],
  "meta": { "count": 3, "limit": 12, "has_more": false, "depth_capped": false,
            "data_through": "2026-08-31T16:20:14+03:00",
            "generated_at": "2026-08-31T16:32:07+03:00", "contract": "2026-09-01" }
}

Месяц без чекинов остаётся в ряду нулями: «в июле не было ни одного чекина» — это ответ, а не отсутствие ответа. checkins считает все чекины, ratings — только с оценкой, и средняя считается по вторым. Крайние месяцы обрезаны границами окна, а не календарём: если from — 15-е число, в первом месяце ровно половина.

GET /v1/meta
публичноецена: 1 строкаETag нет

Справочники кодов: коды дефектов и коды тонов вкуса вместе с группами. База при этом не трогается вовсе. Запросите один раз, положите у себя и сверяйте поле version: изменилось — перечитайте.

ПолеЧто это
defect_groups[] / descriptor_groups[]code и name группы.
defect_codes[]code, name, group, group_name, critical (код критичен сам по себе — вздутая банка не бывает несрочной), cause (вероятная причина, для технолога).
descriptor_codes[]code, name, group, group_name, kind, defect, echo_pct.
versionОтпечаток справочника. Меняется только вместе с составом.
{
  "data": {
    "defect_groups": [ { "code": "bio", "name": "Микробиология" }, … ],
    "defect_codes": [
      { "code": "gushing", "name": "Гашинг (фонтанирование)", "group": "bio",
        "group_name": "Микробиология", "critical": true,
        "cause": "Заражённый солод (Fusarium), микробиология розлива…" }
    ],
    "descriptor_groups": [ { "code": "taste", "name": "Вкус" }, … ],
    "descriptor_codes": [
      { "code": "sour", "name": "Кислинка", "group": "taste", "group_name": "Вкус",
        "kind": "tone", "defect": false, "echo_pct": 4 }
    ],
    "version": "3f1c9a0b7d2e5648a1c4f0b93e77dd21"
  },
  "meta": { "count": 1, "has_more": false,
            "generated_at": "2026-08-31T16:32:07+03:00", "contract": "2026-09-01" }
}
Синхронизация: курсор и since

Забрать историю к себе и потом поддерживать её в актуальном состоянии — это два разных режима, и путать их нельзя.

1. Бэкфилл — курсором

Первая полная выгрузка. Запрашиваете /v1/my/checkins?limit=500, берёте meta.next, передаёте его в cursor, повторяете, пока meta.has_more не станет false. Строк это не стоит, тратятся только запросы: самая крупная пивоварня в базе выкачивается примерно за 1 200 запросов, то есть влезает в суточный лимит с большим запасом.

2. Догрузка — по since

Сохраните meta.sync_cursor из любого ответа бэкфилла (он отдаётся в обоих режимах и снимается до выборки) и дальше ходите с since=<это значение>. Режим отдаёт всё, что появилось и изменилось после этого момента, от старых к новым; внутри него листание тоже курсорное. В конце запомните новый sync_cursor.

Строки из since кладите upsert'ом по chekin_id, а не «вставить как новое». Этот режим отдаёт не только новые чекины, но и правки уже отданных: тексты отзывов у нас дочитываются задним числом, а провалы в истории заливаются пачками с датами до девяти лет назад. Простая вставка даст дубли, а «вставить, если нет» — потерянные правки. По той же причине since, а не «фильтр по дате чекина»: чекин, залитый сегодня, может быть датирован позапрошлым годом, и по дате вы его никогда не увидите.
Пустой ответ по since не отличается от вставшего у нас парсера. «Новых чекинов не было» и «данные не обновляются третьи сутки» выглядят одинаково — пустым data. Отличает их meta.data_age_min и заголовок X-Data-Age-Min: это возраст самого свежего чекина вашей пивоварни в нашей базе. Заведите на него тревогу у себя — скажем, «больше 180 минут в рабочее время» — и вы узнаете о нашей поломке раньше, чем мы вам о ней напишем. Заголовок приходит и на HEAD, то есть проверять его можно, не выкачивая тело.

Пока на сервере не прогнана миграция отметок загрузки, режим since отвечает 503 с текстом про то, что отметки ещё не ведутся. Это ожидаемое состояние сразу после запуска API, а не поломка у вас: выгружайтесь пока курсором, это тот же самый набор данных. У /v1/my/defects такой зависимости нет — там since идёт по времени разбора и работает всегда.

Отметки не заполняются задним числом: since видит только то, что появилось и поправилось после запуска API. Поэтому первая выгрузка обязана быть курсорной — since=2000-01-01 историю не отдаст.

Что надо знать до того, как начнёте
Готовые примеры

Оба делают одно и то же и полностью рабочие: первый запуск выкачивает всю историю курсором, каждый следующий догружает изменения по since. Строки кладутся upsert'ом по chekin_id, отметка синхронизации хранится рядом. Запускать вторым скриптом по расписанию — раз в 15–30 минут более чем достаточно.

PHP
<?php
/**
 * Выгрузка чекинов своей пивоварни в локальную таблицу.
 * Первый запуск — полный бэкфилл курсором, дальше догрузка по since.
 */

$KEY   = getenv('TAPPD_KEY');                 // ключ в коде не держим
$BASE  = 'https://tappd.ru/api/v1';
$STATE = __DIR__ . '/tappd_sync.json';        // тут храним sync_cursor

$pdo = new PDO('mysql:host=localhost;dbname=my;charset=utf8mb4', 'user', 'pass',
    array(PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION));

/** Один запрос к API с повтором на 5xx и 429. */
function api($path, array $query, $key) {
    $url = $path . (strpos($path, '?') === false ? '?' : '&') . http_build_query($query);

    for ($try = 1; $try <= 5; $try++) {
        $ch = curl_init($url);
        curl_setopt_array($ch, array(
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER     => array('X-Api-Key: ' . $key),
            CURLOPT_TIMEOUT        => 60,
            CURLOPT_HEADER         => true,
        ));
        $raw  = curl_exec($ch);
        $code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        $hlen = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
        $head = substr((string) $raw, 0, $hlen);
        $body = substr((string) $raw, $hlen);
        curl_close($ch);

        // 5xx бывают в первую минуту после выкладки — это норма, надо ретраить.
        // 429 говорит, сколько ждать, в заголовке Retry-After.
        if ($code >= 500 || $code === 429 || $raw === false) {
            $wait = 2 * $try;
            if (preg_match('/^Retry-After:\s*(\d+)/mi', $head, $m)) $wait = (int) $m[1];
            sleep(min($wait, 120));
            continue;
        }

        $json = json_decode($body, true);
        if ($code !== 200) {
            $e = isset($json['error']) ? $json['error'] : array();
            throw new RuntimeException('API ' . $code . ' ' . (isset($e['code']) ? $e['code'] : '')
                . ': ' . (isset($e['message']) ? $e['message'] : $body)
                . ' (request_id ' . (isset($e['request_id']) ? $e['request_id'] : '?') . ')');
        }
        return $json;
    }
    throw new RuntimeException('API недоступен после пяти попыток: ' . $url);
}

/** UPSERT: since отдаёт и НОВЫЕ строки, и ПРАВКИ уже отданных. */
function save(PDO $pdo, array $rows) {
    $st = $pdo->prepare(
        'INSERT INTO checkins (chekin_id, dt, beer_id, beer_name, username, rating,
                               text, has_photo, venue_name, served, is_collab)
         VALUES (:id, :dt, :bid, :bname, :user, :rating, :text, :photo, :venue, :served, :collab)
         ON DUPLICATE KEY UPDATE
             dt = VALUES(dt), beer_id = VALUES(beer_id), beer_name = VALUES(beer_name),
             rating = VALUES(rating), text = VALUES(text), has_photo = VALUES(has_photo),
             venue_name = VALUES(venue_name), served = VALUES(served), is_collab = VALUES(is_collab)');

    foreach ($rows as $r) {
        $st->execute(array(
            ':id'     => $r['chekin_id'],
            ':dt'     => $r['datetime'],
            ':bid'    => $r['beer']['id'],
            ':bname'  => $r['beer']['name'],
            ':user'   => $r['username'],
            ':rating' => $r['rating'],          // null = оценки не ставили
            ':text'   => $r['text'],
            ':photo'  => $r['has_photo'] ? 1 : 0,
            ':venue'  => $r['venue']['name'],
            ':served' => $r['served'],
            ':collab' => $r['is_collab'] ? 1 : 0,
        ));
    }
}

$state = is_file($STATE) ? json_decode(file_get_contents($STATE), true) : array();
$since = isset($state['sync_cursor']) ? $state['sync_cursor'] : '';

$query = array('limit' => 500);
if ($since !== '') $query['since'] = $since;    // пусто — значит первый запуск, идём курсором

$total = 0;
$mark  = $since;

while (true) {
    $res  = api($BASE . '/my/checkins', $query, $KEY);
    $rows = $res['data'];
    $meta = $res['meta'];

    save($pdo, $rows);
    $total += count($rows);

    // Снимок берём из ПЕРВОГО ответа: он сделан ДО выборки, и всё, что появилось
    // за время выгрузки, догрузится в следующий раз.
    if ($mark === $since && !empty($meta['sync_cursor'])) $mark = $meta['sync_cursor'];

    // Свежесть данных: пустой ответ и вставший парсер выглядят одинаково.
    if (isset($meta['data_age_min']) && $meta['data_age_min'] > 180) {
        error_log('[tappd] данные не обновлялись ' . $meta['data_age_min'] . ' мин');
    }

    if (empty($meta['has_more'])) break;
    $query['cursor'] = $meta['next'];           // курсор возвращаем КАК ЕСТЬ
}

if ($mark !== '') file_put_contents($STATE, json_encode(array('sync_cursor' => $mark)));
echo "загружено строк: $total\n";
Python
#!/usr/bin/env python3
"""Выгрузка чекинов своей пивоварни: бэкфилл курсором, дальше догрузка по since."""

import json
import os
import pathlib
import re
import time

import requests

KEY   = os.environ["TAPPD_KEY"]           # ключ в коде не держим
BASE  = "https://tappd.ru/api/v1"
STATE = pathlib.Path(__file__).with_name("tappd_sync.json")

session = requests.Session()
session.headers["X-Api-Key"] = KEY        # основной способ передать ключ


def api(path, **params):
    """Один запрос с повтором на 5xx и 429."""
    for attempt in range(1, 6):
        r = session.get(BASE + path, params=params, timeout=60)

        # 5xx бывают в первую минуту после выкладки — это норма, надо ретраить.
        if r.status_code >= 500 or r.status_code == 429:
            wait = int(r.headers.get("Retry-After", 2 * attempt))
            time.sleep(min(wait, 120))
            continue

        if r.status_code != 200:
            e = r.json().get("error", {})
            raise RuntimeError(
                f"API {r.status_code} {e.get('code')}: {e.get('message')} "
                f"(request_id {e.get('request_id')})"
            )
        return r.json()

    raise RuntimeError(f"API недоступен после пяти попыток: {path}")


def save(rows):
    """UPSERT: since отдаёт и НОВЫЕ строки, и ПРАВКИ уже отданных."""
    with connection.cursor() as cur:        # ваш драйвер БД
        cur.executemany(
            """INSERT INTO checkins (chekin_id, dt, beer_id, beer_name, username,
                                     rating, text, has_photo, venue_name, served, is_collab)
               VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
               ON DUPLICATE KEY UPDATE
                   dt = VALUES(dt), beer_id = VALUES(beer_id), beer_name = VALUES(beer_name),
                   rating = VALUES(rating), text = VALUES(text), has_photo = VALUES(has_photo),
                   venue_name = VALUES(venue_name), served = VALUES(served),
                   is_collab = VALUES(is_collab)""",
            [
                (
                    r["chekin_id"], r["datetime"], r["beer"]["id"], r["beer"]["name"],
                    r["username"], r["rating"],          # None = оценки не ставили
                    r["text"], int(r["has_photo"]), r["venue"]["name"],
                    r["served"], int(r["is_collab"]),
                )
                for r in rows
            ],
        )
    connection.commit()


state = json.loads(STATE.read_text()) if STATE.exists() else {}
since = state.get("sync_cursor", "")

params = {"limit": 500}
if since:
    params["since"] = since               # пусто — первый запуск, идём курсором

total, mark = 0, since

while True:
    res = api("/my/checkins", **params)
    rows, meta = res["data"], res["meta"]

    save(rows)
    total += len(rows)

    # Снимок берём из ПЕРВОГО ответа: он сделан ДО выборки, и всё, что появилось
    # за время выгрузки, догрузится в следующий раз.
    if mark == since and meta.get("sync_cursor"):
        mark = meta["sync_cursor"]

    # Свежесть данных: пустой ответ и вставший парсер выглядят одинаково.
    age = meta.get("data_age_min")
    if age is not None and age > 180:
        print(f"[tappd] данные не обновлялись {age} мин")

    if not meta.get("has_more"):
        break
    params["cursor"] = meta["next"]       # курсор возвращаем КАК ЕСТЬ

if mark:
    STATE.write_text(json.dumps({"sync_cursor": mark}))
print(f"загружено строк: {total}")
Совместимость

В каждом ответе есть meta.contract — дата контракта. Сейчас это 2026-09-01.

Условия использования

Данные выдаются для собственных нужд подписчика: внутренние отчёты, своя аналитика, свои экраны и рассылки. Ключ именной — он выдан конкретному аккаунту, и передавать его третьим лицам нельзя. Перепродажа полученных данных и их публикация массивом запрещены: выгрузка предназначена вам, а не для перевыкладки её как своего набора данных или как чужого сервиса.

Заметили, что ключ утёк, — отзовите его в кабинете и выпустите новый; всё, что выпущено раньше, продолжает работать, пока не отозвано. Вопросы, ошибки и просьбы — в поддержку, и назовите request_id из ответа или префикс своего ключа: по ним запрос находится в журнале за секунду.