Свой чек

API для сайта

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

Версия конверта 1 HTTP + JSON Подпись HMAC-SHA256 PHP · Python · JavaScript

Как это устроено

«Свой чек» пробивает чеки по 54-ФЗ вашей собственной кассой, а не арендованной облачной. Между сайтом и кассой стоит очередь заданий — приёмник, который живёт рядом с вашим сайтом.

ваш сайт  ──POST /v1/tasks──►  ПРИЁМНИК  ◄──приходит сам──  АГЕНТ ──► касса
   ▲                            (очередь)                   (кассовый
   └────── вебхук о чеке ──────────┘                        компьютер)

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

Из такой схемы следуют три вещи, о которых лучше знать заранее:

  • Ответ «положено» — это не «чек пробит». Чек выйдет секундами позже, а если касса выключена — часами. Реквизиты берут опросом или вебхуком.
  • Очередь принимает задания, даже когда касса недоступна. Магазин закрыли, компьютер выключили, интернет оборвался — сайт всё это время продолжает принимать оплату, и ни один чек не теряется.
  • Приёмник в тело задания не смотрит. Он хранит его байт в байт и отдаёт агенту. Состав чека проверяет агент — у него есть настройки кассы, а у очереди их нет.

С чего начать

Нужны три вещи, и все три вы задаёте сами при установке:

Адрес приёмника Тот адрес, по которому вы поставили приёмник рядом с сайтом: https://ваш-сайт.ру/svoychek. Все пути ниже дописываются к нему.
Ключ источника Идентификатор ключа — едет в заголовке, поэтому только латиница: ^[A-Za-z0-9_.:-]{1,64}$.
Секрет Им подписывается запрос. В заголовок не попадает никогда и может быть любым.

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

Самый короткий путь: возьмите обёртку для своего языка (PHP, Python, JavaScript), она берёт на себя подпись, повторы и разбор отказов. Всё, что ниже, — на случай, когда обёртка не подходит и запрос собирается руками.

Проверка связи

GET /v1/health отвечает без подписи и говорит своё время. С него стоит начинать: если подпись «не сходится», в половине случаев виноваты часы, а не ключ.

Подпись

Каждый запрос несёт три заголовка:

X-SvoyChek-Key:        идентификатор ключа (не сам секрет)
X-SvoyChek-Timestamp:  unix-время отправителя, секунды
X-SvoyChek-Signature:  v2=<hex(HMAC-SHA256)>

Подписываются метка времени, что делаем, куда и сырое тело:

подпись = HMAC_SHA256(секрет, метка + "." + МЕТОД + " " + путь + "." + тело)
метод Прописными: GET, POST.
путь Ровно та строка, которую вы дописали к адресу приёмника — вместе со строкой запроса и в том же процентном кодировании: /v1/tasks/2026%2F09%2F05-17?source_id=shop-main. Не нормализовать и не перекодировать. Базовый адрес в подпись не входит: приёмник знает свою базу из настроек и отрезает её сам.
тело Сырые байты, как уходят в сеть. У GET тела нет — подписывается пустое.
Главная ловушка

Нельзя пересобирать JSON между подписью и отправкой. Сериализуйте тело один раз, подпишите ровно эти байты и отправьте их же. PHP, Python и JavaScript расходятся в порядке ключей, экранировании кириллицы и пробелах после двоеточий — подпись перестанет сходиться ровно тогда, когда чинить её будет некому.

Окно метки времени — 5 минут в обе стороны. Разъехавшиеся часы на хостинге не редкость, поэтому про метку приёмник отвечает прямо, а не «доступ запрещён».

Схема названа v2 и записана прямо в подпись. Чужую схему приёмник не пытается понять — честно отказывает: несовпадение должно кричать, а не молчать.

Четыре ручки

Это всё, что нужно сайту. Ещё три ручки (/v1/agent/claim, /v1/agent/results, /v1/agent/state) — для агента, и ходит он в них своим паролем: ключ источника и пароль агента не взаимозаменяемы.

POST /v1/tasks положить задание на чек

Тело — конверт (см. ниже). Ответ 201 — задание создано и лежит в очереди; 200 — такое уже было, вернулось оно же.

GET /v1/tasks/{номер заказа}?source_id={источник} что с ним стало

Номер заказа — в процентном кодировании. source_id нужен, когда ключ обслуживает несколько источников; когда один — можно опустить. В ответе — всё задание: состояние, попытки, реквизиты чека или причина отказа.

