Открытый API

Чеки, начисление и списание баллов, карты гостей — из вашей кассы, 1С или мобильного приложения.

Открыть в кабинете

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

Базовый адрес и версия

https://api.snovacard.ru/api/v1

Версия в адресе меняется только при несовместимых правках. Новые поля в ответах добавляются без смены версии — разбирайте JSON так, чтобы незнакомое поле не ломало клиента. Тела запросов и ответов — JSON, суммы — в рублях числом, даты — ISO 8601 в UTC.

Ключ доступа

Ключ выдаёт владелец или администратор в кабинете: «Интеграции» → «Ключи API», поле «Название ключа» и кнопка «Создать ключ». Открытый API — возможность тарифа «Про» и выше; на младших тарифах вместо формы показано, с какого тарифа она откроется. Ключи для готовых модулей интернет-магазинов выдаются иначе — см. Shop-Script и 1С-Битрикс.

Вкладка «Ключи API»: ключ для своей интеграции — название и кнопка «Создать ключ»
Ключ показывается один раз: в базе хранится только его отпечаток.
Вкладка «Ключи API»: ключ для своей интеграции — название и кнопка «Создать ключ»

Ключ передаётся заголовком X-API-Key и заменяет собой вход по паролю — отдельной процедуры получения токена нет. Выглядит он как wlk_live_…; у ключей песочницы — wlk_test_…. Полное значение показывается один раз, сразу после создания, — скопируйте его и нажмите «Я скопировал». Потеряли — создайте новый, а старый отзовите.

Области доступа

У ключа есть области доступа — каждый метод требует свою. Ключ, созданный в кабинете, получает постоянный набор: transactions:write, cards:read и customers:write, — выбирать области форма не даёт. Этого хватает на все методы ниже.

ОбластьЧто открывает
transactions:writeПроводить чеки и возвраты: POST /transactions, POST /transactions/:id/refund
cards:readИскать карты, читать историю, проверять купон: GET /cards/by-barcode/:barcode, POST /cards/lookup, GET /cards/:id/history, POST /cards/:id/coupon-preview
customers:writeЗаводить гостей и подтверждать их номер: POST /customers, POST /customers/phone-code, /phone-call-state, /phone-verify
не нужнаGET /program и GET /pos/locations — их может вызвать любой живой ключ

Ещё три области — transactions:read, cards:write и customers:read — есть в схеме и у ключа песочницы, но ни один метод сегодня их не требует. Ключу без нужной области приходит FORBIDDEN с перечнем недостающих в тексте сообщения.

Выпущенные ключи

Все действующие ключи компании — на соседней вкладке «Интеграции» → «Выпущенные ключи». У каждого видно название, начало ключа (wlk_live_xxxxxxx…) — по нему ключ узнаётся в вашем конфиге, — и когда им пользовались последний раз: «использован 27 сент.» или «ещё не использовался». Области доступа в списке не показываются.

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

