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, в историю
браузера и в логи веб-сервера, а ключ — это доступ ко всей выгрузке пивоварни.
http:// — 403
https_required.GET и HEAD. Любой другой
метод — 405. HEAD обслуживается как GET, но тела не
отдаёт и строк не списывает.ip_not_allowed, и в тексте ошибки назван адрес, который мы
увидели: чаще всего это не атака, а сменившийся адрес у подрядчика.
У успешного ответа всегда два ключа верхнего уровня: 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 и не HEAD | — | API только читает. |
409 no_brewery | К аккаунту не закреплена пивоварня | — | Закрепите её в кабинете. Тот же код отвечает, если закреплённая пивоварня не отслеживается — тогда напишите в поддержку. |
429 rate_limited | Лимит исчерпан | — | В теле — quota (какой именно) и reset, в заголовке — Retry-After в секундах. Ждите столько и повторяйте. |
503 api_disabled | API закрыт для этого аккаунта | — | Напишите в поддержку — включим. |
503 db_unavailable | Наша поломка | — | Обязательно повторить. Так же отвечает since, пока на сервере не прогнана миграция отметок загрузки — см. Синхронизация. |
503 showcase_stale | Витрина рейтингов не пересобиралась | — | Только у /v1/rankings/*. Карточки, списки и поиск при этом работают. Повторите позже. |
Свои данные объёмом не ограничены вовсе. У маршрутов
/v1/my/* нет ни кошелька строк, ни потолка глубины, ни обязательного
окна по датам: выкачать всю историю пивоварни целиком — это то, за что заплачено,
и ограничение здесь било бы ровно по тому, кто платит. Тратится только сам запрос
(минутный и суточный счётчики).
Кошелёк строк тратят только публичные маршруты. Сколько списывает каждый — написано значком у маршрута ниже. Три случая, где цена не равна числу отданных строк:
?ids= — по числу запрошенных номеров, а не
найденных, и дубликаты считаются. Иначе список добивают несуществующими
номерами и недоплачивают./v1/search — фиксированные 50 строк: поиск по подстроке идёт
полным проходом, и работа одна и та же на одну найденную строку и на двадцать./v1/breweries/{id}/stats — фиксированные 100 строк: это
единственный живой агрегат по чекинам во всём публичном API. Отсюда же —
обязательный ETag на нём.
Ответ 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, он приходит в заголовке 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. У остальных порядок фиксирован. |
dir | asc | desc | desc | Направление сортировки. У 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: столько номеров туда всё равно не помещается. |
limit=1000 — это
потолок маршрута (100 у публичных,
500 у своих, 20
у поиска), смещение за глубину списка — пустая страница с
depth_capped: true, окно статистики больше года — двенадцать
месяцев. Это нормальный край, а не ошибка.limit=много,
from=вчера, beer_id=abc — 400 bad_param с
именем поля. Дата, не прошедшая проверку календаря, тоже: молчаливо выкинутый
фильтр дал бы вам не ошибку, а лишние данные в отчёте, и заметить это было бы
нечем.from больше
to — это опечатка, а какую из двух дат считать верной, знаете
только вы./v1/my/*Восемь маршрутов. Пивоварня берётся из привязки аккаунта, а не из параметров запроса: «чужую» пивоварню здесь запросить нечем. Кошелёк строк они не тратят, потолка объёма у них нет.
/my/checkins, /my/defects и …/mentions
отвечают 403 bind_too_fresh, если пивоварню закрепили за аккаунтом
сами меньше 7 суток назад; дата
открытия приходит в поле open_at. Первая привязка аккаунта и любая
привязка, сделанную администратором, работают немедленно. Метрики, счётчики, коды
и тона доступны всё это время — то есть настраивать интеграцию можно, не дожидаясь
открытия.
Кто я, какая пивоварня закреплена, до какого числа подписка, сколько осталось лимитов и открыты ли сырые тексты. Первый запрос интеграции и единственный, по которому видно, почему остальные отвечают не то.
Параметров нет.
| Поле | Что это |
|---|---|
login, role | Логин аккаунта и его роль на сайте. |
subscription.until | До какого числа оплачена подписка. |
subscription.active | Работает ли ключ прямо сейчас — с учётом отсрочки в 3 суток. |
brewery | id, name, country, logo_url закреплённой пивоварни. |
limits.* | По каждому лимиту: left — остаток, limit — потолок. |
bind.trusted | false — три маршрута с сырыми текстами пока отвечают 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"
}
}
Весь ассортимент пивоварни за всё время, включая снятое с производства.
from и to меняют метрики, а не состав:
сорт, у которого за период не было ни одного чекина, останется в списке с
checkins: 0 и rating: null. Иначе каждое сужение окна
выкидывало бы из выгрузки половину ассортимента.
| Параметр | Тип и значения | По умолчанию | Что делает |
|---|---|---|---|
from, to | даты | всё время | Окно, за которое считаются метрики. |
sort | checkins, rating, name, abv, first_checkin | checkins | Сортировка. Сорта без оценки при sort=rating уходят в конец при любом направлении. |
dir | asc | desc | desc, у name — asc | Направление. |
limit | целое 1…500 | 50 | Размер страницы. |
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).
Период против прошлого такого же: чекины, средняя оценка, отзывы с текстом, находки дефектов. Прошлый период считается от длины текущего, а не «месяц назад»: запросили 10 дней — сравниваем с предыдущими десятью. Дельты не считаем намеренно — отдаём два честных набора чисел, вычесть их вы сможете так, как принято у вас.
| Параметр | Тип и значения | По умолчанию | Что делает |
|---|---|---|---|
from, to | даты | последние 30 суток | Период. Задана одна граница — прошлого периода не существует, и обе его половины придут как null. |
conf_gte | целое 0…100 | настройка раздела | Порог уверенности для счётчика находок. |
| Поле | Что это |
|---|---|
period, previous_period | from, 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" }
} Сводка разбора отзывов на брак: сколько проверено, сколько найдено, какие коды и где похоже на партию, а не на случай.
| Параметр | Тип и значения | По умолчанию | Что делает |
|---|---|---|---|
from, to | даты | последние 30 суток | Период. |
beer_id | целое | — | Сузить счётчики до одного сорта. Блок clusters при этом всё равно считается по всей пивоварне — он отвечает на вопрос «где горит». |
conf_gte | целое 0…100 | настройка раздела | Порог уверенности. |
compare | prev | — | Добавить в ответ прошлый такой же период. |
| Поле | Что это |
|---|---|
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" }
} Какие тона вкуса и аромата покупатели называют в отзывах, в каком контексте и насколько ярко. Знаменатель у долей — отзывы с описанием, а не все проверенные: примерно каждый четвёртый отзыв не про вкус вовсе («вкусно, как всегда»), и доля от всех занижала бы каждую строку без объяснения.
| Параметр | Тип и значения | По умолчанию | Что делает |
|---|---|---|---|
from, to | даты | всё время | Период. |
beer_id | целое | — | Сузить до одного сорта. |
limit | целое 1…500 | 300 | Сколько тонов вернуть. Порядок — по убыванию упоминаний. Листания у маршрута нет: has_more здесь всегда false, а если тонов оказалось больше limit, в meta.depth_capped стоит true. |
| Поле | Что это |
|---|---|
reviews.checked | Сколько отзывов разобрано за период. |
reviews.with_descriptors | Из них с описанием вкуса. Это знаменатель для share. |
reviews.mentions | Всего упоминаний тонов. |
descriptors[].code, name, group, group_name | Код тона и его группа. |
descriptors[].kind | tone — конкретный тон, 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" }
} other), а умолчание
limit — 300, то есть при обычном запросе вы получаете весь список
целиком. Поставили limit меньше — придёт
depth_capped: true: это значит «часть тонов не поместилась»,
поднимите limit. has_more тут всегда
false, и next не бывает.
Сами находки: отзывы, где покупатель описал брак, вместе с текстом, которым он это описал, и разбором модели. Статусы разбора («подтверждено» / «не дефект») API показывает, но не меняет — это работа раздела на сайте.
| Параметр | Тип и значения | По умолчанию | Что делает |
|---|---|---|---|
from, to | даты | всё время | Период по дате чекина. |
codes | коды через запятую | — | Оставить только эти коды, до 10 штук. Незнакомый код — 400. Справочник — /v1/meta. |
status | new, confirmed, rejected | все, кроме rejected | Как находку разобрал человек. Умолчание повторяет поведение раздела на сайте: снятое кнопкой «не дефект» уходит с глаз. |
beer_id | целое | — | Один сорт. |
conf_gte | целое 0…100 | настройка раздела | Порог уверенности. |
sev_gte | целое 1…3 | — | Минимальный уровень. |
since | дата / отметка времени | — | Режим догрузки по времени разбора, от старых к новым. См. Синхронизация. |
cursor | строка | — | Работает только вместе с since. Без него — 400. |
limit | целое 1…500 | 50 | Размер страницы. |
| Поле | Что это |
|---|---|
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_label | 1…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 есть.
Сами отзывы, где упомянут этот тон, — то, ради чего в разборе хранится номер чекина: «манго, 40 упоминаний» без возможности провалиться в текст это картинка, а не разбор.
| Параметр | Тип и значения | По умолчанию | Что делает |
|---|---|---|---|
{code} | код тона в пути | — | Из /v1/meta или из /v1/my/descriptors. Код other тоже работает — так помечено всё, чего ещё нет в справочнике. |
from, to | даты | всё время | Период. |
beer_id | целое | — | Один сорт. |
limit | целое 1…500 | 50 | Сколько отзывов вернуть, свежие сверху. Листания у маршрута нет: has_more всегда false, а если отзывов оказалось больше limit, в meta.depth_capped стоит true. |
| Поле | Что это |
|---|---|
known | false — такого кода в справочнике нет. Так опечатка отличается от «за период таких отзывов не было»: в обоих случаях список пуст. |
mentions[].chekin_id, datetime, beer, username, rating | Сам чекин. |
mentions[].text | Текст отзыва, очищенный. |
mentions[].has_comment / has_photo | Признаки. photo_url у этого маршрута нет намеренно: разбор тона читают текстом. |
mentions[].sentiment | −1 помешал / 0 нейтрально / 1 понравился. |
mentions[].intensity | 0 фоном или «не хватает» / 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: там курсор, и он не
теряет ни строки. Смещения тут нет намеренно — порядок «свежие сверху» стоит на
дате разбора без уникального хвоста, и листание по нему молча задваивало бы одни
отзывы и теряло другие.
Вся история чекинов пивоварни — то, ради чего API и покупают. Два режима:
курсор для первой полной выгрузки и since
для ежедневной догрузки. Подробно — в разделе Синхронизация.
| Параметр | Тип и значения | По умолчанию | Что делает |
|---|---|---|---|
from, to | даты | всё время | Окно по дате чекина. |
beer_id | целое | — | Один сорт. |
rating_gte, rating_lte | число 0…5 | — | Порог оценки. rating_lte заодно отсекает чекины без оценки. |
include_collabs | 1 | 0 | 0 | Добавить чекины коллабов, сваренных у партнёра. См. врезку ниже. |
cursor | строка | — | Следующая страница выгрузки. Берётся из meta.next. |
since | дата / отметка времени | — | Режим догрузки. С cursor сочетается: курсор внутри режима since листает его страницы. |
limit | целое 1…500 | 50 | Размер страницы. Для бэкфилла берите 500. |
| Поле | Что это |
|---|---|
chekin_id | Номер чекина. Ключ для upsert на вашей стороне. |
datetime | Время чекина. |
beer | id, name, style, logo_url. Название и стиль — null, если страницу сорта пока не разобрали. |
brewery_id | Владелец сорта. Совпадает с вашей пивоварней всегда, кроме строк, пришедших по include_collabs. |
is_collab | true — это чекин коллаба, сваренного у партнёра. |
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 строк — как те же списки на сайте. Рейтинги — исключение: витрины публичны целиком и листаются до конца. Сырых отзывов по чужой пивоварне здесь нет ни на одном маршруте — их нет и на сайте.
Рейтинги за месяц — те же самые, что на главной, на «Сортах» и на «Заведениях».
У пивоварен есть ещё и прошлый месяц (past_*), чтобы считать дельты.
| Параметр | Тип и значения | По умолчанию | Что делает |
|---|---|---|---|
sort | пивоварни и сорта: rating, checkins, users, venuesзаведения: checkins, rating, users, beers, breweries | rating; у заведений — checkins | Умолчание совпадает с сортировкой соответствующей страницы сайта. |
dir | asc | desc | desc | Направление. |
limit | целое 1…100 | 50 | Размер страницы. |
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" }
} showcase_stale, а не пустым списком. Считать рейтинг
вживую на каждый запрос мы не будем: это секунды работы базы. Карточки,
справочники и поиск при этом продолжают работать, у них метрики просто придут
как null с пометкой "source": "stale".
past_* тоже бывают null — это значит «в прошлом месяце
пивоварня не взяла порог», а не «ноль чекинов».
Батч на 50 номеров за раз. Он обязателен для любой витрины: сорок позиций иначе означали бы сорок запросов на каждое обновление и упор в минутный лимит.
| Параметр | Тип и значения | По умолчанию | Что делает |
|---|---|---|---|
ids | номера через запятую, до 50 | — обязателен | Больше 50 — лишние отбрасываются и в meta.depth_capped стоит true. Без ids — 400: маршрута «отдай весь справочник» не существует. Молча обрезается именно перебор номеров; строка длиннее 2048 символов — это 400 bad_param, в неё 50 номеров не укладываются ни при каком раскладе. |
If-None-Match. Витрину опрашивают по
кругу, и 304 здесь не стоит ни одной строки суточного кошелька.
Версия — та же, что у карточки, плюс сам список номеров: переставили номера
местами — это другой запрос и другой ETag.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 показывает, сколько запросили).
Карточка одной сущности. Нет такого номера или мы эту сущность не отслеживаем — 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.external | true — пивоварню мы не отслеживаем, и /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" — что метрики не считались вовсе.
Ассортимент пивоварни, страницами. Порядок фиксирован — свежие сорта впереди; сортировки у маршрута нет. Строки — те же объекты сорта, что в карточке.
| Параметр | Тип и значения | По умолчанию | Что делает |
|---|---|---|---|
limit | целое 1…100 | 50 | Размер страницы. |
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: там нет ни глубины, ни
кошелька строк, зато есть метрики за произвольный период.
Чекины и средняя оценка пивоварни по месяцам. Единственный живой агрегат в публичной части — отсюда фиксированная цена и настойчивая просьба пользоваться 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-е число, в первом месяце ровно половина.
Поиск по названию. Ответ намеренно короче карточки: имя, чем отличается от соседа, и номер, по которому берут остальное.
| Параметр | Тип и значения | По умолчанию | Что делает |
|---|---|---|---|
q | строка от 3 символов | — обязателен | Подстрока названия. Длина считается в символах: «пив» — это три буквы. Проценты и подчёркивания ищутся буквально. |
type | beer | brewery | venue | — обязателен | Что искать. |
brewery_id | целое | — | Только при type=beer: искать сорт внутри одной пивоварни. |
limit | целое 1…20 | 10 | Сколько вернуть. |
GET /api/v1/search?type=beer&q=melomel&limit=1
{
"data": [
{
"beer_id": 6804379,
"name": "Melomel Wild Pear Batch #2",
"style": "Mead - Melomel",
"abv": 14.0,
"image_url": "https://tappd.ru/beer_logo/6804379.webp",
"brewery": { "brewery_id": 460729, "name": "Ashram Cider", "external": false }
}
],
"meta": { "count": 1, "limit": 1, "has_more": false,
"generated_at": "2026-08-31T16:32:07+03:00", "contract": "2026-09-01" }
}
Страниц у поиска нет (has_more всегда false): не
нашлось за двадцать строк — уточняйте запрос. Точные совпадения идут первыми.
Справочники кодов: коды дефектов и коды тонов вкуса вместе с группами. База при
этом не трогается вовсе. Запросите один раз, положите у себя и сверяйте поле
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Забрать историю к себе и потом поддерживать её в актуальном состоянии — это два разных режима, и путать их нельзя.
Первая полная выгрузка. Запрашиваете /v1/my/checkins?limit=500,
берёте meta.next, передаёте его в cursor, повторяете, пока
meta.has_more не станет false. Строк это не стоит,
тратятся только запросы: самая крупная пивоварня в базе выкачивается примерно за
1 200 запросов, то есть влезает в суточный лимит с большим запасом.
from, to, beer_id,
rating_gte, rating_lte, include_collabs и
since, а вместе с ними маршрут и пивоварня. Прислали старый курсор
вместе с изменившимся любым из них — придёт 400 bad_param с
field: "cursor". Это защита: иначе у вас была бы дыра в выгрузке, о
которой вы бы никогда не узнали. Меняете фильтры — начинайте выгрузку заново,
без cursor.limit посреди выгрузки менять можно — на него курсор не завязан.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 историю не отдаст.
Access-Control-Allow-Origin нет и не будет: обратиться к API из
браузерного JavaScript означает положить ключ в код страницы, то есть отдать
выгрузку своей пивоварни любому, кто откроет исходник. Ходите за данными
со своего сервера. Если задача — показать блок «наши сорта» или
экран на стене, для этого есть готовые виджеты feed/: они не
требуют ключа вовсе, и ссылку на такой экран выдаёт поддержка./v1/my/*.beer_id, brewery_id, venue_id,
chekin_id — те же числа, что в адресах источника. Отдельного
«нашего» идентификатора нет, сопоставлять ничего не нужно.null. Не считайте по ним среднее: у нас оно тоже считается только
по чекинам с оценкой, и именно поэтому числа в API сходятся с числами на сайте.has_photo: true не гарантирует
photo_url. У части старых чекинов известен факт фото, но
не его адрес. Это разные вопросы, и поля отвечают на разные.null, если страницу сорта не
удалось разобрать — это не «сорт называется никак». То же и со стилем.
Пустых строк в этих полях не бывает нигде: ни на публичных маршрутах, ни на
/my/*, проверять достаточно на null. Обычно чинится
само в течение суток.
Оба делают одно и то же и полностью рабочие: первый запуск выкачивает всю историю
курсором, каждый следующий догружает изменения по since. Строки
кладутся upsert'ом по chekin_id, отметка синхронизации хранится рядом.
Запускать вторым скриптом по расписанию — раз в 15–30 минут более чем достаточно.
<?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";
#!/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.
source, served, status, кодах дефектов и
тонов со временем появятся значения, которых сегодня нет./v2/ не будет. Вторая версия при одном
разработчике означала бы вечную двойную поддержку, и обе половины поехали бы.Данные выдаются для собственных нужд подписчика: внутренние отчёты, своя аналитика, свои экраны и рассылки. Ключ именной — он выдан конкретному аккаунту, и передавать его третьим лицам нельзя. Перепродажа полученных данных и их публикация массивом запрещены: выгрузка предназначена вам, а не для перевыкладки её как своего набора данных или как чужого сервиса.
Заметили, что ключ утёк, — отзовите его в кабинете и выпустите новый; всё, что
выпущено раньше, продолжает работать, пока не отозвано.
Вопросы, ошибки и просьбы — в поддержку, и назовите
request_id из ответа или префикс своего ключа: по ним запрос
находится в журнале за секунду.