POST /v1/tasks/{номер заказа}/cancel?source_id={источник} снять задание

Снимается только то, что ещё не взяла касса. Уже пробитый чек не отменяется — на это есть возврат.

GET /v1/health жив ли приёмник и который у него час

Единственная ручка без подписи.

В каждом ответе приёмник называет свою версию:

X-SvoyChek-Protocol:            1
X-SvoyChek-Protocol-Supported:  1

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

Конверт задания

То, что приёмник читает. Меняется он редко и версионируется строго — всё остальное лежит в теле.

{
  "protocol": 1,
  "source": { "id": "shop-main", "kind": "woocommerce", "name": "Мой магазин" },
  "external_id": "заказ-1024",
  "org": { "inn": "7701234567", "kpp": "770101001", "taxation": "usnIncome" },
  "kind": "sell",
  "payload_version": 1,
  "payload": "{\"email\":\"buyer@example.ru\",…}",
  "webhook_url": "https://ваш-сайт.ру/касса/вебхук"
}
ПолеЧто это
protocolВерсия конверта. Сейчас 1.
sourceОткуда пришло: id (должен быть разрешён вашему ключу), kind, name.
external_idВаш номер заказа. Он же ключ идемпотентности — см. ниже.
orgОт чьего имени чек: ИНН (10 или 12 цифр), КПП и система налогообложения. Маршрутизация не туда — это нарушение, поэтому агент не пробьёт задание на кассе с другими регистрационными данными.
kindsell, sellReturn, buy, buyReturn.
payload_versionКак читать тело. Сейчас 1.
payloadСамо задание строкой, не объектом. Приёмник хранит её байт в байт.
deviceНеобязательно: {"zn": "…"}, если чек обязан уйти на конкретную кассу.
webhook_urlНеобязательно: куда сообщить о смене состояния.
Почему тело едет строкой

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

Тело: сам чек

Версия 1. Внутри — JSON, и правила у него короткие, но обязательные.

{
  "email": "buyer@example.ru",
  "phone": null,
  "items": [
    { "name": "Кофе 250 г", "price": 45000, "quantity": "1", "sum": 45000,
      "vat": "vat22", "payment_method": "full_payment",
      "payment_object": "commodity" }
  ],
  "total": 45000,
  "payments": [ { "type": "cashless", "sum": 45000 } ],
  "place": "https://ваш-сайт.ру"
}
  • Деньги — целые копейки целыми числами. 45000 — это 450 рублей. Дробные PHP и Python округляют по-разному ровно тогда, когда это дороже всего.
  • Количество — строкой: "1", "0.125".
  • Сумма позиций обязана сойтись с итогом. Не сошлась — чек не пробивается вовсе: расхождение в чеке хуже, чем его отсутствие.
  • Нужен хотя бы один из email или phone: электронный чек надо куда-то отправить.
  • Кодировка UTF-8 без BOM, кириллица не экранируется. Время — UTC.

Ставки НДС

Словом из словаря, а не числом и не процентом:

none, vat0, vat5, vat7, vat10, vat20, vat22 и расчётные vat105, vat107, vat110, vat120, vat122.

vat20 нужна и сегодня — для возвратов по продажам до 2026 года: возврат берёт ставку из исходного чека, а не текущую.

Виды оплаты

cashless (безналичными — обычный случай для сайта), cash, prepaid, credit, other.

Чего сайт не назначает

Кассира (тег 1021), адрес расчётов (1009) и признак «расчёт в интернете» (1125) подставляет касса — они относятся к тому, кто пробивает чек, а витрин у одной кассы может быть сколько угодно. Пришлёте их в теле — они будут проигнорированы. Место расчётов (place, тег 1187) — наоборот, ваше: сайтов у кассы бывает несколько.

Маркированный товар

Если в заказе есть товар с кодом маркировки (Честный ЗНАК), тело задания становится версии 2. Это та же версия 1 плюс три необязательных поля у позиции — и другой номер в payload_version.

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

Чек с маркированным товаром, пробитый без кода, — нарушение. Агент, который маркировку не умеет, обязан отказаться от задания, а не выбросить непонятное поле и пробить чек молча. Номер версии — это и есть тот отказ.