Мы запоминаем заголовок User-Agent последнего запроса. Если в нём есть адрес в скобках с плюсом — МояКасса/2.1 (+https://shop.example.ru), — он выводится рядом с ключом, и владелец видит, чья программа им пользуется. Присылайте такой заголовок: доступ по нему не решается, но чужой адрес в списке заметен сразу.

Провести чек

Главный метод. Гость указывается штрихкодом карты (cardBarcode) или телефоном (phone) — что-то одно обязательно.

POST /transactions

curl -X POST https://api.snovacard.ru/api/v1/transactions \
  -H "X-API-Key: wlk_live_..." \
  -H "Idempotency-Key: 9f1c0f2e-4a7b-4f3a-9c1e-2f1b7d5a8e10" \
  -H "Content-Type: application/json" \
  -d '{
    "cardBarcode": "2000000012345",
    "amount": 850,
    "redeemPoints": 100,
    "locationId": "0f2a...",
    "externalId": "чек-2026-08-27-0042",
    "items": [
      { "name": "Капучино", "category": "напитки", "qty": 2, "price": 250 },
      { "name": "Круассан", "category": "выпечка", "qty": 1, "price": 350 }
    ]
  }'
  • amount — сумма чека до скидок и списания баллов, в рублях. Больше нуля, не больше 10 000 000.
  • redeemPoints — сколько баллов списать, целое, по умолчанию 0. Больше потолка нельзя: вернётся REDEEM_LIMIT_EXCEEDED, а потолок никогда не выше баланса карты. Меньше минимального списания программы (minRedeemAmount из GET /program) — тоже отказ, VALIDATION_ERROR.
  • items — позиции чека, до 500: name, category, qty, price, все необязательные. Нужны правилам, которые смотрят на категории товаров; без них правило по категории просто не сработает.
  • externalId — номер чека в вашей системе, до 120 символов. Пригодится при разборе расхождений.
  • couponCode — код купона или промокода, один на чек. Скидка купона считается раньше баллов, и потолок списания берётся уже от остатка. Проверить код до чека — POST /cards/:id/coupon-preview, ниже.
  • applyDiscount: true — ваша касса показала гостю скидку правилом программы (discount из поиска карты) и вычла её из суммы к оплате. Без флага сервер скидку не считает и не записывает: касса, которая о правиле не знает, гостю ничего не скинула. amount при этом — сумма чека до скидки, как её пробила касса; скидка считается первой, потом купон от остатка, потом баллы.
  • locationId — точка продаж из GET /program. От неё зависят три вещи: часовой пояс, по которому считаются правила вроде «до 11 утра»; выручка по точкам в отчётах; и сами правила — их можно привязать к точке. Точка, которой у компании нет, отклоняется (NOT_FOUND): чек с чужой ссылкой считался бы не по тем правилам и молчал бы об этом.
  • staffId — кто провёл чек, идентификатор сотрудника компании. По нему считаются оценки визитов и «кто сколько провёл». Необязательный; чужой идентификатор — NOT_FOUND, а не тихий ноль.

Ответ 201

{
  "transactionId": "5a1d...",
  "card": { "id": "b7c2...", "barcode": "2000000012345", "balance": 1839, "balanceBefore": 1864 },
  "points": { "earned": 75, "redeemed": 100, "redeemableWas": 425 },
  "tier": { "name": "Легенда", "level": 2, "changed": false },
  "appliedRules": [{ "id": "…", "name": "Базовое начисление 10%", "points": 75 }],
  "stamps": null,
  "coupon": null,
  "discount": null,
  "currencyName": "баллы"
}
  • points.earned считается от суммы, оплаченной деньгами, — после скидки, купона и списания баллов.
  • redeemableWas — сколько можно было списать в этом чеке: по нему касса видит, что гость списал не всё, что мог.
  • stamps — штампы по чеку: name, delta (сколько дал чек), count и target (счётчик и цель), rewards и rewardPoints — подарки этим чеком. null — штампов в программе нет.
  • coupon — погашенный купон: title, kind (POINTS, PERCENT, GIFT), discount — рубли, которые касса вычитает из чека сама, points — баллы на карту, gift — позиция, которую касса кладёт в чек бесплатно.
  • discount — скидка правилом, если чек пришёл с applyDiscount: true: percent, amount в рублях и ruleName.

Повтор с тем же ключом в первые сутки отдаёт первый ответ целиком. Позже — или если два одинаковых запроса пришли одновременно — приходит ответ, собранный по уже проведённому чеку: баланс и баллы те же, но redeemableWas в нём 0, а appliedRules пуст.

Правила и точки продаж

Правило начисления или лимита списания заведение может привязать к точке: «после 18:00 двойные баллы, но только в зале», «в интернет-магазине списывать не больше 20% чека». На чеке работают правила без привязки плюс привязанные к его точке; правила чужих точек пропускаются.

Найти карту и узнать потолок списания

До чека гостю называют, сколько он может списать. Считает это сервер — правила лимита живут у него, — а касса складывает готовые числа.

POST /cards/lookup

{ "phone": "+79995551234", "locationId": "0f2a..." }

Ответ 201

