Как это устроено
«Свой чек» пробивает чеки по 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) — для агента,
и ходит он в них своим паролем: ключ источника и пароль агента
не взаимозаменяемы.
Тело — конверт (см. ниже). Ответ 201 — задание создано
и лежит в очереди; 200 — такое уже было, вернулось оно же.
Номер заказа — в процентном кодировании. source_id нужен,
когда ключ обслуживает несколько источников; когда один — можно опустить.
В ответе — всё задание: состояние, попытки, реквизиты чека или причина
отказа.
Снимается только то, что ещё не взяла касса. Уже пробитый чек не отменяется — на это есть возврат.
Единственная ручка без подписи.
В каждом ответе приёмник называет свою версию:
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 цифр), КПП и система налогообложения. Маршрутизация не туда — это нарушение, поэтому агент не пробьёт задание на кассе с другими регистрационными данными. |
kind | sell, 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_signature | 401 | Подпись не сходится. |
stale_timestamp | 401 | Метки нет, она не число или разошлась с временем приёмника больше чем на 5 минут. |
unknown_source | 401 | Ключ неизвестен приёмнику или источник этому ключу не принадлежит. |
unsupported_protocol | 400 | Версия конверта приёмнику не известна. В ответе — список тех, что он знает. |
validation_failed | 422 | Задание не заполнено или заполнено негодно. |
duplicate_mismatch | 409 | Тот же номер заказа, но другое тело. |
already_done | 409 | Отменить нельзя: касса уже взяла задание или пробила чек. |
payload_too_large | 413 | Тело запроса больше 64 КБ или payload больше 60 000 байт. |
not_found | 404 | Такого задания нет. Или такой ручки. |
rate_limited | 429 | Слишком часто. В ответе — Retry-After. |
storage_error | 503 | Приёмнику плохо: недоступна база. Это временно — повторите. |
Что повторять, а что нет. 5xx и обрыв связи —
повторяйте. 429 — повторяйте, подождав Retry-After.
Остальные 4xx — это про ваш запрос: от повтора неверная
подпись верной не станет.
Битая подпись, чужой ключ и старая метка отвечают одинаковым
401 нарочно: снаружи не должно быть видно, что именно не так.
Пределы
| Тело запроса | 64 КБ |
|---|---|
payload | 60 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 везёт свой чек целиком) или дочитывают с сайта по номеру заказа.
Пробивать чек по вебхуку конструктора «заказ оформлен». Он приходит и по неоплаченным заказам, а чек — это деньги в налоговой. Достоверное событие даёт эквайринг, а вебхук конструктора годится только на то, чтобы узнать состав и почту.
Частые беды
«Подпись не сходится», а всё вроде правильно
Четыре причины, и они покрывают почти все случаи:
- Тело пересобрали между подписью и отправкой. Самая частая. Подписывайте те самые байты, которые уходят в сеть.
- Путь нормализовали.
parse_urlсо сборкой обратно,urldecode, снятая двойная косая черта — и подпись разъезжается на номерах заказов вида2026/09/05-17. - Часы разъехались. Сверьте своё время с
GET /v1/health: окно всего 5 минут. - Заголовок не доехал. Часть хостингов не пропускает свои
заголовки в PHP через CGI/FastCGI —
X-SvoyChek-Signatureприходит какHTTP_X_SVOYCHEK_SIGNATURE, а иногда не приходит вовсе, и нужна строчка в.htaccess.
Задание положено, а чека нет
Посмотрите GET /v1/tasks/{номер}. queued —
касса ещё не приходила: проверьте, запущена ли служба на кассовом
компьютере. failed — в error написано, что
случилось, человеческими словами.
Чек ушёл не на ту кассу
Агент отдаёт задание только той кассе, у которой сходится ИНН
из org. Если касс несколько и нужна конкретная — назовите
её в device.zn.
Два чека на один заказ
Такого не бывает, пока external_id — ваш номер заказа,
а не время или случайное число. Если номер каждый раз новый, очередь
не может узнать в повторе повтор.