{
  "email": "buyer@example.ru",
  "items": [
    { "name": "Сигареты", "price": 20000, "quantity": "2", "sum": 40000,
      "vat": "vat22",
      "marks": ["0104607177703549215Nm3(b93dGVz",
                "0104607177703549215Xy4)c93aBcD1"] },
    { "name": "Пиво разливное", "price": 20000, "quantity": "1", "sum": 20000,
      "vat": "vat22", "mark": "0104607177703549215Nm3(b",
      "measure": "liter", "fraction": "1/2" }
  ],
  "total": 60000,
  "payments": [ { "type": "cashless", "sum": 60000 } ]
}
  • mark — код одной единицы товара, строкой, ровно как его прочитал сканер на складе. Разделитель GS (0x1D) внутри кода сохраняется.
  • marks — список кодов, если в строке заказа несколько единиц. Кодов должно быть ровно столько, сколько товара.
  • measure — мера количества: piece, kilogram, liter, meter и прочие из ФФД. Не сказано — штука.
  • fraction — частичное выбытие пачки, правильной дробью: "1/2". Бывает только вместе с кодом маркировки.

Версию тела не надо ставить руками: все три SDK считают её по самому чеку — есть коды, значит вторая.

Что агент делает сам

  • Расщепляет позицию. Две пачки с двумя кодами — это две позиции по одной штуке: код у позиции по закону один. Деньги делятся целыми копейками, сумма чека не меняется.
  • Ставит признак «маркированный товар» вместо обычного commodity — обычный там был бы ошибкой в чеке.
  • Меняет статус кода в возврате на возвратный.
  • Отличает EAN-13. Он тоже код товара, но в фискальном накопителе не проверяется — и не заставляет чек ждать ответа системы маркировки.
Что проверяем не мы

Код маркировки проверяет сама касса — в фискальном накопителе и на сервере ИСМ, в момент чека. Мы не ходим в Честный ЗНАК и не подменяем эту проверку: отдельная предпродажная проверка нужна там, где кассир держит товар в руках, а в интернет-магазине код кладёт в заказ склад при сборке.

И одно требование к кассе: ФФД 1.2. На кассе с ФФД 1.05 или 1.1 маркированный чек не пробьётся никогда, и агент откажет сразу и словами — повторять такое задание бессмысленно.

Состояния

queued ──► claimed ──► done
   ▲           │
   └───────────┴──────► failed        cancelled — снято вами
СостояниеЧто значит
queued Лежит в очереди, касса за ним ещё не приходила.
claimed Агент забрал его и сейчас пробивает. Брошенное задание возвращается в очередь через 15 минут — агент мог выключиться посреди работы.
done Чек пробит. В result — реквизиты.
failed Пять попыток потрачено. В error — причина человеческими словами.
cancelled Вы сняли задание, пока чек не пробит.

Есть и «сейчас не могу»: за кассой работает живой кассир, смена закрыта, владелец ещё не подтвердил крупный чек. Тогда задание возвращается в queued с полем not_before, а попытка не тратится: это не отказ, это «приходите позже».

Реквизиты пробитого чека:

"result": {
  "fn": "9999078902004312",        номер фискального накопителя
  "fd": 1234,                      номер фискального документа
  "fp": "3105314570",              фискальный признак
  "zn": "0000000000012345",        на какой кассе пробит
  "shift": 42,
  "receipt_at": "2026-09-07T12:00:00Z",
  "check_url": "https://check.ofd.ru/rec/1234"
}

Повторы и идемпотентность

Ключ — пара source.id + external_id, и держит её UNIQUE-индекс в базе, а не проверка в коде: между «посмотрели» и «вставили» пролезает второй запрос, и получаются два чека на один заказ.

Что пришлоОтвет
новое задание201, состояние queued
тот же external_id, то же тело200, то самое задание
тот же external_id, другое тело409 duplicate_mismatch

Отсюда простое правило: сеть моргнула, ответ не дошёл — шлите то же самое ещё раз. Второго чека не будет. На этом же стоит встроенный в обёртки повтор: три попытки с паузами 1, 3 и 8 секунд.

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

Возвраты