{
  "id": "b7c2...", "barcode": "2000000012345", "balance": 1864, "status": "ACTIVE",
  "currencyName": "зёрна",
  "currency": { "name": "зёрна", "one": "зерно", "few": "зерна", "many": "зёрен" },
  "customer": { "name": "Анна Соколова", "phone": "+79995551234" },
  "tier": { "name": "Легенда", "level": 2 },
  "maxRedeemPercent": 20,
  "maxRedeemPoints": null,
  "pointValue": 1,
  "discount": { "percent": 10, "cap": null, "ruleName": "Постоянным −10 %" },
  "stamps": { "name": "Шестая чашка в подарок", "count": 3, "target": 6, "rewardPoints": 250 },
  "coupons": [
    { "id": "…", "code": "K7QM2X", "title": "−15 % на завтрак", "kind": "PERCENT",
      "value": 15, "giftSku": null, "validTo": "2026-10-31T20:59:59.000Z" }
  ]
}
  • maxRedeemPercent — какую долю чека можно закрыть баллами на названной точке: настройка программы, срезанная правилами лимита. Без locationId приходит доля для чека без точки.
  • maxRedeemPoints — потолок в баллах, если правила его задали; null — потолка нет. Отдельным числом, потому что доля зависит от суммы чека, а потолок нет.
  • discount — прямая скидка правилом программы, которую гостю называют до чека: «ваша скидка 10 %». null — скидки нет. Рубли считаются от полной суммы чека: round(amount × percent) / 100, не больше cap, если он задан. Чтобы сервер записал скидку, чек проводится с applyDiscount: true.
  • stamps — прогресс по штампам: «третья чашка из шести». Без оглядки на точку — счётчик на карте один. null — штампов в программе нет.
  • coupons — купоны гостя, которые можно погасить сейчас. Код уходит в couponCode чека.
  • status — ACTIVE у действующей карты. По карте в другом статусе чек не проводится: CARD_BLOCKED.

Сколько списать с конкретного чека: min(баланс, сумма × maxRedeemPercent / 100 / pointValue, maxRedeemPoints), округляя вниз, где сумма — чек после скидки и купона. Тот же расчёт делает наш экран кассы.

То же самое по штрихкоду: GET /cards/by-barcode/:barcode?locationId=0f2a…. Номер телефона в адрес не кладётся нигде — он остался бы в журналах прокси, — поэтому поиск по телефону идёт телом POST. По телефону находится только действующая карта: гостя нет или вместо номера пришёл мусор — CARD_NOT_FOUND. По штрихкоду карта приходит в любом статусе — смотрите status.

Проверить купон до чека

POST /cards/:id/coupon-preview

{ "code": "K7QM2X", "amount": 850 }

Ответ 201

{ "title": "−15 % на завтрак", "kind": "PERCENT", "discount": 127.5, "points": 0, "gift": null }

Кассир вводит код с листовки и до «Провести» видит, что он даёт; опечатка иначе стоила бы возврата и второго чека. :id — идентификатор карты из поиска, amount — сумма чека, от неё считается скидка. Ответ тот же, что даст гашение: кода нет или он чужой — COUPON_NOT_FOUND, купон погашен, отозван или его срок вышел — COUPON_UNUSABLE с причиной в details.reason.

Все методы

Метод и адресЧто делаетОбласть
GET /programПроверить ключ: программа, цена балла, компания, тариф, точкине нужна
GET /pos/locationsДействующие точки продажне нужна
POST /transactionsПровести чек: скидка, купон, списание и начислениеtransactions:write
POST /transactions/:id/refundВозврат: полный или частичныйtransactions:write
GET /cards/by-barcode/:barcodeНайти карту по штрихкодуcards:read
POST /cards/lookupНайти карту по телефону: номер идёт телом запроса, а не в адресеcards:read
GET /cards/:id/historyИстория движения баллов по картеcards:read
POST /cards/:id/coupon-previewПроверить код купона до чекаcards:read
POST /customersЗавести гостя и выдать ему картуcustomers:write
POST /customers/phone-codeОтправить покупателю код подтверждения номераcustomers:write
POST /customers/phone-call-stateУзнать, подтвердил ли покупатель номер звонкомcustomers:write
POST /customers/phone-verifyПроверить код подтверждения номераcustomers:write

Завести гостя

POST /customers

{
  "phone": "+79995551234",
  "firstName": "Анна",
  "lastName": "Соколова",
  "email": "anna@example.com",
  "birthDate": "1994-05-14",
  "consent": true,
  "consentVersion": "1.0",
  "source": "касса"
}

Ответ 201

