ФармМонитор.Опросы — Public API

v1

Read-only REST API для выгрузки опросов и результатов их прохождения. Мультитенантный ключ (Authorization: Bearer brf_live_…), keyset-пагинация с resume-курсором для инкрементальной догрузки, персональные данные респондентов не отдаются (псевдоним + аналитические сегменты). См. раздел «Быстрый старт» и «Стабильность идентификаторов» на /api/v1/docs.

Сервер: https://surveys.pharmmonitor.ru

Оглавление

Быстрый старт

  1. 1

    Получите ключ у команды ФармМонитор — платформенный ключ выдаётся вручную (панель /admin/integrations) и показывается один раз при создании. Формат: brf_live_<случайная строка>.

  2. 2

    Список опросов, доступных ключу:

    curl -H "Authorization: Bearer brf_live_ВАШ_КЛЮЧ" \
      https://surveys.pharmmonitor.ru/api/v1/surveys
  3. 3

    Структура анкеты — чтобы сопоставить block_id из ответов с текстом вопроса и вариантами:

    curl -H "Authorization: Bearer brf_live_ВАШ_КЛЮЧ" \
      https://surveys.pharmmonitor.ru/api/v1/surveys/42/structure
  4. 4

    Первая страница ответов — про постраничную и инкрементальную догрузку целиком см. раздел «Пагинация и инкрементал» ниже:

    curl -H "Authorization: Bearer brf_live_ВАШ_КЛЮЧ" \
      "https://surveys.pharmmonitor.ru/api/v1/surveys/42/responses?limit=500"

Аутентификация