Возврат — не чек с другим знаком, а чек, который считается от исходной продажи.

  • Состав, ставки и способ оплаты берутся из продажи. Продали в 2025-м по 20 % — возвращаем по 20 %, хотя сегодня ставка другая. Поэтому состав можно не передавать вовсе: обёртка дочитает тело исходного задания у приёмника и положит его же.
  • У возврата свой номер заказавозврат-<номер>, следующий возврат-2-<номер>. Тот же номер, что у продажи, очередь сочтёт повтором и вернёт старое задание вместо нового чека.
  • Частичный возврат передаётся составом — теми строками и количествами, которые возвращаете. Угадывать, какую половину заказа вернули, очередь не может и не будет.
  • Больше, чем продано, вернуть нельзя. Сторожит это касса: она держит журнал пробитых чеков и сверяет с ним. Если исходная продажа в журнале есть, возврат сверх её суммы до кассы не дойдёт.
чек.вернуть("заказ-1024")                     весь заказ
чек.вернуть("заказ-1024", {"items": […], "total": 45000, …})   часть
чек.вернуть("заказ-1024", часть=2)            второй возврат по тому же заказу

Руками это обычный POST /v1/tasks с "kind": "sellReturn" и номером возврат-….

Возврат можно сделать и без сайта

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

Вебхуки

Если в задании задан webhook_url, приёмник сообщит на него о смене состояния: POST с телом

{ "protocol": 1, "event": "state_changed", "task": { …всё задание… } }

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

Два свойства, с которыми надо считаться

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

Вебхук может прийти дважды, и порядок не гарантирован. Состояние берите из тела, а не из очерёдности прихода.

Не ответили 2xx — приёмник повторит по расписанию: через минуту, 5 минут, полчаса, 2 часа, 6 часов, сутки. Опрос GET /v1/tasks/{номер} работает всегда и остаётся основным путём для тех, кому вебхук принять некуда.

Отказы

У каждого отказа две части: код для программы и сообщение для человека.

{
  "error": {
    "code": "validation_failed",
    "message": "ИНН в org должен быть из 10 или 12 цифр",
    "hint": "Проверьте реквизиты организации в настройках модуля"
  }
}

message можно показывать владельцу магазина как есть — он для него и написан.

КодHTTPКогда
bad_signature401Подпись не сходится.
stale_timestamp401Метки нет, она не число или разошлась с временем приёмника больше чем на 5 минут.
unknown_source401Ключ неизвестен приёмнику или источник этому ключу не принадлежит.
unsupported_protocol400Версия конверта приёмнику не известна. В ответе — список тех, что он знает.
validation_failed422Задание не заполнено или заполнено негодно.
duplicate_mismatch409Тот же номер заказа, но другое тело.
already_done409Отменить нельзя: касса уже взяла задание или пробила чек.
payload_too_large413Тело запроса больше 64 КБ или payload больше 60 000 байт.
not_found404Такого задания нет. Или такой ручки.
rate_limited429Слишком часто. В ответе — Retry-After.
storage_error503Приёмнику плохо: недоступна база. Это временно — повторите.

Что повторять, а что нет. 5xx и обрыв связи — повторяйте. 429 — повторяйте, подождав Retry-After. Остальные 4xx — это про ваш запрос: от повтора неверная подпись верной не станет.

Битая подпись, чужой ключ и старая метка отвечают одинаковым 401 нарочно: снаружи не должно быть видно, что именно не так.

Пределы

Тело запроса64 КБ
payload60 000 байт
Окно метки времени5 минут в обе стороны
Частота300 запросов в минуту на ключ (настраивается), сверх — 429 и Retry-After
Порция агенту50 заданий за раз
Попыток на задание5, после чего failed
Хранение тела30 суток после done, failed или cancelled (настраивается)
Что вычищается по сроку

Через месяц после того, как с заданием всё кончено, из очереди вычищается тело: состав заказа и почта покупателя. Номер заказа, суммы и реквизиты чека (ФН, ФД, ФПД) остаются — по ним потом отвечают покупателю и налоговой. Это же значит, что вернуть(номер) без состава работает, пока тело ещё хранится; позже состав возврата придётся передать явно.

Три рабочих примера

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

php пример.php     https://ваш-сайт.ру/svoychek shop-main-key секрет
python пример.py   https://ваш-сайт.ру/svoychek shop-main-key секрет
node пример.js     https://ваш-сайт.ру/svoychek shop-main-key секрет
<?php
/**
 * Рабочий пример: положить чек, посмотреть состояние, снять задание.
 *
 *     php пример.php http://127.0.0.1:8766 shop-main-key секрет
 *
 * Тот же сценарий на Python и JS лежит рядом, в соседних папках.
 */

require __DIR__ . '/svoychek.php';