{
  "customerId": "1c9e...",
  "cardId": "b7c2...",
  "barcode": "2000000012345",
  "balance": 100,
  "created": true,
  "installUrl": "https://pass.snovacard.ru/card/…?token=…"
}

Обязателен только phone. programId нужен, если у компании несколько программ, иначе — VALIDATION_ERROR. source — метка, откуда пришёл гость, до 40 символов, по умолчанию pos; она же приходит в вебхуке card.issued. installUrl — ссылка на карту гостя: с неё карта ставится в Apple Wallet или Google Кошелёк. Отдайте её гостю — QR-кодом на экране, сообщением.

Гость с таким номером уже есть и карта у него есть — вторая не заводится: приходит та же карта с created: false, баллы и приветственный бонус не трогаются. Ссылка installUrl при этом выпускается заново, а прежняя гаснет. Отказы при выдаче: OPERATOR_NOT_NAMED — заведение ещё не назвало себя оператором персональных данных, CARDS_LIMIT_REACHED — исчерпан лимит карт тарифа, PROGRAM_INACTIVE — программа выключена.

Подтвердить номер покупателя

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

POST /customers/phone-code

{ "phone": "+79995551234" }

Ответ 201

{
  "sent": true,
  "channel": "telegram",
  "retryAfterSec": 60,
  "reason": null,
  "sentTo": null,
  "call": null
}
  • channel — чем ушёл код: telegram, sms, call или email. Письмо уходит только на почту, которую гость сам оставлял раньше, — адрес из запроса не принимается; sentTo — этот адрес, замаскированный.
  • sent: false — код не отправлен. С reason: null — прошло меньше минуты с прошлого кода на этот номер: он уже ушёл каналом из channel, подождите retryAfterSec секунд. С заполненным reason — доставить нечем: no_channels (у заведения не настроено ни одного канала) или delivery_failed (каналы есть, но ни один не довёз).
  • call — если канал звонок: phonePretty — номер 8-800, на который покупатель звонит сам, вызов сбрасывается без ответа; checkId — что опрашивать; windowSec — сколько ждём звонка, 300 секунд.
  • В песочнице кода не отправляем вовсе, а возвращаем его полем shownCode.

Пока покупатель звонит, опрашивайте POST /customers/phone-call-state с { "checkId": "…" }. Ответ 200 — { "state": "pending" }, expired или { "state": "confirmed", "code": "4821" }: после звонка с самого номера приходит код, и дальше подтверждение идёт общим путём.

POST /customers/phone-verify

{ "phone": "+79995551234", "code": "4821" }

Ответ { "valid": true }, код при этом гасится. Неверный, истёкший или исчерпавший попытки код — VALIDATION_ERROR с объяснением в тексте. Код живёт пять минут, ошибиться можно пять раз. Код уходит только на российский мобильный (+7 9…), на номер — не больше пяти платных кодов и пяти писем в сутки; сверх этого — RATE_LIMITED. Подробнее о пределах — в кодах ошибок.

Возврат

POST /transactions/:id/refund

{ "amount": 350, "comment": "возврат круассана" }

Ответ 201

{ "balance": 1799, "cardId": "b7c2...", "refundedAmount": 350, "complete": false }

:id — transactionId из ответа на чек. Без amount возвращается весь остаток чека. С amount — часть, и таких возвратов может быть несколько: интернет-магазин возвращает заказ по позициям. Доля считается от суммы чека накопительно: снимается столько же процентов начисленного, возвращается столько же процентов списанного, а когда возвращена вся сумма, чек помечается возвращённым и всё сходится до балла — сколько бы кусков ни было. Округление в пользу заведения. Если начисленное гость уже потратил, баланс уходит в минус. Сумма больше остатка — VALIDATION_ERROR с details.remaining; возврат уже возвращённого чека — TRANSACTION_ALREADY_REFUNDED.

Частичный возврат защищайте заголовком Idempotency-Key, как и чек: повтор с тем же ключом не снимет долю дважды. Полному возврату ключ не нужен — его повтор отвергается статусом.

Кто я

GET /program