Каждый запрос к /api/v1/* требует заголовок Authorization: Bearer brf_live_<ключ>. Ключ — платформенный (не привязан к пользовательской сессии), выдаётся командой ФармМонитор вручную в панели /admin/integrations и показывается один раз в момент создания.

  • Один ключ может быть выдан на конкретный список организаций или на все организации сразу («мультитенантный» доступ) — эндпоинт GET /api/v1/organizations возвращает, какие именно организации видит ваш ключ.
  • Отсутствие заголовка, схема, отличная от Bearer, пустой токен, а также неизвестный/отозванный/истёкший ключ — все эти случаи дают ОДИН и тот же ответ 401 {"code": "unauthorized"}. Наружу не раскрывается, какая именно из причин сработала — это осознанное решение, чтобы не давать атакующему различить «ключа не существует» от «ключ истёк».
  • Запрос к ресурсу чужой организации — это НЕ ошибка авторизации, а 404: платформа не подтверждает и не опровергает существование чужого опроса.

Эндпоинты

get/api/v1/surveys

Список опросов

Опросы всех организаций, доступных ключу. Кэш 60с на ключ.

Ответы

200OK
401Ключ API отсутствует, отозван или истёк.
ErrorResponse
{
  "error": {
    "code": "unauthorized",
    "message": "Ключ API отсутствует, отозван или истёк"
  }
}
429Превышен лимит запросов на ключ (60 rpm / burst 120 / 20 000 в сутки).

Заголовки: Retry-After — Секунд до следующей попытки.

ErrorResponse
{
  "error": {
    "code": "rate_limited",
    "message": "Превышен лимит запросов, повторите позже"
  }
}
500Непредвиденная ошибка сервера. Причина — в серверном логе, наружу код детали не раскрывает.
ErrorResponse
{
  "error": {
    "code": "internal_error",
    "message": "Внутренняя ошибка сервера"
  }
}
504Запрос к БД превысил statement_timeout (10с, см. §5 спеки/db.ts) — обычно на большом `limit` или тяжёлом диапазоне `completed_after`. Уменьшите `limit` и/или сузьте диапазон.
ErrorResponse
{
  "error": {
    "code": "timeout",
    "message": "Запрос превысил лимит времени выполнения — уменьшите limit или сузьте диапазон completed_after"
  }
}
get/api/v1/surveys/{id}

Карточка опроса

Параметры

ПараметрГдеТипОписание
idpathintegerобязательноЧисловой id опроса.

Ответы

200OK
401Ключ API отсутствует, отозван или истёк.
ErrorResponse
{
  "error": {
    "code": "unauthorized",
    "message": "Ключ API отсутствует, отозван или истёк"
  }
}
404Опрос не найден ЛИБО принадлежит организации, недоступной этому ключу — оба случая дают одинаковый ответ (§2 спеки: не раскрываем наружу существование чужого ресурса).
ErrorResponse
{
  "error": {
    "code": "not_found",
    "message": "Ресурс не найден"
  }
}
429Превышен лимит запросов на ключ (60 rpm / burst 120 / 20 000 в сутки).

Заголовки: Retry-After — Секунд до следующей попытки.

ErrorResponse
{
  "error": {
    "code": "rate_limited",
    "message": "Превышен лимит запросов, повторите позже"
  }
}
500Непредвиденная ошибка сервера. Причина — в серверном логе, наружу код детали не раскрывает.
ErrorResponse
{
  "error": {
    "code": "internal_error",
    "message": "Внутренняя ошибка сервера"
  }
}
504Запрос к БД превысил statement_timeout (10с, см. §5 спеки/db.ts) — обычно на большом `limit` или тяжёлом диапазоне `completed_after`. Уменьшите `limit` и/или сузьте диапазон.
ErrorResponse
{
  "error": {
    "code": "timeout",
    "message": "Запрос превысил лимит времени выполнения — уменьшите limit или сузьте диапазон completed_after"
  }
}
get/api/v1/surveys/{id}/structure

Структура анкеты

Плоский список блоков (не граф переходов). ПД-блоки и блоки без ответа (info_screen, presentation) исключены. Удалённые из анкеты вопросы остаются в списке с archived: true (их ответы по-прежнему приходят в responses). Кэш 60с на ключ+опрос.

Параметры

ПараметрГдеТипОписание
idpathintegerобязательноЧисловой id опроса.

Ответы

200OK
401Ключ API отсутствует, отозван или истёк.
ErrorResponse
{
  "error": {
    "code": "unauthorized",
    "message": "Ключ API отсутствует, отозван или истёк"
  }
}
404Опрос не найден ЛИБО принадлежит организации, недоступной этому ключу — оба случая дают одинаковый ответ (§2 спеки: не раскрываем наружу существование чужого ресурса).
ErrorResponse
{
  "error": {
    "code": "not_found",
    "message": "Ресурс не найден"
  }
}
429Превышен лимит запросов на ключ (60 rpm / burst 120 / 20 000 в сутки).

Заголовки: Retry-After — Секунд до следующей попытки.

ErrorResponse
{
  "error": {
    "code": "rate_limited",
    "message": "Превышен лимит запросов, повторите позже"
  }
}
500Непредвиденная ошибка сервера. Причина — в серверном логе, наружу код детали не раскрывает.
ErrorResponse
{
  "error": {
    "code": "internal_error",
    "message": "Внутренняя ошибка сервера"
  }
}
504Запрос к БД превысил statement_timeout (10с, см. §5 спеки/db.ts) — обычно на большом `limit` или тяжёлом диапазоне `completed_after`. Уменьшите `limit` и/или сузьте диапазон.
ErrorResponse
{
  "error": {
    "code": "timeout",
    "message": "Запрос превысил лимит времени выполнения — уменьшите limit или сузьте диапазон completed_after"
  }
}
get/api/v1/surveys/{id}/responses

Завершённые прохождения с ответами

Keyset-пагинация с resume-курсором. Не кэшируется.

Параметры

ПараметрГдеТипОписание
idpathintegerобязательноЧисловой id опроса.
cursorquerystringопциональноOpaque base64url keyset-курсор из next_cursor предыдущего ответа. Без параметра — выгрузка с самого начала (или с completed_after, если он передан).
limitqueryinteger (по умолчанию 100)опциональноРазмер страницы. По умолчанию 100, максимум 500.
completed_afterquerystringопциональноISO 8601 — вернуть только прохождения, завершённые СТРОГО ПОСЛЕ этой даты. Альтернатива курсору для первого запуска/пересбора.
statusquery"complete" | "over_quota" (по умолчанию "complete")опциональноФильтр по статусу прохождения. По умолчанию complete.

Ответы

200OK
400Некорректный query-параметр (`cursor`/`limit`/`completed_after`/`status`) — коды: invalid_cursor, invalid_limit, invalid_completed_after, invalid_status. Отдельный код `invalid_params` — `cursor` и `completed_after` переданы ОДНОВРЕМЕННО (это два взаимоисключающих способа задать точку старта выборки, см. «Пагинация и инкрементал» в доке).
401Ключ API отсутствует, отозван или истёк.
ErrorResponse
{
  "error": {
    "code": "unauthorized",
    "message": "Ключ API отсутствует, отозван или истёк"
  }
}
404Опрос не найден ЛИБО принадлежит организации, недоступной этому ключу — оба случая дают одинаковый ответ (§2 спеки: не раскрываем наружу существование чужого ресурса).
ErrorResponse
{
  "error": {
    "code": "not_found",
    "message": "Ресурс не найден"
  }
}
429Превышен лимит запросов на ключ (60 rpm / burst 120 / 20 000 в сутки).

Заголовки: Retry-After — Секунд до следующей попытки.

ErrorResponse
{
  "error": {
    "code": "rate_limited",
    "message": "Превышен лимит запросов, повторите позже"
  }
}
500Непредвиденная ошибка сервера. Причина — в серверном логе, наружу код детали не раскрывает.
ErrorResponse
{
  "error": {
    "code": "internal_error",
    "message": "Внутренняя ошибка сервера"
  }
}
504Запрос к БД превысил statement_timeout (10с, см. §5 спеки/db.ts) — обычно на большом `limit` или тяжёлом диапазоне `completed_after`. Уменьшите `limit` и/или сузьте диапазон.
ErrorResponse
{
  "error": {
    "code": "timeout",
    "message": "Запрос превысил лимит времени выполнения — уменьшите limit или сузьте диапазон completed_after"
  }
}
get/api/v1/organizations

Организации, доступные ключу

Ответы

200OK
401Ключ API отсутствует, отозван или истёк.
ErrorResponse
{
  "error": {
    "code": "unauthorized",
    "message": "Ключ API отсутствует, отозван или истёк"
  }
}
429Превышен лимит запросов на ключ (60 rpm / burst 120 / 20 000 в сутки).

Заголовки: Retry-After — Секунд до следующей попытки.

ErrorResponse
{
  "error": {
    "code": "rate_limited",
    "message": "Превышен лимит запросов, повторите позже"
  }
}
500Непредвиденная ошибка сервера. Причина — в серверном логе, наружу код детали не раскрывает.
ErrorResponse
{
  "error": {
    "code": "internal_error",
    "message": "Внутренняя ошибка сервера"
  }
}
504Запрос к БД превысил statement_timeout (10с, см. §5 спеки/db.ts) — обычно на большом `limit` или тяжёлом диапазоне `completed_after`. Уменьшите `limit` и/или сузьте диапазон.
ErrorResponse
{
  "error": {
    "code": "timeout",
    "message": "Запрос превысил лимит времени выполнения — уменьшите limit или сузьте диапазон completed_after"
  }
}

Объект прохождения и типы value

Форма прохождения (ResponseObject) и каждого отдельного ответа (AnswerEntry) — ниже, вместе с полным словарём схем. Форма поля value зависит от типа блока (ChoiceValue, MatrixRowValue, DiaryAnswerValue и т.д.) — см. AnswerValue в словаре.

Персональные данные не отдаются нигде. ФИО, email, телефон — не поля ни одного объекта API. Блоки contacts/email/phone/respondent_passport/consent исключены из structure и из responses[].answers целиком (не value: null — записи нет вовсе). Респондент — детерминированный псевдоним (respondent.id, стабильный в рамках одной организации, см. «Стабильность идентификаторов») плюс аналитические сегменты (роль, специализация, город, статус верификации).
Свободный текст может содержать вольный ввод врача. Ответы на open_text/final_comment, а также вложенные текстовые поля diary (дневник назначений) и followup (уточнения по ответам) — неструктурированный текст, который респондент вписывает сам. Это не ПД-поля платформы, но по содержанию свободный текст не контролируется схемой API — обрабатывайте его как потенциально содержащий чувствительные врачебные детали.
То же относится к элементам city_picker («Город») с freeform: true — город, который респондент не выбрал из подсказки справочника адресов, а вписал вручную (region/federal_district/fias_id в этом случае — null). Это обычный пользовательский текст, а не проверенное название населённого пункта: обрабатывайте его так же, как любой другой свободный текст выше.
Удалённые из анкеты вопросы остаются в structure с archived: true. Если автор анкеты удаляет вопрос (soft-delete) уже после того, как на него собрали ответы, блок не пропадает из GET /surveys/{id}/structure — он остаётся в списке с archived: true (после активных блоков анкеты), потому что GET /surveys/{id}/responses продолжает отдавать исторические ответы на него (данные не теряются). Без этого поля block_id из старых ответов не находился бы в structure вовсе — ориентируйтесь на archived, чтобы отличать текущие вопросы анкеты от удалённых.
OrganizationRef

Организация, доступная ключу.

ПолеТипОписание
idintegerобязательно
namestringобязательно
SurveySummary

Карточка опроса (GET /api/v1/surveys, GET /api/v1/surveys/{id}).

ПолеТипОписание
idintegerобязательно
titlestringобязательно
statusstringобязательно
created_atstringобязательноISO 8601 UTC
launched_atstringлибоnullобязательноISO 8601 UTC; null — опрос ещё не запускался
completed_atstringлибоnullобязательноISO 8601 UTC; null — опрос ещё не завершён
archived_atstringлибоnullобязательноISO 8601 UTC; null — опрос не в архиве
responses_countintegerобязательноЖивой счётчик завершённых прохождений (complete|over_quota, без удалённых)
organizationOrganizationRefобязательно
StructureOption

Один вариант ответа/строка матрицы в структуре анкеты.

ПолеТипОписание
idstringобязательноopt_<index> (choice-семейство) либо row_<index> (строки статической matrix_scale)
labelstringобязательно
StructureBlock

Один блок анкеты (GET /api/v1/surveys/{id}/structure). ПД-блоки (contacts/email/phone/respondent_passport/consent) и блоки без ответа (info_screen, presentation) ИСКЛЮЧЕНЫ из выдачи целиком — тот же предикат применён и к responses[].answers, поэтому block_id из structure всегда соответствует block_id, который может встретиться в ответах. Удалённые из анкеты вопросы (soft-delete) НЕ исключаются — остаются в structure с archived: true, после активных блоков анкеты.

ПолеТипОписание
block_idintegerобязательно
block_typestringобязательноТип блока платформы (single_choice, checkboxes, matrix_scale, diary, followup, …)
questionstringобязательно
optionsarray of StructureOptionлибоnullобязательноВарианты/строки для choice-семейства и matrix_scale; null для остальных типов
settingsobjectлибоnullобязательноКурированное подмножество настроек, влияющих на интерпретацию value (НЕ сырой дамп block.settings — редакторские поля вроде visibilityRule/media исключены). Примеры по семействам: шкалы — scale_min/scale_max; slider — min/max/step/unit; number — min_value/max_value/integer_only; matrix_scale — scale_min/scale_max/column_labels/require_all_rows/scale_kind ("nominal" — колонки — варианты ответа: label в ответах — название колонки, среднее по кодам не имеет смысла)/na_last_column (true — последняя колонка — «нет ответа»: код ответа scale_max, в среднее не входит); datetime_picker — include_time/date_min/date_max/date_restriction; file_upload — allowed_file_types/max_size_mb; choice-семейство — has_other; справочники — dictionary_mode/list_mode/allow_freeform; followup — questions (под-вопросы как есть, публично-безопасны); city_picker — city_mode ("single"|"multi"); null — для типов без settings, влияющих на интерпретацию.
group
ПолеТипОписание
idintegerобязательно
namestringобязательно
либоnull
обязательноГруппа блоков графа; null — блок вне группы
archivedbooleanобязательноtrue — блок удалён из анкеты автором (soft-delete), но остаётся в structure, потому что ответы на него продолжают отдаваться в responses[].answers (исторические данные не теряются) — без этого поля block_id из старых ответов не находился бы в structure вовсе.
ChoiceValue

Одиночный выбор (single_choice*, ab_text, ab_image) и элемент множественного выбора (checkboxes). См. раздел «Стабильность идентификаторов» в доке — opt_<index> может сдвинуться между выгрузками, разделёнными правкой анкеты.

ПолеТипОписание
option_idstringобязательноopt_<index> по ТЕКУЩЕЙ позиции варианта в block.options на момент запроса (не заморожен на момент ответа респондента). Сентинелы: opt_other — свободный текст в «Другое»; opt_undecided/opt_refuse — системные варианты «Затрудняюсь ответить»/«Отказ от ответа»; opt_unknown — вариант удалён из анкеты ПОСЛЕ сбора ответа (label сохраняет исторический текст, но опция уже не резолвится к позиции в structure).
labelstringобязательно
RankingItem

Один элемент ответа на ranking-блок, в порядке rank.

ПолеТипОписание
option_idstringобязательно
labelstringобязательно
rankintegerобязательно1-based позиция в ранжировании респондента
MatrixRowValue

Один ответ строки matrix_scale.

ПолеТипОписание
row_idstringобязательноrow_<index> — позиция строки В ОТВЕТЕ (answerJson.rows), совпадает со structure для статических матриц. Для матриц с динамическими строками (settings.rowsFrom, options-piping) row_id стабилен ТОЛЬКО в рамках одного ответа — сопоставляйте строки между респондентами по row_label, не по row_id.
row_labelstringобязательно
option_idstringобязательноopt_<index> выбранной колонки либо opt_<value> для нестандартного значения
labelstringобязательно
DistributionRowValue

Один ответ строки distribution («Распределение 100%»). В отличие от MatrixRowValue здесь нет option_id/label — value уже сама доля в процентах, а не индекс выбранной колонки шкалы.

ПолеТипОписание
row_idstringобязательноrow_<index> — позиция строки в block.options (берётся из answerJson.displayOrder): совпадает с row_<index> в structure и стабильна между респондентами даже при включённом перемешивании строк.
row_labelstringобязательно
valuenumberобязательноДоля строки в процентах (0–100). Сумма value по всем строкам ОДНОГО ответа равна 100.
CityValue

Ответ блока city_picker («Город»): город из подсказки справочника адресов (регион, федеральный округ, код ФИАС) или введённый вручную (freeform: true, остальные поля null).

ПолеТипОписание
namestringобязательно
regionstringлибоnullобязательно
federal_districtstringлибоnullобязательно
fias_idstringлибоnullобязательно
freeformbooleanобязательно
DictionaryItem

Элемент ответа на блок-справочник (drug_picker, brand_picker, inn_picker, icd_picker, …).

ПолеТипОписание
idintegerлибоnullобязательноid записи в справочнике ФармМонитора; null — свободный текст вне справочника («Другое»)
labelstringобязательно
sourcestringобязательноКод справочника (drug/brand/inn/icd/…)
FileUploadValue

Метаданные загруженного файла — БЕЗ url (v1-решение спеки, файл по API не отдаётся).

ПолеТипОписание
namestringлибоnullобязательно
sizenumberлибоnullобязательноРазмер в байтах
mimestringлибоnullобязательно
DiaryAnswerValue

Структура дневника назначений (diary) как есть — валидированная v2-форма, каждая запись содержит одно/несколько назначений. Поля entries[] — свободная медицинская структура (диагноз, назначения, дозировки); значения полей freeform-словарей (МКБ/МНН/«Другое») могут содержать вольный текст, вписанный врачом от руки, не только выбор из справочника.

ПолеТипОписание
entriesarray of objectобязательноЗаписи дневника назначений (v2), поля см. в доке блока «Дневник назначений»
FollowupAnswerValue

Структура ответа на блок «Уточнения по ответам» (followup) как есть. answers[questionId] — string | string[] | number по типу под-вопроса; для open_text-под-вопросов и isOther-карточек значение — вольный текст респондента, может содержать произвольный ввод врача.

ПолеТипОписание
itemsarray of objectобязательноПо одной карточке на каждый выбранный вариант источника: {option, isOther, answers: {questionId: значение}}
AnswerValue

Полиморфная форма — конкретная форма зависит от block_type соседнего поля (см. описание AnswerEntry и раздел «Типы блоков» в доке). null — блок не был отвечен (пропущен ветвлением/необязательный) либо ответ не распознан. open_text/final_comment/datetime_picker — string|null; шкалы/number — number|null; single_choice-семейство — ChoiceValue|null; checkboxes — ChoiceValue[]; ranking — RankingItem[]; matrix_scale — MatrixRowValue[]; distribution — DistributionRowValue[]; city_picker — CityValue[]; справочники — DictionaryItem[]; file_upload — FileUploadValue|null; diary — DiaryAnswerValue|null; followup — FollowupAnswerValue|null.

ChoiceValueлибоarray of ChoiceValueлибоarray of RankingItemлибоarray of MatrixRowValueлибоarray of DistributionRowValueлибоarray of CityValueлибоarray of DictionaryItemлибоnumberлибоstringлибоFileUploadValueлибоDiaryAnswerValueлибоFollowupAnswerValueлибоnull
Respondent

Аналитический сегмент респондента. ФИО/email/телефон НЕ отдаются нигде в этом объекте.

ПолеТипОписание
idstringобязательноДетерминированный псевдоним (rsp_<hash>) — HMAC respondentId + соль из env, необратим. Стабилен во всех опросах ОДНОЙ организации; между организациями псевдонимы одного и того же респондента не совпадают.
rolestringлибоnullобязательноRespondent.subtype — аналитический сегмент роли (doctor/pharmacist/veterinarian/nurse/other_med), не ПД
specializationstringлибоnullобязательноRespondent.speciality
citystringлибоnullобязательно
verifiedbooleanобязательно
AnswerEntry

Один ответ прохождения. ПД-блоки и блоки без ответа (info_screen, presentation) отсутствуют как записи (не value: null — записи нет вовсе).

ПолеТипОписание
block_idintegerобязательно
block_typestringобязательно
questionstringобязательно
valueAnswerValueобязательно
ResponseObject

Одно завершённое прохождение опроса (GET /api/v1/surveys/{id}/responses). Контракт иммутабельности: после завершения прохождение не меняется, кроме respondent.id при анонимизации респондента.

ПолеТипОписание
idintegerобязательно
survey_idintegerобязательно
respondentRespondentлибоnullобязательноnull — анонимное прохождение (респондент не привязан)
started_atstringобязательноISO 8601 UTC
completed_atstringобязательноISO 8601 UTC
statusenum: "complete""over_quota"обязательно
answersarray of AnswerEntryобязательно
ErrorResponse

Единая форма ошибки для всех эндпоинтов /api/v1/*.

ПолеТипОписание
error
ПолеТипОписание
codestringобязательно
messagestringобязательно
обязательно
ListSurveysResponse

Опросы всех организаций, доступных ключу (без пагинации — см. §9 спеки).

ПолеТипОписание
dataarray of SurveySummaryобязательно
ListOrganizationsResponse

Организации, доступные ключу.

ПолеТипОписание
dataarray of OrganizationRefобязательно
ListStructureResponse

Плоский список блоков анкеты в порядке структуры (не граф переходов — v1 не отдаёт ветвление).

ПолеТипОписание
dataarray of StructureBlockобязательно
ListResponsesResponse

Страница завершённых прохождений (GET /api/v1/surveys/{id}/responses).

ПолеТипОписание
dataarray of ResponseObjectобязательно
next_cursorstringобязательноOpaque base64url keyset-курсор (completed_at, id). Возвращается ВСЕГДА, включая has_more=false — resume-курсор для инкрементальной догрузки.
has_morebooleanобязательно

Стабильность идентификаторов

option_id (opt_<index>) вычисляется по ТЕКУЩЕЙ позиции варианта в block.options на момент КАЖДОГО запроса, а не замораживается на момент сбора ответа. Если автор анкеты переименовывает вариант — option_id остаётся стабильным (по внутренней истории rename). Если автор удаляет вариант из середины списка — option_id всех вариантов ПОСЛЕ удалённого сдвигается: один и тот же исторический ответ на неизменившийся лейбл может получить РАЗНЫЙ opt_<index> в двух выгрузках, сделанных до и после правки анкеты.

Гарантия, которую API даёт: structure[].options[].id и responses[].answers[].value.option_id взаимно согласованы В РАМКАХ ОДНОГО запроса (один и тот же opt_<index> в обеих частях одного ответа API всегда указывает на один и тот же лейбл). Гарантии стабильности opt_<index> МЕЖДУ выгрузками, разделёнными редактированием анкеты, НЕТ.

Если вашей интеграции нужно сопоставлять ответы одного и того же варианта между выгрузками за разное время — используйте label, а не option_id, как ключ сопоставления, либо запрашивайте structure заново перед каждой сверкой и не кэшируйте option_id дольше одного запроса.

То же самое относится к row_id строк matrix_scale с динамическими строками (settings.rowsFrom, ответ на другой блок определяет набор строк в рантайме): row_id стабилен только в рамках одного ответа — между респондентами и между выгрузками сопоставляйте строки по row_label.

row_id строк distribution («Распределение 100%») динамических строк не знает — набор строк всегда статичен (block.options), как у matrix_scale без rowsFrom, поэтому row_id здесь стабилен между респондентами и между выгрузками (совпадает с row_<index> в structure). Тем не менее для сопоставления строк рекомендуем row_label — тот же принцип, что и для остальных типов с построчными ответами.

Иммутабельность и анонимизация: завершённое прохождение не меняется после completed_at — за одним исключением. Анонимизация респондента меняет respondent.id (тот же псевдоним, что был у прохождений этого респондента, перестаёт совпадать между выгрузками «до» и «после» анонимизации). Если для вашей интеграции важна такая консистентность — периодически делайте полный пересбор выгрузки, а не полагайтесь на инкрементал бесконечно.

respondent.id стабилен во всех опросах ОДНОЙ организации — один и тот же псевдоним у всех прохождений одного человека внутри опросов одного владельца. МЕЖДУ организациями псевдонимы одного и того же респондента НЕ совпадают: если ваш ключ видит несколько организаций, не полагайтесь на respondent.id как на кросс-организационный идентификатор — это намеренное ограничение, а не артефакт.

Пагинация и инкрементал

Эндпоинт GET /api/v1/surveys/{id}/responses отдаёт завершённые прохождения через keyset-пагинацию: страница ответа содержит has_more (есть ли ещё данные) и next_cursor — opaque base64url-курсор, который нужно подставить в следующий запрос параметром cursor.

Важный нюанс: next_cursor приходит в ответе ВСЕГДА, даже когда has_more: false — это resume-курсор «после последней уже отданной строки». Именно его нужно сохранять для следующего запуска интеграции, а не полагаться на дату/время локально.

curl -H "Authorization: Bearer brf_live_ВАШ_КЛЮЧ" \
  "https://surveys.pharmmonitor.ru/api/v1/surveys/42/responses?limit=500"

# сохраните next_cursor из тела ответа и подставьте в следующий запрос:
curl -H "Authorization: Bearer brf_live_ВАШ_КЛЮЧ" \
  "https://surveys.pharmmonitor.ru/api/v1/surveys/42/responses?limit=500&cursor=<next_cursor>"

# повторяйте, пока has_more не станет false

Инкрементальная догрузка (следующий запуск загрузчика — например раз в час): сохраните ПОСЛЕДНИЙ полученный next_cursor и на следующем запуске продолжите с него, без ручного управления датами:

curl -H "Authorization: Bearer brf_live_ВАШ_КЛЮЧ" \
  "https://surveys.pharmmonitor.ru/api/v1/surveys/42/responses?cursor=<сохранённый_next_cursor>"

Альтернатива для первого запуска/пересбора данных за период — параметр completed_after (ISO 8601, строго «после»); её нельзя комбинировать с cursor в одном запросе.

Лимиты

60

Запросов в минуту

120

Всплеск (burst)

20 000

Суточная квота

Лимиты считаются на ключ API. При превышении сервер отвечает 429 с заголовком Retry-After (секунд до следующей попытки).

GET /api/v1/surveys и GET /api/v1/surveys/{id}/structure кэшируются 60 секунд на стороне сервера (в разрезе ключа) — повторный запрос в это окно не расходует лимит на новую выборку из БД, но по-прежнему учитывается в rate limit.

Изменение доступа ключа (сужение или расширение списка организаций) применяется не мгновенно — до 60 секунд, пока не истечёт кэш surveys/structure для этого ключа: организация, только что добавленная/убранная в панели «Интеграции», может ещё минуту оставаться видимой/невидимой по-старому в этих двух эндпоинтах. Отзыв ключа — исключение: он действует мгновенно (следующий же запрос получает 401), кэш здесь ни при чём — авторизация проверяется на каждый запрос заново, до чтения из кэша.

Changelog

v12026-08-24

  • Псевдонимы respondent.id стали пер-организационными (PMS-370): стабильны в рамках одной организации, между организациями не совпадают. Значения всех псевдонимов изменились разово (внешних потребителей на момент перехода не было).

v12026-08-22

  • Первая версия публичного API: surveys, surveys/{id}, surveys/{id}/structure, surveys/{id}/responses, organizations.
  • Keyset-пагинация с resume-курсором, rate limit 60 rpm / burst 120 / 20 000 в сутки на ключ.