$адрес  = $argv[1] ?? 'http://127.0.0.1:8766';
$ключ   = $argv[2] ?? 'test-source';
$секрет = $argv[3] ?? 'svoychek-test-secret-2026';

$чек = new SvoyChek\Клиент($адрес, array(
    'ключ' => $ключ, 'секрет' => $секрет,
    'источник' => 'shop-main', 'вид_источника' => 'woocommerce',
    'название' => 'Пример на PHP',
    'инн' => '7701234567', 'кпп' => '770101001', 'сно' => 'usnIncome',
));

$номер = 'ПРИМЕР-PHP-' . time();
$состав = array(
    'email' => 'buyer@example.ru',
    'items' => array(array('name' => 'Кофе 250 г', 'price' => 45000,
        'quantity' => '1', 'sum' => 45000, 'vat' => 'vat22')),
    // Деньги — целые копейки: 45000 это 450 рублей.
    'total' => 45000,
    'payments' => array(array('type' => 'cashless', 'sum' => 45000)),
);

try {
    echo 'приёмник: ' . $чек->живЛи()['time'] . PHP_EOL;

    $задание = $чек->создать($номер, $состав);
    echo 'положено: ' . $задание['state'] . ' ' . $задание['task_id'] . PHP_EOL;

    $ещёРаз = $чек->создать($номер, $состав);
    echo 'повтор вернул то же задание: '
       . ($ещёРаз['task_id'] === $задание['task_id'] ? 'да' : 'НЕТ') . PHP_EOL;

    echo 'состояние: ' . $чек->состояние($номер)['state'] . PHP_EOL;

    // Возврат: состав и ставки берутся из самой продажи, номер у него свой.
    $назад = $чек->вернуть($номер);
    echo 'возврат: ' . $назад['state'] . ' ' . $назад['external_id'] . PHP_EOL;
    $чек->отменить($назад['external_id']);

    echo 'снято: ' . $чек->отменить($номер)['state'] . PHP_EOL;
} catch (SvoyChek\ЧекError $ошибка) {
    echo "отказ [{$ошибка->код}]: {$ошибка->getMessage()} {$ошибка->подсказка}" . PHP_EOL;
    exit(1);
}
"""Рабочий пример: положить чек, посмотреть состояние, снять задание.

    python пример.py http://127.0.0.1:8766 shop-main-key секрет

Тот же сценарий на PHP и JS лежит рядом, в соседних папках.
"""
import sys
import time

sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent))
from svoychek import Клиент, ЧекError  # noqa: E402

адрес = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8766"
ключ = sys.argv[2] if len(sys.argv) > 2 else "test-source"
секрет = sys.argv[3] if len(sys.argv) > 3 else "svoychek-test-secret-2026"

чек = Клиент(адрес, ключ=ключ, секрет=секрет, источник="shop-main",
             вид_источника="custom", название="Пример на Python",
             инн="7701234567", кпп="770101001", сно="usnIncome")

номер = f"ПРИМЕР-PY-{int(time.time())}"
try:
    print("приёмник:", чек.жив_ли().get("time"))

    задание = чек.создать(номер, {
        "email": "buyer@example.ru",
        "items": [{"name": "Кофе 250 г", "price": 45000, "quantity": "1",
                   "sum": 45000, "vat": "vat22"}],
        # Деньги — целые копейки: 45000 это 450 рублей.
        "total": 45000,
        "payments": [{"type": "cashless", "sum": 45000}],
    })
    print("положено:", задание["state"], задание["task_id"])

    ещё_раз = чек.создать(номер, {
        "email": "buyer@example.ru",
        "items": [{"name": "Кофе 250 г", "price": 45000, "quantity": "1",
                   "sum": 45000, "vat": "vat22"}],
        "total": 45000,
        "payments": [{"type": "cashless", "sum": 45000}],
    })
    print("повтор вернул то же задание:",
          ещё_раз["task_id"] == задание["task_id"])

    print("состояние:", чек.состояние(номер)["state"])

    # Возврат: состав, ставки и способ оплаты берутся из самой продажи —
    # передавать их заново не надо и не нужно. Номер у возврата свой.
    назад = чек.вернуть(номер)
    print("возврат:", назад["state"], назад["external_id"])
    чек.отменить(назад["external_id"])

    print("снято:", чек.отменить(номер)["state"])
except ЧекError as ошибка:
    print(f"отказ [{ошибка.код}]: {ошибка} {ошибка.подсказка}")
    sys.exit(1)