{
  "company": { "name": "Кофейня «Заря»", "slug": "demo-coffee", "sandbox": false },
  "program": {
    "id": "…", "name": "Зёрна", "slug": "zarya",
    "joinUrl": "https://pass.snovacard.ru/join/zarya",
    "isActive": true,
    "currencyName": "зёрна",
    "currency": { "name": "зёрна", "one": "зерно", "few": "зерна", "many": "зёрен" },
    "pointValue": 1, "maxRedeemPercent": 30, "minRedeemAmount": 1,
    "welcomeBonus": 100, "pointsExpireDays": 180
  },
  "operator": { "named": true, "title": "ООО «Заря»" },
  "plan": { "code": "PRO", "name": "Про", "api": true, "webhooks": true, "cardsLimit": 2000 },
  "cards": { "active": 43, "limit": 2000 },
  "locations": [
    { "id": "0f2a...", "name": "Заря на Покровке", "maxRedeemPercent": 30, "maxRedeemPoints": null },
    { "id": "7b31...", "name": "Интернет-магазин", "maxRedeemPercent": 20, "maxRedeemPoints": null }
  ]
}

Первый вызов любой интеграции: проверяет ключ и отдаёт то, что нужно показать до первого гостя, — как называются бонусы и сколько стоит балл. Областей доступа не требует. operator.named — заведение назвало себя оператором персональных данных; пока это не так, выдача карт отвечает OPERATOR_NOT_NAMED. program.joinUrl — публичная страница выдачи карты: её можно показать гостю QR-кодом. company.sandbox — ключ от песочницы. При нескольких программах укажите ?programId=…, иначе — VALIDATION_ERROR.

locations — точки продаж заведения, только действующие. Их идентификаторы и уходят в locationId; список стоит обновлять по расписанию, а не запоминать навсегда: заведение заводит новые точки и закрывает старые. Только список, без программы и лимитов, отдаёт GET /pos/locations — [{ "id": "…", "name": "…" }]; он работает и у компании с несколькими программами. cards — сколько карт выдано и сколько разрешает тариф: предел упирается молча, и гость узнаёт о нём первым.

У каждой точки — свой maxRedeemPercent и maxRedeemPoints: потолок списания с учётом привязанных к ней правил. Нужны, чтобы назвать «до 20%» до того, как покупатель назвался, — в чекауте, на экране кассы, в товароучёте: cards/lookup требует гостя, а число нужно и без него. Берите поле точки как есть — склеивать с процентом программы не надо, у точки без своих правил он уже подставлен.

История карты

GET /cards/:id/history

[
  { "id": "…", "date": "2026-08-27T09:14:02.511Z", "delta": 75, "balanceAfter": 1839,
    "reason": "PURCHASE", "comment": null, "amount": 850 },
  { "id": "…", "date": "2026-08-27T09:14:02.511Z", "delta": -100, "balanceAfter": 1764,
    "reason": "REDEEM", "comment": null, "amount": 850 }
]

Последние 100 движений баллов, новые первыми. reason — причина: PURCHASE, REDEEM, REFUND, EXPIRE (сгорание), MANUAL (правка из кабинета), WELCOME, BIRTHDAY, REFERRAL, COUPON, STAMP_REWARD и другие — список может расти. amount — сумма чека, если движение связано с чеком. Чужая или выдуманная карта — NOT_FOUND.

Проверить на стенде

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

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

Машинного описания API (OpenAPI) снаружи нет: контракт — эта страница, вебхуки и коды ошибок.

Частые вопросы

Как передать, что чек пробит в конкретной точке?

Поле locationId в теле запроса, идентификатор берётся из GET /program или GET /pos/locations. Оно определяет часовой пояс для правил вроде «двойные баллы до 11 утра», выручку по точкам в отчётах и сами правила: их можно привязать к точке. Ту же точку называйте и при поиске карты, иначе потолок списания разойдётся с чеком.

Можно ли начислить баллы без чека?

Да, ручной корректировкой из кабинета — она требует комментария и попадает в журнал действий. В API такого метода нет намеренно: начисление без основания должно оставаться решением человека, а не строкой в чужом коде.

Что будет, если гость не найден?

Вернётся CARD_NOT_FOUND со статусом 404. Заводить гостя автоматически при первом чеке мы не станем: карта — это согласие на обработку данных, а его даёт человек.

Можно ли выдать ключу только чтение?

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

Первые 20 карт — бесплатно. Программа настраивается за вечер.

Создать программу