FORMAT: 1A HOST: https://loyella.ru # Loyella API v1 Публичный HTTP API сервиса электронных карт Loyella (Apple Wallet, Google Wallet, Telegram). Через API вы выпускаете и обновляете карты, начисляете и списываете баллы, ставите штампы, погашаете подарочные карты, читаете состояние карт и подписываетесь на события через вебхуки. Документ написан в формате **API Blueprint** (Apiary) — его можно импортировать в Apiary, Postman или Stoplight. Единый источник правды: этот файл; страница [loyella.ru/developers](https://loyella.ru/developers) — его человекочитаемая версия. Комментарии и подписи — по-русски; идентификаторы, ключи и код — латиницей (как в реальных запросах). ## Обзор (Overview) - **Base URL:** `https://loyella.ru` - **Версия:** все методы под префиксом `/v1`. Аддитивные изменения (новые поля/методы) выходят без смены версии; ломающие — только в новой версии (`/v2`). - **Формат:** запросы и ответы — JSON (`Content-Type: application/json`). - **Обёртка ответа:** успешный ответ — `{ "data": … }`. До 15.07.2026 было одно исключение — выпуск карты (`POST /v1/passes`) отдавался плоским объектом без обёртки `data`; это оказалось ошибкой (инцидент #326): реальная 1С Fitness 53.1 читает РОВНО `data.serial_number/pass_number/ link`. Исключение снято, форма приведена к донору бит-в-бит, см. раздел «Совместимость». - **Кодировка:** UTF-8. - **Неизвестный путь** под `/v1` возвращает JSON `404` (а не HTML) — инвариант совместимости 1С. ## Аутентификация (Authentication) Каждый запрос подписывается парой ключей организации в заголовках: ``` X-Client-Id: client-- X-Client-Secret: <секрет> ``` - Ключи выпускаются в личном кабинете: раздел **«Интеграции»** (доступен на тарифе Pro). Секрет показывается **один раз** при выпуске — сохраните его; в базе хранится только его bcrypt-хэш. - Любой отказ авторизации (нет заголовков / неизвестный `client_id` / неверный секрет / организация приостановлена) — единый ответ `403`: ```json { "error": { "code": 403, "message": "Forbidden" } } ``` - Все данные жёстко изолированы по организации: чужие карты, поля и подписки недостижимы, а чужой серийный номер отдаёт `404` (существование чужой карты не раскрывается). ## Идемпотентность (Idempotency) Повторная отправка мутации (POST/PATCH/PUT/DELETE) с тем же ключом идемпотентности не выполняет операцию второй раз, а возвращает **сохранённый первый ответ** дословно. Это защищает от двойных списаний/начислений при ретраях по таймауту или 5xx. **Как задаётся ключ:** - Общий случай — заголовок `Idempotency-Key` (принимается и написание `Idempotence-Key`). - **Денежные пути** — `POST /v1/passes/{serial}/points/credit|debit`, `.../redeem`, `.../stamps/set` — ключом служит **обязательное поле тела `operation_id`**, и оно приоритетнее заголовка. Причина: некоторые клиенты регенерируют заголовок на каждый ретрай (это задвоило бы деньги), а `operation_id` — стабильный бизнес-ключ операции. **Поведение повтора:** - Тот же ключ + то же тело → сохранённый ответ, заголовок `Idempotent-Replay: true`, без побочных эффектов. - Тот же ключ + **другое** тело → `409` (коллизия «ключ ↔ тело»). - Запрос с этим ключом ещё обрабатывается → `409` («повторите позже»). - Ключ хранится ~24 часа (настраивается на стороне сервиса). **ВАЖНОЕ ПРЕДУПРЕЖДЕНИЕ.** Реплеится ЛЮБОЙ сохранённый ответ, **включая ошибки 4xx** (например, `404 Pass not found` или `422 Недостаточно средств`). Если первая попытка вернула исправимую 4xx (пополнили баланс, исправили серийный номер), повторяйте её с **новым** `operation_id` / `Idempotency-Key` — иначе получите закэшированную ошибку, пока не истечёт срок хранения ключа. Кэшируются только ответы со статусом < 500; ответы 5xx не кэшируются и корректно переисполняются при ретрае. Ключ идемпотентности опционален: без него унаследованные вызовы 1С Fitness работают как раньше, байт-в-байт. ## Лимиты запросов (Rate limits) - По умолчанию **600 запросов в минуту на организацию** (щедрый порог — защита прода от лавины, а не тарифный лимит; настраивается на стороне сервиса). - Заголовки на каждом ответе: - `RateLimit-Limit` — потолок за окно. - `RateLimit-Remaining` — сколько запросов осталось. - `RateLimit-Reset` — секунд до сброса окна. - При превышении — `429` + `Retry-After`: ```json { "error": { "code": 429, "message": "Слишком много запросов — попробуйте позже." } } ``` ## Формат ошибок (Errors) Единый конверт ошибки: ```json { "error": { "code": 422, "message": "Недостаточно средств на балансе", "field": "bonuses", "balance": 40 } } ``` - `code` — как правило, дублирует HTTP-статус. У части унаследованных методов 1С (пакетный `PATCH /v1/passes`, `POST /v1/webhooks` с ошибкой валидации) поле `code` может отсутствовать — тогда есть только `message`. - Отдельные валидации несут дополнительные поля: `field`, `balance`, `target`. | Статус | Когда | |--------|-------| | `400` | Некорректное тело запроса (например, отсутствует `url` у вебхука; ключ идемпотентности длиннее 255 символов) | | `402` | Недостаточно средств на балансе организации для выпуска карты | | `403` | Ошибка аутентификации / организация приостановлена | | `404` | Карта / вид / вебхук не найдены (или принадлежат другой организации) | | `409` | Конфликт идемпотентности (тот же ключ с другим телом / запрос ещё в обработке) | | `422` | Бизнес-ошибка (недостаточно средств на карте, штампы не настроены, некорректный параметр) | | `429` | Превышен лимит запросов | | `500` | Внутренняя ошибка (безопасно ретраить — не кэшируется идемпотентностью) | # Group Карты — Cards Выпуск, чтение, обновление и удаление карт. `project_id` в теле/пути справочных методов — это **внешний номер вида карты** в вашей организации (тот, что вы задаёте в 1С), а не внутренний id. ## Одна карта [/v1/passes/{serial}] + Parameters + serial: `a1b2c3d4-...` (string, required) - серийный номер (UUID) или номер карты (`pass_number`). ### Прочитать карту [GET] Полное состояние карты: поля, статус, вид, число установок на устройства, метки времени, ссылка на установку. Резолвится сначала по `serial_number`, затем по `pass_number`. Чужой/несуществующий серийный → `404`. ``` curl https://loyella.ru/v1/passes/a1b2c3d4-0000-0000-0000-000000000001 \ -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" ``` + Response 200 (application/json) { "data": { "serial_number": "a1b2c3d4-0000-0000-0000-000000000001", "pass_number": "CARD-000123", "project_id": 1, "status": "active", "voided": false, "created_via": "api", "fields": { "first_name": "Иван", "last_name": "Петров", "bonuses": "150", "phone": "+79990001122" }, "point_id": 4, "registrations": 1, "created_at": "2026-07-01T10:00:00.000Z", "updated_at": "2026-07-10T12:30:00.000Z", "link": "https://loyella.ru/pass/a1b2c3d4-0000-0000-0000-000000000001" } } + Response 404 (application/json) { "error": { "code": 404, "message": "Pass not found" } } ### Удалить карту [DELETE] Удаляет карту организации. **Идемпотентно** (#329, инцидент W6, 15.07.2026, как у донора 1С Fitness): повторное удаление, удаление уже отсутствующей карты и удаление чужого серийного — ВСЕГДА `200 {data:{serial_number,status:"deleted",success:true}}`, существование чужой карты не раскрывается кодом ответа. Для «мягкого» аннулирования (карта остаётся, но помечается voided) используйте `PATCH /v1/passes/{serial}` с телом `{"voided": true}`. ``` curl -X DELETE https://loyella.ru/v1/passes/a1b2c3d4-0000-0000-0000-000000000001 \ -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" ``` + Response 200 (application/json) { "data": { "serial_number": "a1b2c3d4-0000-0000-0000-000000000001", "status": "deleted", "success": true } } ### Обновить одну карту [PATCH] Поверхностный мерж переданных полей. `{"voided": true}` аннулирует карту. После обновления карта пушится на устройства, изменения фиксируются в журнале, отправляется вебхук. ``` curl -X PATCH https://loyella.ru/v1/passes/a1b2c3d4-0000-0000-0000-000000000001 \ -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" \ -H "Content-Type: application/json" \ -d '{"phone":"+79995556677","bonuses":"200"}' ``` + Request (application/json) { "phone": "+79995556677", "bonuses": "200" } + Response 200 (application/json) { "data": { "serial_number": "a1b2c3d4-0000-0000-0000-000000000001", "pass_number": "CARD-000123", "status": "updated", "success": true, "fields": { "first_name": "Иван", "phone": "+79995556677", "bonuses": "200" } } } + Response 404 (application/json) { "error": { "message": "Pass not found" } } ## Список и выпуск карт [/v1/passes] ### Список / поиск карт [GET] Два режима: 1. **Точечный поиск** по одному идентификатору: `?serial_number=` | `?pass_number=` | `?phone=` (телефон нормализуется для дедупликации). Возвращает список из 0..1 карт. 2. **Фильтруемый список:** `?query=` (ФИО/номер), `?status=active|voided` (или `?voided=true|false`), `?project_id=` (внешний номер вида), `?updated_since=` (инкрементальный синк), `?limit=` (по умолчанию 50, максимум 100), `?offset=`. ``` curl "https://loyella.ru/v1/passes?query=Петров&status=active&limit=20" \ -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" # Точечный поиск по телефону: curl "https://loyella.ru/v1/passes?phone=%2B79990001122" \ -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" ``` + Response 200 (application/json) { "data": { "items": [ { "serial_number": "a1b2c3d4-0000-0000-0000-000000000001", "pass_number": "CARD-000123", "project_id": 1, "status": "active", "voided": false, "first_name": "Иван", "last_name": "Петров", "fields": { "first_name": "Иван", "last_name": "Петров", "bonuses": "150" }, "created_at": "2026-07-01T10:00:00.000Z", "updated_at": "2026-07-10T12:30:00.000Z", "link": "https://loyella.ru/pass/a1b2c3d4-0000-0000-0000-000000000001" } ], "total": 1, "limit": 20, "offset": 0 } } ### Выпустить карту [POST] Создаёт карту выбранного вида. `project_id` — внешний номер вида в вашей организации (по умолчанию `1`). Номер карты берётся из нумератора организации. Выпуск списывает годовую ставку с баланса той же транзакцией; при нехватке средств → `402`, карта не создаётся. Укажите `created_via: "test"` для бесплатного тестового выпуска (не идёт в счёт). **Ответ — с обёрткой `data`, бит-в-бит донор** (`project_id` в ответе — внешний номер вида, НЕ internal `projects.id`, инцидент #325; `fields` — как сохранены в карте). До 15.07.2026 ответ был плоским, без обёртки `data`, — это была ошибка (инцидент #326): реальная 1С Fitness 53.1 читает РОВНО `data.serial_number/pass_number/link`. ``` curl -X POST https://loyella.ru/v1/passes \ -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" \ -H "Content-Type: application/json" \ -d '{"project_id":1,"fields":{"first_name":"Иван","last_name":"Петров","phone":"+79990001122"}}' ``` + Request (application/json) { "project_id": 1, "fields": { "first_name": "Иван", "last_name": "Петров", "phone": "+79990001122" }, "created_via": "api" } + Response 201 (application/json) { "data": { "serial_number": "a1b2c3d4-0000-0000-0000-000000000001", "pass_number": "CARD-000123", "link": "https://loyella.ru/pass/a1b2c3d4-0000-0000-0000-000000000001", "project_id": 1, "fields": { "first_name": "Иван", "last_name": "Петров", "phone": "+79990001122" } } } + Response 402 (application/json) { "error": { "code": 402, "message": "Недостаточно средств на балансе для выпуска карты" } } + Response 404 (application/json) { "error": { "code": 404, "message": "Project not found" } } ### Обновить набор карт (batch) [PATCH] Пакетное обновление полей. Принимает массив `[{serial_number, fields}]` (или один объект). Ответ — карта результатов по серийным номерам (форма, совместимая с 1С Fitness). Основной метод, вызываемый из 1С Fitness. Ненайденная карта → `{success:false, status:404, error:{code:"not_found", message:"Pass not found"}}` в её элементе карты результатов (#329, инцидент W3, 15.07.2026) — 1С самоочистку устаревшей карты запускает ИМЕННО по `error.code`. ``` curl -X PATCH https://loyella.ru/v1/passes \ -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" \ -H "Content-Type: application/json" \ -d '[{"serial_number":"a1b2...001","fields":{"bonuses":"300"}}]' ``` + Request (application/json) [ { "serial_number": "a1b2c3d4-0000-0000-0000-000000000001", "fields": { "bonuses": "300" } } ] + Response 200 (application/json) { "data": { "a1b2c3d4-0000-0000-0000-000000000001": { "serial_number": "a1b2c3d4-0000-0000-0000-000000000001", "pass_number": "CARD-000123", "status": "updated", "success": true } } } ## Одни поля списку карт [/v1/passes/by-fields] ### Записать одни и те же поля списку карт [PATCH] Массовая правка одного набора полей у перечисленных карт. Ответ — карта результатов по серийным. Ненайденная карта (свой тенант, но нет такого serial) → `error.code: "not_found"` (#329, инцидент W3, 15.07.2026) — по этому коду 1С (`ОбновитьМассивКарт`) запускает самоочистку устаревшей карты в своей базе; та же форма ошибки — у батча `PATCH /v1/passes`. ``` curl -X PATCH https://loyella.ru/v1/passes/by-fields \ -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" \ -H "Content-Type: application/json" \ -d '{"serial_numbers":["a1b2...001","a1b2...002"],"fields":{"tier":"gold"}}' ``` + Request (application/json) { "serial_numbers": ["a1b2c3d4-...-001", "a1b2c3d4-...-002"], "fields": { "tier": "gold" } } + Response 200 (application/json) { "data": { "a1b2c3d4-...-001": { "serial_number": "a1b2c3d4-...-001", "pass_number": "CARD-000123", "status": "updated", "success": true }, "a1b2c3d4-...-002": { "serial_number": "a1b2c3d4-...-002", "error": { "code": "not_found", "message": "Pass not found" }, "success": false, "status": 404 } } } ## Журнал событий карты [/v1/passes/{serial}/events{?type,limit,offset}] + Parameters + serial: `a1b2c3d4-...` (string, required) + type: `points_earn` (string, optional) - фильтр по типу события. + limit: `50` (number, optional) - размер страницы (по умолчанию 50, максимум 100). + offset: `0` (number, optional) - смещение. ### Журнал событий [GET] События карты (выпуск, установка, штампы, баллы, рассылки и т.д.), новейшие первыми. Внутренние поля `meta` (id кассира, id устройства и пр.) наружу не отдаются — только безопасное человекочитаемое подмножество в `details`. Чужой/несуществующий серийный → `404`. ``` curl "https://loyella.ru/v1/passes/a1b2c3d4-...-001/events?type=points_earn&limit=20" \ -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" ``` + Response 200 (application/json) { "data": { "items": [ { "id": 812, "type": "points_earn", "source": "api", "created_at": "2026-07-13T14:05:00Z", "details": { "field": "bonuses", "delta": 150, "before": 150, "after": 300, "operation_id": "sale-4417" } }, { "id": 640, "type": "issue", "source": "api", "created_at": "2026-07-10T09:00:00Z", "details": { "channel": "api" } } ], "total": 2, "limit": 20, "offset": 0 } } # Group Штампы — Stamps Штамп-карты (собери N штампов — получи награду). Штампы настраиваются на **виде карты**; если у вида они выключены — методы отвечают `422`. ## Поставить штамп [/v1/passes/{serial}/stamp] + Parameters + serial: `a1b2c3d4-...` (string, required) ### Поставить один штамп (+1) [POST] Инкремент на 1 с логикой награды: при достижении цели выдаётся награда (`reward_text`), счётчик сбрасывается в 0. Кулдауна нет (в отличие от кассирского сканера) — повторный POST это намеренное действие. Для защиты от двойного штампа при ретрае передавайте заголовок `Idempotency-Key`. ``` curl -X POST https://loyella.ru/v1/passes/a1b2...001/stamp \ -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" \ -H "Idempotency-Key: stamp-2026-07-13-17-42" ``` + Response 200 (application/json) { "data": { "serial_number": "a1b2c3d4-...-001", "pass_number": "CARD-000123", "stamps": { "current": 4, "target": 6 }, "rewarded": false, "success": true } } + Response 422 (application/json) { "error": { "code": 422, "message": "Штампы не настроены для этого вида карты" } } ## Установить счётчик штампов [/v1/passes/{serial}/stamps/set] + Parameters + serial: `a1b2c3d4-...` (string, required) ### Установить абсолютное значение [POST] Задаёт счётчик штампов в точное значение `count` (0..target) — для импорта/коррекции. Награда **не** триггерится, даже если `count === target`. `operation_id` обязателен (ключ идемпотентности). ``` curl -X POST https://loyella.ru/v1/passes/a1b2...001/stamps/set \ -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" \ -H "Content-Type: application/json" \ -d '{"count":3,"operation_id":"import-2026-07-13-001"}' ``` + Request (application/json) { "count": 3, "operation_id": "import-2026-07-13-001" } + Response 200 (application/json) { "data": { "serial_number": "a1b2c3d4-...-001", "pass_number": "CARD-000123", "stamps": { "current": 3, "target": 6 }, "operation_id": "import-2026-07-13-001", "success": true } } + Response 422 (application/json) { "error": { "code": 422, "message": "count должен быть целым от 0 до 6", "target": 6 } } # Group Баллы и бонусы — Points Начисление и списание баллов/бонусов с журналом операций и идемпотентностью. Поле по умолчанию — `bonuses`; можно указать другое поле-счётчик (латиница/цифры/`_`, 1..64 символа). `operation_id` **обязателен** — это ключ идемпотентности проводки (без него ретрай 1С задвоил бы баллы). ## Начислить баллы [/v1/passes/{serial}/points/credit] + Parameters + serial: `a1b2c3d4-...` (string, required) ### Начислить [POST] ``` curl -X POST https://loyella.ru/v1/passes/a1b2...001/points/credit \ -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" \ -H "Content-Type: application/json" \ -d '{"amount":150,"field":"bonuses","operation_id":"sale-4417","source_doc":"Чек №4417"}' ``` + Request (application/json) { "amount": 150, "field": "bonuses", "operation_id": "sale-4417", "comment": "Начисление за покупку", "source_doc": "Чек №4417" } + Response 200 (application/json) { "data": { "serial_number": "a1b2c3d4-...-001", "pass_number": "CARD-000123", "field": "bonuses", "before": 150, "after": 300, "delta": 150, "operation_id": "sale-4417", "success": true } } ## Списать баллы [/v1/passes/{serial}/points/debit] + Parameters + serial: `a1b2c3d4-...` (string, required) ### Списать [POST] Та же механика, что начисление, но со знаком минус. Уход остатка ниже нуля запрещён — `422` с текущим остатком в поле `balance`. ``` curl -X POST https://loyella.ru/v1/passes/a1b2...001/points/debit \ -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" \ -H "Content-Type: application/json" \ -d '{"amount":50,"operation_id":"redeem-4418"}' ``` + Request (application/json) { "amount": 50, "field": "bonuses", "operation_id": "redeem-4418" } + Response 200 (application/json) { "data": { "serial_number": "a1b2c3d4-...-001", "pass_number": "CARD-000123", "field": "bonuses", "before": 300, "after": 250, "delta": -50, "operation_id": "redeem-4418", "success": true } } + Response 422 (application/json) { "error": { "code": 422, "message": "Недостаточно средств на балансе", "field": "bonuses", "balance": 40 } } # Group Погашение подарочных — Redeem ## Погасить подарочную карту / депозит [/v1/passes/{serial}/redeem] + Parameters + serial: `a1b2c3d4-...` (string, required) ### Погасить [POST] Частичное списание с номинала подарочной карты/депозита (поле по умолчанию — `balance`). Уход в минус запрещён (`422`). `void_at_zero: true` + остаток 0 → карта аннулируется в той же транзакции. `operation_id` обязателен. ``` curl -X POST https://loyella.ru/v1/passes/GIFT-000042/redeem \ -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" \ -H "Content-Type: application/json" \ -d '{"amount":300,"field":"balance","operation_id":"sale-9001","void_at_zero":true}' ``` + Request (application/json) { "amount": 300, "field": "balance", "operation_id": "sale-9001", "void_at_zero": true } + Response 200 (application/json) { "data": { "serial_number": "GIFT-000042", "pass_number": "GIFT-000042", "field": "balance", "before": 300, "after": 0, "redeemed": 300, "operation_id": "sale-9001", "voided": true, "success": true } } + Response 422 (application/json) { "error": { "code": 422, "message": "Недостаточно средств на карте", "field": "balance", "balance": 100 } } # Group Рассылки — Broadcasts Push-рассылка держателям карт (текст появляется на экране блокировки). Для точечного сообщения одному клиенту используйте `PATCH /v1/passes/{serial}` — рассылки предназначены для массовых отправок и ограничены **5 в сутки** на организацию. ## Рассылки [/v1/broadcasts] ### Запустить рассылку [POST] `operation_id` обязателен (ключ идемпотентности — повтор с тем же ключом не отправит второй раз). `target`: `"all"` (все активные карты), `{"project_id": N}` (карты вида), `{"serial": "…"}` или `{"serials": ["…"]}`. Ответ `202` — рассылка поставлена в очередь; `409` — предыдущая ещё идёт; `429` — исчерпан дневной лимит. Чужие/аннулированные/тестовые карты в списке `serials` молча исключаются (изоляция). ``` curl -X POST https://loyella.ru/v1/broadcasts \ -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" \ -H "Content-Type: application/json" \ -d '{"message":"Акция выходного дня!","target":{"project_id":1},"operation_id":"promo-2026-07-13"}' ``` + Request (application/json) { "message": "Акция выходного дня!", "target": { "project_id": 1 }, "operation_id": "promo-2026-07-13" } + Response 202 (application/json) { "data": { "id": 5501, "queued": 128, "status": "sending" } } + Response 409 (application/json) { "error": { "code": 409, "message": "Рассылка уже выполняется для этой организации — дождитесь завершения предыдущей" } } ## Статус рассылки [/v1/broadcasts/{id}] + Parameters + id: `5501` (number, required) - id рассылки из ответа POST. ### Статус рассылки [GET] Пока это последняя рассылка организации и она идёт — `status: "sending"` с прогрессом `sent`; иначе `completed` (`sent` = `queued`). Чужой/несуществующий id → `404`. ``` curl https://loyella.ru/v1/broadcasts/5501 -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" ``` + Response 200 (application/json) { "data": { "id": 5501, "message": "Акция выходного дня!", "target": "project:1", "queued": 128, "sent": 128, "status": "completed", "created_at": "2026-07-13T14:05:00Z" } } # Group Аналитика — Analytics Компактная сводка организации (только чтение) — для BI и учётных систем. ## Сводка [/v1/stats] ### Сводка организации [GET] Карты (всего/активные/аннулированные), установки в Apple Wallet, выпуск за сегодня и 30 дней, распределение активных карт по каналам выпуска (`api` — 1С, `admin` — кабинет, `form` — анкета). ``` curl https://loyella.ru/v1/stats -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" ``` + Response 200 (application/json) { "data": { "cards": { "total": 1240, "active": 1180, "voided": 60 }, "installs": { "apple": 902 }, "issued": { "today": 8, "last_30_days": 210, "by_channel": [ { "channel": "api", "count": 800 }, { "channel": "admin", "count": 200 }, { "channel": "form", "count": 180 } ] } } } # Group Реестр полей — Fields Известные поля организации (латинский ключ → русская подпись). Используется, чтобы CRM/1С и Loyella одинаково понимали, что означает каждый ключ полей карты. ## Реестр полей [/v1/fields] ### Получить поля [GET] Возвращает объявленный реестр плюс фактические ключи, встречающиеся в картах, но ещё не заведённые (`in_registry: false`). Плоские срезы `keys[]` / `labels{}` — для простой интеграции. ``` curl https://loyella.ru/v1/fields -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" ``` + Response 200 (application/json) { "data": { "fields": [ { "key": "first_name", "label": "Имя", "in_registry": true }, { "key": "bonuses", "label": "Баллы", "in_registry": true }, { "key": "tier", "label": "Tier", "in_registry": false } ], "keys": ["first_name", "bonuses", "tier"], "labels": { "first_name": "Имя", "bonuses": "Баллы", "tier": "Tier" } } } ### Обновить подписи полей (upsert) [PUT] Аддитивный upsert: новый ключ — создаётся, существующий — переименовывается подпись. Ничего не удаляется. Принимает `{fields:[{key,label}]}` или `{labels:{ключ:подпись}}`. Любая невалидная запись → `422`, реестр не меняется. ``` curl -X PUT https://loyella.ru/v1/fields \ -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" \ -H "Content-Type: application/json" \ -d '{"labels":{"tier":"Уровень","visits":"Визитов"}}' ``` + Request (application/json) { "labels": { "tier": "Уровень", "visits": "Визитов" } } + Response 200 (application/json) { "data": { "created": 1, "updated": 1, "fields": [ { "key": "first_name", "label": "Имя" }, { "key": "tier", "label": "Уровень" }, { "key": "visits", "label": "Визитов" } ] } } ## Манифест полей коннектора [/v1/field-manifest] ### Отправить манифест [POST] **Статус метода: НЕ обязательный, но настоятельно РЕКОМЕНДУЕМЫЙ** для разработчиков коннекторов. Без манифеста интеграция полностью работоспособна: карты можно создавать и обновлять с любыми ключами `fields` через `POST /v1/passes`/`PATCH`, а Loyella обнаруживает новые ключи автоматически из фактических данных карт (раздел «Обнаружены в данных, но не в списке» на «Настройки → Поля») — владелец заводит их в реестр вручную одним кликом, или сразу применяет типовой пресет учётной системы («Заполнить из пресета»). С манифестом реестр полей организации наполняется автоматически, с готовыми подписями и категориями (клиент/сотрудник) — владельцу не нужно ничего заводить руками, а в кабинете («Настройки → Интеграции») видна версия подключённого коннектора. Рекомендация: отправлять манифест один раз при каждом включении расширения и при каждой смене его версии или состава полей — тогда реестр в Loyella всегда соответствует тому, что реально умеет ваша интеграция. Коннектор 1С шлёт свою версию и структуру полей при включении/смене версии расширения — реестр наполняется аддитивно (та же семантика, что применение пресета: новый ключ создаётся с подписью и категорией манифеста, существующий не трогается вовсе — ни подпись, ни категория). Версия/момент манифеста сохраняются НЕЗАВИСИМО от того, добавились ли новые поля, и показываются тихой строкой на «Настройки → Интеграции». `category` не задана — категория выводится правилом (префикс `employee_` → сотрудник, иначе клиент). Любая невалидная запись → `400` со всеми причинами сразу (`error.message`, склеены через «; »), реестр не меняется. Ключи полей, объявленные последним манифестом, в кабинете («Настройки → Поля») показывают категорию ФИКСИРОВАННОЙ (значок замка вместо селектора) — категория такого поля приходит из вашего манифеста, и владелец не может рассинхронизировать её вручную в кабинете. ``` curl -X POST https://loyella.ru/v1/field-manifest \ -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" \ -H "Content-Type: application/json" \ -d '{"extension_version":"1.0.1.1","fields":[{"key":"membership_name","label":"Членство"},{"key":"employee_next_shift","label":"Следующая смена","category":"employee"}]}' ``` + Request (application/json) { "extension_version": "1.0.1.1", "fields": [ { "key": "membership_name", "label": "Членство" }, { "key": "employee_next_shift", "label": "Следующая смена", "category": "employee" } ] } + Response 200 (application/json) { "data": { "created": 2, "skipped": 0, "extension_version": "1.0.1.1" } } + Response 400 (application/json) { "error": { "code": 400, "message": "fields[0].key «bad key!»: латиница, цифры и _ (1..64 символа)" } } # Group Сверка с 1С (автогашение карт) — Sync Reconciliation Автоматическое аннулирование карт, которых больше нет в учётной системе. Ваш коннектор регламентным заданием шлёт **ПОЛНЫЙ срез `serial_number` всех живых карт организации**; карта, которой в срезе нет (или которая помечена на удаление), при определённых условиях аннулируется в Loyella. Цена ошибки асимметрична — ошибочно погашенная карта означает сломанную карту у живого клиента, а восстановление ручное, — поэтому все проверки ниже устроены так, чтобы **лучше не погасить, чем погасить лишнее**. **Режим работы — обязательно прочитать перед реализацией на стороне 1С:** - **Сверка идёт ТОЛЬКО по `serial_number`** — внутреннему коду карты Loyella, который хранится и на самой карте. Номер карты в 1С (`pass_number`) для сверки НЕ используется и непригоден: он не уникален (нумератор переиспользуется, номер карты можно сменить и т. п.). - **Присылайте ПОЛНЫЙ срез, а не дельту изменений.** Решение принимается только когда собраны ВСЕ части прогона; отсутствие карты в полном срезе и есть свидетельство «карты больше нет». Нужно прислать ВСЕ живые карты организации, а не только те, что изменились с прошлого прогона. - **Один `run_id` (GUID) на прогон.** Части нумеруются с 1 (`chunk`, `chunks_total`) и могут приходить в любом порядке. Открытие нового `run_id` автоматически закрывает («supersedes») любой прошлый незавершённый прогон этой организации — недосланный старый срез не подхватывается и не смешивается с новым. - **Карты, помеченные на удаление** — отдельным массивом `deleted_serial_numbers` (необязательно; можно вовсе не присылать этот массив). И «нет в срезе», и «есть, но помечена на удаление» трактуются одинаково: «в 1С этой карты больше нет». - **Обрыв связи.** Если передача прервалась на середине — не начинайте прогон заново: `GET /v1/sync/cards-snapshot/{run_id}` вернёт `chunks_missing` — номера недостающих частей. Дошлите ТОЛЬКО их (тем же `run_id` и тем же `chunks_total`). После финализации повторная отправка любой части безопасна (структурная идемпотентность) — она просто вернёт сохранённый итог, ничего не пересчитывая заново. - **Режим по умолчанию — наблюдение (`observe`).** Новая организация ничего не гасит автоматически: срезы принимаются, «сироты» (карты без пары в 1С) считаются и показываются владельцу в личном кабинете, но НИ ОДНА карта не аннулируется, пока владелец сам не включит боевой режим в кабинете Loyella. Боевых режимов ДВА, потому что сирота бывает двух природ: `enforce_missing` гасит только карты, которых нет в срезе вовсе (карточка в 1С удалена физически), а `enforce_all` — ещё и те, что в 1С есть, но помечены на удаление (за такой пометкой вполне может стоять живой клиент, поэтому по умолчанию их не трогают). До 26.07.2026 боевой режим был один и назывался `enforce` — он соответствует `enforce_all`. Действующий на данный момент режим виден в поле `mode` ответа `GET`; режим, который РЕАЛЬНО применялся при принятии решения по конкретному прогону, — в `result.mode` (они могут разойтись, если владелец сменил режим уже после того, как прогон завершился). - **Срез могут отклонить целиком** (решения `blocked_coverage` / `blocked_limit`, см. таблицу ниже) — это не ошибка запроса (HTTP-статус всё равно `200`), а защитный отказ гасить что-либо: - `blocked_coverage` — срез увидел меньше 95% нашей базы. Похоже на обрезанную или сломанную выгрузку (например, урезанный фильтр запроса на стороне 1С) — проверьте формирование среза и отправьте прогон заново целиком, с новым `run_id`. - `blocked_limit` — «сирот» больше разового лимита автоматики (2% базы, но не меньше 10 карт). Это не ошибка интеграции: нужно явное подтверждение владельца в кабинете Loyella (там виден список карт-кандидатов на аннулирование по этому прогону). **Прочие предохранители** (действуют независимо от режима и не настраиваются с стороны 1С): карта моложе 24 часов (или выпущенная позже начала текущего прогона) в сверке не участвует вовсе — не входит ни в знаменатель покрытия, ни в «сироты» (она физически могла не успеть доехать до 1С); в сверке участвуют только карты, выпущенные каналами `api` (через 1С-коннектор) и `import` (перенесённые миграцией из прежнего кошелька) — карты, заведённые из кабинета/анкеты/конструктора/ ИИ-агента, в 1С не существуют по определению, и их отсутствие в срезе ничего не значит. **Значения `decision` в `result`:** | `decision` | Что произошло | |------------|----------------| | `nothing` | Сирот нет — все карты найдены в 1С (или помеченных на удаление нет). Гасить нечего. | | `observed` | Ничего не погашено: либо режим наблюдения (`mode: "observe"`, дефолт нового клиента), либо `enforce_missing`, а все сироты оказались лишь помеченными на удаление. Сироты при этом найдены и посчитаны. | | `blocked_coverage` | Срез покрыл меньше 95% базы — гашение отменено целиком, независимо от режима. | | `blocked_limit` | Боевой режим, но карт К ГАШЕНИЮ больше разового лимита за прогон — нужно подтверждение владельца в кабинете. Лимит считается по тому, что режим реально гасит: в `enforce_missing` помеченные на удаление в него не входят. | | `voided` | Боевой режим, карт к гашению не больше лимита — они аннулированы автоматически прямо в этом запросе. | Разбивка сирот по природам приходит в `result` полями `orphans_missing` (карточки нет в срезе вовсе) и `orphans_deleted` (карта в 1С есть, помечена на удаление); их сумма равна `orphans`. У прогонов до 26.07.2026 оба поля `null` — тогда разбивка не сохранялась. ## Приём среза [/v1/sync/cards-snapshot] ### Отправить часть среза [POST] Принимает одну часть полного среза. Когда собраны ВСЕ части (`chunks_received` покрывает `1..chunks_total`) — приём этой, последней по счёту части СРАЗУ финализирует прогон: принимается решение (`result.decision`), а в боевом режиме тут же происходит аннулирование. Ответы `POST` и `GET` имеют одинаковую форму прогресса (`status`, `chunks_total`, `chunks_received`, `chunks_missing`, `serials_stored`, `started_at`), чтобы 1С могла использовать любой из методов для докачки недостающих частей. **Ограничения:** `run_id` — строка 1..64 символа (один GUID на весь прогон); `chunks_total` — целое 1..200; `chunk` — целое 1..`chunks_total` (нумерация с единицы, части можно слать в любом порядке); `serial_numbers` и `deleted_serial_numbers` — до 5000 строк в ОДНОЙ части (итого до 1 000 000 карт на прогон при максимуме частей), каждый `serial_number` — непустая строка не длиннее 128 символов; `source` — опциональная строка до 64 символов, метка для журнала (например, `"1c:ПолнаяСверка"`). `serial_numbers` обязателен как поле (массив, может быть пустым), если часть целиком состоит из удалённых карт. Как и у манифеста полей, все причины отказа собираются за один проход — 1С может исправить выгрузку одним циклом, а не по одной ошибке за прогон. ``` curl -X POST https://loyella.ru/v1/sync/cards-snapshot \ -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" \ -H "Content-Type: application/json" \ -d '{"run_id":"8f1c2e40-9b7a-4a11-8d2f-000000000001","chunk":2,"chunks_total":2,"serial_numbers":["a1b2...004"],"deleted_serial_numbers":["a1b2...099"]}' ``` + Request (application/json) { "run_id": "8f1c2e40-9b7a-4a11-8d2f-000000000001", "chunk": 2, "chunks_total": 2, "serial_numbers": ["a1b2c3d4-0000-0000-0000-000000000004"], "deleted_serial_numbers": ["a1b2c3d4-0000-0000-0000-000000000099"] } + Response 200 (application/json) Это была последняя недостающая часть — прогон собран целиком и СРАЗУ финализирован: принято решение `result.decision` (пример — дефолтный режим наблюдения: сироты посчитаны, ничего не погашено). { "data": { "run_id": "8f1c2e40-9b7a-4a11-8d2f-000000000001", "status": "complete", "chunks_total": 2, "chunks_received": 2, "chunks_missing": [], "serials_stored": 5, "started_at": "2026-07-26T09:00:00.000Z", "chunk": 2, "accepted": 2, "duplicates": 0, "repeated": false, "complete": true, "result": { "mode": "observe", "decision": "observed", "base": 1751, "matched": 1740, "coverage_ppm": 993718, "coverage_min_ppm": 950000, "orphans": 11, "voided": 0, "void_limit": 35, "completed_at": "2026-07-26T09:00:05.000Z" } } } + Response 400 (application/json) `chunks_total` этого запроса не совпадает с тем, что было в первой части этого `run_id` — части разных выгрузок нельзя смешивать в одном прогоне: { "error": { "code": 400, "message": "chunks_total=3 не совпадает с началом прогона (2) — части разных выгрузок нельзя смешивать в одном run_id" } } + Response 409 (application/json) Прогон уже закрыт («superseded») — 1С открыла более новый `run_id`, не досдав этот. Дослать в закрытый прогон нельзя, нужен новый `run_id` с полным срезом заново: { "error": { "code": 409, "message": "Этот прогон сверки закрыт (начат более новый). Начните новый прогон с новым run_id." } } **Часть в процессе (ещё не все части собраны).** Первая часть двухчастного прогона — прогон открыт, финализации нет, ждём часть 2: ```json { "data": { "run_id": "8f1c2e40-9b7a-4a11-8d2f-000000000001", "status": "open", "chunks_total": 2, "chunks_received": 1, "chunks_missing": [2], "serials_stored": 3, "started_at": "2026-07-26T09:00:00.000Z", "chunk": 1, "accepted": 3, "duplicates": 0, "repeated": false, "complete": false } } ``` **Повтор ПОСЛЕ финализации (идемпотентность).** 1С не дождалась ответа на последнюю часть и переотправила её тем же телом — ничего не пересчитывается, `repeated: true`, `result` — тот же сохранённый итог: ```json { "data": { "run_id": "8f1c2e40-9b7a-4a11-8d2f-000000000001", "status": "complete", "chunks_total": 2, "chunks_received": 2, "chunks_missing": [], "serials_stored": 5, "started_at": "2026-07-26T09:00:00.000Z", "chunk": 2, "accepted": 0, "duplicates": 2, "repeated": true, "complete": true, "result": { "mode": "observe", "decision": "observed", "base": 1751, "matched": 1740, "coverage_ppm": 993718, "coverage_min_ppm": 950000, "orphans": 11, "voided": 0, "void_limit": 35, "completed_at": "2026-07-26T09:00:05.000Z" } } } ``` **Другие исходы `result.decision`** (тот же прогон, другие условия — приведён только объект `result`): ```json // Боевой режим «только удалённые» (mode: "enforce_missing"): из 11 сирот 4 удалены в 1С — они и // погашены; 7 помеченных на удаление ждут решения владельца в кабинете. { "mode": "enforce_missing", "decision": "voided", "base": 1751, "matched": 1740, "coverage_ppm": 993718, "coverage_min_ppm": 950000, "orphans": 11, "orphans_missing": 4, "orphans_deleted": 7, "voided": 4, "void_limit": 35, "completed_at": "2026-07-26T09:00:05.000Z" } // Боевой режим, сирот 40 — больше лимита 35: нужно подтверждение владельца в кабинете. { "mode": "enforce_all", "decision": "blocked_limit", "base": 1751, "matched": 1701, "coverage_ppm": 971444, "coverage_min_ppm": 950000, "orphans": 40, "orphans_missing": 6, "orphans_deleted": 34, "voided": 0, "void_limit": 35, "completed_at": "2026-07-26T09:00:05.000Z" } // Срез увидел только 60% базы — не похоже на полную выгрузку, гашение отменено целиком. { "mode": "enforce_all", "decision": "blocked_coverage", "base": 1751, "matched": 1050, "coverage_ppm": 599657, "coverage_min_ppm": 950000, "orphans": 701, "orphans_missing": 690, "orphans_deleted": 11, "voided": 0, "void_limit": 35, "completed_at": "2026-07-26T09:00:05.000Z" } ``` ## Состояние прогона [/v1/sync/cards-snapshot/{run_id}] + Parameters + run_id: `8f1c2e40-...` (string, required) - тот же `run_id`, что был в `POST`. ### Статус прогона [GET] Нужен коннектору, чтобы после сбоя связи дослать РОВНО недостающие части (`chunks_missing`), а не гонять срез заново, и чтобы посмотреть решение уже завершённого прогона. `mode` в ответе — ТЕКУЩИЙ режим тенанта (может отличаться от `result.mode`, если владелец сменил режим уже после завершения прогона). Незавершённый прогон решения не имеет — вместо `result` отдаётся `preview` («что было бы, если бы срез собрался прямо сейчас»), чтобы 1С могла отлаживаться, не дожидаясь финализации. ``` curl https://loyella.ru/v1/sync/cards-snapshot/8f1c2e40-9b7a-4a11-8d2f-000000000001 \ -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" ``` + Response 200 (application/json) Прогон не завершён (не хватает части 2 — ровно то, что дослать): { "data": { "run_id": "8f1c2e40-9b7a-4a11-8d2f-000000000001", "status": "open", "chunks_total": 2, "chunks_received": 1, "chunks_missing": [2], "serials_stored": 3, "started_at": "2026-07-26T09:00:00.000Z", "serials_marked_deleted": 0, "mode": "observe", "preview": { "base": 1751, "matched": 3, "coverage_ppm": 1713, "orphans": 1748, "note": "Срез не собран целиком — решение по нему не принимается." } } } + Response 404 (application/json) { "error": { "code": 404, "message": "Not Found" } } **Прогон завершён** — тот же вызов после финализации отдаёт сохранённый `result` (совпадает с тем, что вернула финализирующая часть `POST`): ```json { "data": { "run_id": "8f1c2e40-9b7a-4a11-8d2f-000000000001", "status": "complete", "chunks_total": 2, "chunks_received": 2, "chunks_missing": [], "serials_stored": 5, "started_at": "2026-07-26T09:00:00.000Z", "serials_marked_deleted": 1, "mode": "observe", "result": { "mode": "observe", "decision": "observed", "base": 1751, "matched": 1740, "coverage_ppm": 993718, "coverage_min_ppm": 950000, "orphans": 11, "voided": 0, "void_limit": 35, "completed_at": "2026-07-26T09:00:05.000Z" } } } ``` # Group Справочники — Reference Метаданные вида карты, нумераторы и данные сертификата — для совместимости с 1С Fitness. ## Виды карт [/v1/projects] ### Список видов [GET] Все виды карт организации: внешний номер, название, продукт, настройка штампов, счётчик активных карт. Только чтение (создание/переименование видов через API пока не поддерживается). Формат — бит-в-бит с донором: `{"data": [...]}`, ПЛОСКИЙ массив (без обёртки `items`/`total` — учётные системы, включая 1С Fitness, перебирают `data` как массив и читают `.id`/`.name` у каждого элемента). `id` элемента = внешний номер вида (тот же, что и `external_id`, продублирован для совместимости) — именно это значение вы передаёте назад в `project_id` при создании карты. ``` curl https://loyella.ru/v1/projects -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" ``` + Response 200 (application/json) { "data": [ { "id": 1, "external_id": 1, "name": "Карта лояльности", "product": "loyalty", "stamps": { "enabled": true, "target": 6, "reward_text": "Кофе в подарок" }, "active_cards": 1180 } ] } ## Вид карты [/v1/projects/{id}] + Parameters + id: `1` (number, required) - внешний номер вида карты в вашей организации. ### Метаданные вида [GET] Данные вида + описания полей (`attribute`/`label`/`required`/`type`) из шаблона. `id` в ответе — номер вида карты (тот же, что в пути `{id}`); его же передают в `project_id` при создании карты. ``` curl https://loyella.ru/v1/projects/1 -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" ``` + Response 200 (application/json) { "data": { "id": 1, "external_id": 1, "name": "Карта лояльности", "fields": [ { "attribute": "first_name", "label": "Имя", "required": false, "type": "string" }, { "attribute": "bonuses", "label": "Баллы", "required": false, "type": "string" } ] } } ## Нумераторы [/v1/numerators] ### Список нумераторов [GET] ``` curl https://loyella.ru/v1/numerators -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" ``` + Response 200 (application/json) { "data": [ { "id": 1, "name": "Основной", "prefix": "CARD-", "start_number": 1, "format": "%d" } ] } ### Создать нумератор [POST] ``` curl -X POST https://loyella.ru/v1/numerators \ -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" \ -H "Content-Type: application/json" \ -d '{"name":"VIP","prefix":"VIP-","start":1000}' ``` + Request (application/json) { "name": "VIP", "prefix": "VIP-", "start": 1000, "format": "%d" } + Response 201 (application/json) { "data": { "created": true, "id": 2 } } ## Сертификат [/v1/certificates] ### Данные сертификата [GET] Идентификаторы сертификата (без секретов) — для совместимости с протоколом 1С Fitness. ``` curl https://loyella.ru/v1/certificates -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" ``` + Response 200 (application/json) { "data": [ { "pass_type_identifier": "pass.ru.loyella", "team_identifier": "XXXXXXXXXX", "organization_name": "Моя организация" } ] } # Group Вебхуки — Webhooks Подписка на события Loyella. Тело события POST-ится на ваш URL с HMAC-подписью, доставка надёжная (ретраи с backoff, журнал доставок, авто-отключение при постоянных провалах). **Конверт события** Все новые (dot-именованные) события приходят единым конвертом: ```json { "event": "points.earned", "id": "evt_2b7c9f1e-...", "created_at": "2026-07-13T14:05:00.000Z", "tenant": "my-org-slug", "data": { "serial_number": "a1b2...001", "field": "bonuses", "delta": 150, "before": 150, "after": 300 } } ``` **Заголовки доставки и подпись HMAC** На каждой доставке: | Заголовок | Значение | |-----------|----------| | `X-Loyella-Event` | имя события, напр. `points.earned` | | `X-Loyella-Delivery` | уникальный id доставки (`evt_…`) — дедуплицируйте ретраи на своей стороне | | `X-Loyella-Timestamp` | unix-время (секунды) формирования подписи | | `X-Loyella-Attempt` | номер попытки (1..6) | | `X-Loyella-Signature` | `sha256=.")>` | Подпись считается по **сырому телу** запроса (байт-в-байт), склеенному с временной меткой через точку. Секрет (`whsec_…`) выдаётся один раз при создании подписки. Проверка на Node.js: ```js const crypto = require('crypto'); // rawBody — СЫРАЯ строка тела запроса (до JSON.parse). В Express: // app.use('/webhooks/loyella', express.raw({ type: 'application/json' }), handler) function verifyLoyellaSignature(req, rawBody, secret) { const ts = req.headers['x-loyella-timestamp']; const got = req.headers['x-loyella-signature'] || ''; const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(ts + '.' + rawBody).digest('hex'); const a = Buffer.from(got); const b = Buffer.from(expected); return a.length === b.length && crypto.timingSafeEqual(a, b); } ``` **Доставка, ретраи, авто-отключение** - Успех доставки — ответ вашего сервера с кодом `2xx`. Таймаут запроса — 10 секунд. - До **6 попыток**. После неудачной попытки следующая через: `30s → 2m → 10m → 1h → 6h`. - Если подписка накопила **15 подряд** «мёртвых» (исчерпавших ретраи) доставок — она автоматически **отключается** (`active: false`, `disabled_at` заполнен). Проверьте свой приёмник и включите её снова (`PATCH /v1/webhooks/{id}` с `{"active": true}`). - Отвечайте `2xx` быстро и обрабатывайте асинхронно — медленный приёмник упирается в таймаут. **Каталог событий (18)** Подписаться можно на конкретное событие, на группу (`card.*`, `points.*`, …) или на всё (`*`). | Событие | Группа | Когда | Пример `data` | |---------|--------|-------|---------------| | `card.issued` | Карты | карта выпущена (любой канал) | `{serial_number, pass_number, project_id, channel}` | | `card.installed` | Карты | карта установлена в кошелёк | `{serial_number, pass_number, device_id, platform:"apple"}` | | `card.uninstalled` | Карты | карта удалена из кошелька | `{serial_number, device_id, platform:"apple"}` | | `card.fields_updated` | Карты | поля карты изменены | `{serial_number, pass_number, changed:["bonuses"]}` | | `card.voided` | Карты | карта аннулирована | `{serial_number, pass_number, source}` | | `stamp.added` | Лояльность | поставлен штамп | `{serial_number, current, target}` | | `stamp.rewarded` | Лояльность | штампы собраны, выдана награда | `{serial_number, target, reward_text, current}` | | `points.earned` | Лояльность | баллы начислены | `{serial_number, field, delta, before, after, cashier_id?, operation_id?}` | | `points.redeemed` | Лояльность | баллы списаны | `{serial_number, field, delta, before, after, cashier_id?}` | | `scan.operation` | Лояльность | операция сканера | `{serial_number, kind:"visit"\|"reversal", cashier_id?, reversal_of?, delta?}` | | `review.created` | Клиенты | оставлен отзыв (с оценкой) | `{serial_number, rating, comment, point_id, review_id}` | | `form.submitted` | Клиенты | заполнена анкета самозаписи | `{serial_number, pass_number, project_id, channel:"form"}` | | `broadcast.sent` | Кампании | отправлена push-рассылка | `{message, recipients, target}` | | `publish.applied` | Кампании | дизайн применён | `{project_id}` | | `automation.triggered` | Кампании | сработала автоматизация | `{serial_number, trigger_key}` | | `push.delivered` | Служебные | push доставлен | `{serial_number, result, deferred?}` | | `push.failed` | Служебные | push не доставлен | `{serial_number, result, deferred?}` | | `telegram.linked` | Служебные | привязан Telegram | `{serial_number}` | **Предупреждение:** события `push.*` **шумные** — они стреляют на каждое пуш-уведомление (в том числе при каждом обновлении карты и на каждую рассылку). Подписывайтесь на них только если реально ведёте контроль доставки push; иначе ваш приёмник захлебнётся трафиком. ## Подписки [/v1/webhooks] ### Список подписок [GET] Секреты в списке не отдаются (секрет показывается только при создании). ``` curl https://loyella.ru/v1/webhooks -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" ``` + Response 200 (application/json) { "data": [ { "id": 7, "event": null, "events": ["card.*", "points.earned"], "url": "https://example.com/hooks/loyella", "global": true, "projects": [], "active": true, "disabled_at": null, "created_at": "2026-07-13T14:00:00.000Z" } ] } ### Создать подписку [POST] **Новый контур** (рекомендуется): передайте `events[]` — вернётся `secret` (HMAC, показывается один раз), доставка надёжная с подписью. `global: false` + `projects: [...]` ограничивает подписку конкретными видами карт. ``` curl -X POST https://loyella.ru/v1/webhooks \ -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" \ -H "Content-Type: application/json" \ -d '{"name":"CRM sync","url":"https://example.com/hooks/loyella","events":["card.*","points.earned","points.redeemed"]}' ``` + Request (application/json) { "name": "CRM sync", "url": "https://example.com/hooks/loyella", "events": ["card.*", "points.earned"], "global": true } + Response 201 (application/json) { "data": { "id": 7, "secret": "whsec_0123abcd...", "events": ["card.*", "points.earned"] } } + Response 400 (application/json) { "error": { "message": "Unknown event(s): card.frobnicated" } } ### Обновить подписку [PATCH /v1/webhooks/{id}] Аддитивно: `{url?, events?, active?, name?}`. Обновление `events` не меняет секрет. + Parameters + id: `7` (number, required) + Request (application/json) { "active": true, "events": ["card.issued", "card.voided"] } + Response 200 (application/json) { "data": { "id": 7, "event": null, "events": ["card.issued", "card.voided"], "url": "https://example.com/hooks/loyella", "global": true, "projects": [], "active": true, "disabled_at": null, "created_at": "2026-07-13T14:00:00.000Z" } } ### Удалить подписку [DELETE /v1/webhooks/{id}] + Parameters + id: `7` (number, required) + Response 200 (application/json) { "data": { "deleted": true } } ## Тест-пинг [/v1/webhooks/{id}/test] + Parameters + id: `7` (number, required) ### Отправить test.ping [POST] Немедленно отправляет событие `test.ping` на URL подписки (в обход очереди) и возвращает ответ приёмника — удобно проверить настройку. ``` curl -X POST https://loyella.ru/v1/webhooks/7/test -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" ``` + Response 200 (application/json) { "data": { "ok": true, "status_code": 200, "error": null } } ## Журнал доставок [/v1/webhooks/{id}/deliveries{?limit}] + Parameters + id: `7` (number, required) + limit: `20` (number, optional) - по умолчанию 20, максимум 100. ### Последние доставки [GET] ``` curl "https://loyella.ru/v1/webhooks/7/deliveries?limit=20" -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" ``` + Response 200 (application/json) { "data": [ { "id": "evt_2b7c...", "event": "points.earned", "status": "success", "attempts": 1, "last_status_code": 200, "created_at": "2026-07-13T14:05:00.000Z", "delivered_at": "2026-07-13T14:05:01.000Z" } ] } ## Каталог для интеграторов [/v1/webhooks/catalog] ### Справочник событий [GET] Машиночитаемый список подписываемых событий (`name`/`label`/`group`). ``` curl https://loyella.ru/v1/webhooks/catalog -H "X-Client-Id: $ID" -H "X-Client-Secret: $SECRET" ``` + Response 200 (application/json) { "data": [ { "name": "card.issued", "label": "Карта выпущена", "group": "Карты" }, { "name": "points.earned", "label": "Баллы начислены", "group": "Лояльность" } ] } # Group Совместимость с 1С Fitness Loyella — прямая замена сервиса карт для 1С Fitness. **Прежние эндпоинты работают без изменений** — байт-в-байт: тот же формат тела, те же ответы, тот же JSON-404 на неизвестный путь. **Подключение существующей интеграции 1С:** 1. Смените **АдресAPI** на `https://loyella.ru`. 2. Пропишите ключи `X-Client-Id` / `X-Client-Secret`, выданные в кабинете Loyella (раздел «Интеграции»). 3. Больше ничего менять не нужно. Совместимые методы: выпуск карты (`POST /v1/passes`, ответ с обёрткой `data`, бит-в-бит донор — исправлено 15.07.2026, инцидент #326), пакетное обновление (`PATCH /v1/passes`, `PATCH /v1/passes/by-fields`), обновление одной карты и аннулирование (`PATCH /v1/passes/{serial}` с `{voided:true}`), удаление (`DELETE /v1/passes/{serial}`), метаданные вида (`GET /v1/projects/{id}`), нумераторы (`GET/POST /v1/numerators`), сертификат (`GET /v1/certificates`), а также «старые» вебхуки: подписка с одиночным `event` (`PassCreated` / `PassUpdated` / `PassRegistered` / `PassUnregistered`) доставляется прежним телом `{event, data}` без подписи. `event: "*"` в подписке (как шлёт 1С Fitness при регистрации) — тоже legacy-wildcard, эквивалентен `"all"` (#329, инцидент W1, 15.07.2026). Новые dot-события — opt-in, они не ломают старую интеграцию. **Payload `PassCreated`** (#329, инцидент W2, 15.07.2026) — `ОбработатьВебХук()` в 1С читает ТОЧКОЙ `data.link`, `data.project_id`, `data.created_via` и (в ветке анкеты) `data.fields.*`, поэтому тело доставки полное, а не только `{serial_number, pass_number}`: ```json { "event": "PassCreated", "data": { "serial_number": "a1b2c3d4-0000-0000-0000-000000000001", "pass_number": "CARD-000123", "project_id": 1, "link": "https://loyella.ru/pass/a1b2c3d4-0000-0000-0000-000000000001", "fields": { "first_name": "Иван", "last_name": "Петров", "phone": "+79990001122" }, "created_via": "api" } } ``` `project_id` — ВНЕШНИЙ id вида (`projects.external_id`, инцидент #325), НЕ internal `projects.id`. `created_via` — фактический канал выпуска (`api` / `form` / `admin` / `mcp` / …). Пример вызова начисления баллов из 1С (встроенный язык, `HTTPЗапрос`): ```bsl Заголовки = Новый Соответствие; Заголовки.Вставить("X-Client-Id", КлиентID); Заголовки.Вставить("X-Client-Secret", Секрет); Заголовки.Вставить("Content-Type", "application/json"); Запрос = Новый HTTPЗапрос("/v1/passes/" + Серийный + "/points/credit", Заголовки); Запрос.УстановитьТелоИзСтроки("{""amount"":150,""operation_id"":""sale-4417""}"); Соединение = Новый HTTPСоединение("loyella.ru", 443, , , , , Новый ЗащищенноеСоединениеOpenSSL); Ответ = Соединение.ОтправитьДляОбработки(Запрос); ```