/**
 * Рабочий пример: положить чек, посмотреть состояние, снять задание.
 *
 *     node пример.js http://127.0.0.1:8766 shop-main-key секрет
 *
 * Тот же сценарий на PHP и Python лежит рядом, в соседних папках.
 */

'use strict';

const { Клиент, ЧекError } = require('./svoychek');

const адрес = process.argv[2] || 'http://127.0.0.1:8766';
const ключ = process.argv[3] || 'test-source';
const секрет = process.argv[4] || 'svoychek-test-secret-2026';

const чек = new Клиент(адрес, {
  ключ, секрет,
  источник: 'shop-main', видИсточника: 'custom', название: 'Пример на JS',
  инн: '7701234567', кпп: '770101001', сно: 'usnIncome',
});

const номер = `ПРИМЕР-JS-${Math.floor(Date.now() / 1000)}`;
const состав = {
  email: 'buyer@example.ru',
  items: [{ name: 'Кофе 250 г', price: 45000, quantity: '1',
            sum: 45000, vat: 'vat22' }],
  // Деньги — целые копейки: 45000 это 450 рублей.
  total: 45000,
  payments: [{ type: 'cashless', sum: 45000 }],
};

(async () => {
  try {
    console.log('приёмник:', (await чек.живЛи()).time);

    const задание = await чек.создать(номер, состав);
    console.log('положено:', задание.state, задание.task_id);

    const ещёРаз = await чек.создать(номер, состав);
    console.log('повтор вернул то же задание:',
                ещёРаз.task_id === задание.task_id ? 'да' : 'НЕТ');

    console.log('состояние:', (await чек.состояние(номер)).state);

    // Возврат: состав и ставки берутся из самой продажи, номер у него свой.
    const назад = await чек.вернуть(номер);
    console.log('возврат:', назад.state, назад.external_id);
    await чек.отменить(назад.external_id);

    console.log('снято:', (await чек.отменить(номер)).state);
  } catch (ошибка) {
    if (ошибка instanceof ЧекError) {
      console.log(`отказ [${ошибка.код}]: ${ошибка.message} ${ошибка.подсказка}`);
      process.exit(1);
    }
    throw ошибка;
  }
})();

Обёртки берут на себя подпись, повтор при обрыве и 5xx, разбор отказов и проверку подписи вебхука. Ставить ничего не надо: PHP 7.4+ без Composer, Python на стандартной библиотеке, Node 18+ без пакетов.

Если кода на сайте нет

Так бывает у конструкторов вроде Тильды и там, куда в код не пускают. Тогда чек пробивается по уведомлению банка о том, что деньги пришли, а не по слову сайта о том, что заказ оформлен: адрес уведомления вписывается в кабинете эквайринга, и своего кода не нужно вовсе.

Готовые адаптеры: PayKeeper, ЮKassa, Робокасса, CloudPayments. Состав корзины они берут из самого уведомления (CloudPayments везёт свой чек целиком) или дочитывают с сайта по номеру заказа.

Чего нельзя

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

Частые беды

«Подпись не сходится», а всё вроде правильно

Четыре причины, и они покрывают почти все случаи:

  1. Тело пересобрали между подписью и отправкой. Самая частая. Подписывайте те самые байты, которые уходят в сеть.
  2. Путь нормализовали. parse_url со сборкой обратно, urldecode, снятая двойная косая черта — и подпись разъезжается на номерах заказов вида 2026/09/05-17.
  3. Часы разъехались. Сверьте своё время с GET /v1/health: окно всего 5 минут.
  4. Заголовок не доехал. Часть хостингов не пропускает свои заголовки в PHP через CGI/FastCGI — X-SvoyChek-Signature приходит как HTTP_X_SVOYCHEK_SIGNATURE, а иногда не приходит вовсе, и нужна строчка в .htaccess.

Задание положено, а чека нет

Посмотрите GET /v1/tasks/{номер}. queued — касса ещё не приходила: проверьте, запущена ли служба на кассовом компьютере. failed — в error написано, что случилось, человеческими словами.

Чек ушёл не на ту кассу

Агент отдаёт задание только той кассе, у которой сходится ИНН из org. Если касс несколько и нужна конкретная — назовите её в device.zn.

Два чека на один заказ

Такого не бывает, пока external_id — ваш номер заказа, а не время или случайное число. Если номер каждый раз новый, очередь не может узнать в повторе повтор.