Приём оплаты на сайте — SCI

Покупатель переходит на страницу оплаты, платит с любого кошелька, а ваш сервер получает подписанное уведомление с итогом. Здесь — всё, что нужно для подключения.

Как это работает

  1. Покупатель нажимает «Оплатить» на вашем сайте — браузер отправляет подписанную форму на страницу оплаты.
  2. Создаётся счёт: покупатель выбирает монету и видит сумму, адрес, QR-код и таймер.
  3. Покупатель переводит монеты. Мы ждём подтверждений сети и сверяем сумму, монету и время.
  4. Ваш сервер получает уведомление с итогом, покупатель возвращается на ваш сайт.

Подключение по шагам

  1. В кабинете: «Проекты» → «Добавить проект», название и адрес сайта. Вы получите ID магазина, ключ формы (sk_…) и секрет вебхуков (whsec_…). Ключи показываются один раз — сохраните их на сервере магазина.
  2. «Монеты» — включите монеты и сети, которые принимаете.
  3. «Страница оплаты» — срок счёта, допуск суммы, язык, адреса возврата и почта поддержки для покупателей.
  4. «Уведомления» — адрес вебхука и «Отправить тестовое».
  5. «Подключение» — подтвердите домен и переключите проект в режим «Тест».
  6. Проверьте свою сторону на тестовых счетах и переключите проект в режим «Работает».
Открыть проекты

Форма оплаты

Соберите форму на сервере магазина: браузер покупателя отправит поля методом POST на адрес страницы оплаты. Ключ формы в браузер не попадает — в форме только подпись.

<form method="post" action="https://sci.coinvertus.net">
  <input type="hidden" name="shop" value="P4K7Q2A9">
  <input type="hidden" name="order_id" value="10482">
  <input type="hidden" name="amount" value="49.90">
  <input type="hidden" name="currency" value="USD">
  <input type="hidden" name="sign" value="<?= $sign ?>">
  <input type="hidden" name="success_url" value="https://shop.example/orders/10482">
  <button type="submit">Pay with crypto</button>
</form>

Поля формы

ПолеОписание
shop
Обязательное
ID магазина из кабинета, 8 символов.
order_id
Обязательное
Номер заказа в магазине: 1–64 символа без пробелов. Повтор с той же суммой и валютой откроет тот же счёт, с другой — ошибка S005.
amount
Обязательное
Сумма с точкой, до двух знаков после неё: 49.90, 49.9 или 50. Больше нуля.
currency
Обязательное
Валюта суммы: USD, EUR, RUB, CNY или INR. Сумму в монете страница считает по курсу, когда покупатель выбирает монету.
sign
Обязательное
Подпись формы — ниже.
assetМонета за покупателя в виде «символ:сеть» — как asset и network в уведомлении, например DOGE:dogecoin-mainnet или USDT:bsc. Если монета включена в проекте, покупатель сразу видит реквизиты.
descriptionОписание заказа до 255 символов, покупатель видит его на странице оплаты.
emailПочта покупателя, до 254 символов.
langЯзык страницы: ru, en или cn. Без него — язык из настроек проекта, иначе язык браузера. Покупатель может переключить язык сам.
success_urlКуда вернуть покупателя после оплаты, http(s)-адрес. Без него — адрес из настроек проекта, иначе сайт магазина.
fail_urlКуда вернуть покупателя, если оплаты не было или она не совпала. Без него — адрес из настроек проекта, иначе сайт магазина.
metadataJSON-объект или массив до 4 КБ, возвращается в уведомлении как есть.

Подпись

sign — HMAC-SHA256 в шестнадцатеричном виде от строки shop:order_id:amount:currency, ключ — ключ формы проекта. Значения берутся ровно такими, какими уходят в форму: 49.90 и 49.9 дают разные подписи.

$shop = 'P4K7Q2A9';
$formKey = getenv('CVC_SCI_FORM_KEY');
$orderId = '10482';
$amount = '49.90';
$currency = 'USD';
$sign = hash_hmac('sha256', $shop . ':' . $orderId . ':' . $amount . ':' . $currency, $formKey);
Подпись связывает только магазин, заказ, сумму и валюту. Остальные поля покупатель может изменить в браузере: не доверяйте metadata и адресам возврата, ищите заказ по order_id и подтверждайте оплату только уведомлением.

Ошибки формы

Если форма не прошла проверку, покупатель видит страницу с кодом ошибки и ссылкой на ваш сайт.

Код ошибкиЧто значит
S002Поле не прошло проверку — его название есть в тексте ошибки.
S003Подпись не совпала.
S004Магазин не найден или не принимает оплату: проект в режиме «Черновик», в режиме «Работает» без подтверждённого домена или тариф без приёма оплаты.
S005Заказ с этим order_id уже открыт с другой суммой или валютой.
S006Внутренняя ошибка при создании счёта — повторите позже.
S007Слишком много новых счетов: не больше 300 в час на проект и 30 в час с одного адреса покупателя.

Счёт и статусы

Срок счёта — от 15 до 120 минут, он задаётся во вкладке «Страница оплаты». Сумма в монете фиксируется, когда покупатель выбирает монету; сменить монету можно до трёх раз. Сколько подтверждений ждать, решает сеть.

СтатусОписание
newСчёт создан, монета не выбрана.
waitingМонета выбрана, ждём перевод.
confirmingПеревод в сети, ждём подтверждений.
partialПришла часть суммы, ждём доплату — не меньше 15 минут от первой части.
paidОплачен: сумма в допуске, в срок, та монета.
receivedДеньги пришли, но что-то не совпало — причина в warnings.
expiredСрок вышел, перевода не было. Перевод ровно на сумму, пришедший позже, всё равно будет учтён: счёт станет received с incorrect time.
cancelledМагазин отменил счёт через API до оплаты.

Возврат в магазин

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

API сервера магазина

Форма подходит, когда счёт открывает браузер покупателя. Если заказ оформляет ваш сервер, создайте счёт через API и отправьте покупателя по ссылке url из ответа. Через API же можно проверить счёт, если уведомление не дошло, отменить счёт без оплаты и получить список монет с суммами.

Метод и адресОписание
POST https://sci.coinvertus.net/api/v1/invoicesСоздать счёт.
GET https://sci.coinvertus.net/api/v1/invoices/{number}Счёт по его номеру, например CVC-7F3K2A.
GET https://sci.coinvertus.net/api/v1/invoices?order_id=…Счёт по номеру заказа в магазине.
POST https://sci.coinvertus.net/api/v1/invoices/{number}/cancelОтменить счёт, пока по нему нет перевода.
GET https://sci.coinvertus.net/api/v1/assets?currency=USD&amount=49.90Монеты, которые сейчас предложит страница оплаты, с курсом и суммой.

Подпись запроса

Каждый запрос подписывается ключом формы проекта — тем же, что и форма; сам ключ не передаётся. v1 — HMAC-SHA256 в шестнадцатеричном виде от строки «t.МЕТОД.путь.тело»: путь — от /v1/ вместе со строкой запроса, ровно как вы его отправляете; тело — те же байты, что уходят в запросе, у GET — пустая строка. Запрос, у которого t отличается от наших часов больше чем на 5 минут, отклоняется.

ЗаголовокОписание
X-CVC-ShopID магазина — то же значение, что в поле формы shop.
X-CVC-SignatureПодпись: t=<время Unix>,v1=<подпись>.
<?php
function cvcSignature(string $method, string $path, string $body): string
{
    $t = time();
    $v1 = hash_hmac('sha256', $t . '.' . $method . '.' . $path . '.' . $body, getenv('CVC_SCI_FORM_KEY'));
    return 't=' . $t . ',v1=' . $v1;
}

$body = json_encode([
    'order_id' => '10482',
    'amount' => '49.90',
    'currency' => 'USD',
    'success_url' => 'https://shop.example/orders/10482',
]);
$response = $http->request('POST', 'https://sci.coinvertus.net/api/v1/invoices', $body, [
    'Content-Type: application/json',
    'X-CVC-Shop: P4K7Q2A9',
    'X-CVC-Signature: ' . cvcSignature('POST', '/v1/invoices', $body),
]);
if ($response->status === 200 || $response->status === 201) {
    header('Location: ' . json_decode($response->body, true)['url'], true, 303);
}

$path = '/v1/invoices?order_id=' . rawurlencode('10482');
$response = $http->request('GET', 'https://sci.coinvertus.net/api' . $path, '', [
    'X-CVC-Shop: P4K7Q2A9',
    'X-CVC-Signature: ' . cvcSignature('GET', $path, ''),
]);

$http — ваш HTTP-клиент: он отправляет запрос с этими заголовками и возвращает код ответа и тело.

Создание счёта

Тело — JSON с теми же полями, что у формы, без shop и sign: order_id, amount и currency обязательны, остальные — по желанию. amount и order_id можно передать числом, metadata — объектом. Новый счёт возвращается с кодом 201, повтор того же order_id с той же суммой и валютой — тот же счёт с кодом 200. Лимит — 300 новых счетов в час на проект.

{
    "test": false,
    "validation": null,
    "warnings": [],
    "invoice": {
        "number": "CVC-7F3K2A",
        "order_id": "10482",
        "status": "new",
        "amount": "49.90",
        "currency": "USD",
        "asset": null,
        "network": null,
        "rate": null,
        "amount_crypto": null,
        "received_crypto": null,
        "remaining_crypto": null,
        "overpaid_crypto": null,
        "expires_at": "2026-09-17T12:22:00Z",
        "metadata": {
            "cart": "a17"
        }
    },
    "settlement": {
        "mode": "direct",
        "fee_percent": "0.00"
    },
    "url": "https://sci.coinvertus.net/pY3kQ9vT2mXc8LwR1nZb4A"
}

Ответ — счёт в тех же полях, что в уведомлении: invoice, settlement и test. validation и warnings — текущий итог счёта: null и пустой список, пока денег нет. url — страница оплаты: на неё отправьте покупателя. Проверка счёта и отмена отвечают так же.

Отмена счёта

Отменить можно только счёт без денег: в статусе new или waiting и без перевода, даже неподтверждённого. Счёт получает статус cancelled, вашему серверу уходит уведомление invoice.cancelled, повторная отмена возвращает тот же счёт. Если покупатель всё же переведёт ровно сумму счёта, перевод будет учтён: придёт payment.received с validation false; другая сумма уведомления не даст.

Список монет

Монеты, которые страница оплаты предложит прямо сейчас: включённые в проекте и с актуальным курсом. currency — валюта курса, по умолчанию USD; с amount в ответе есть и сумма в монете, как её посчитает счёт. asset и network подходят для поля asset: DOGE:dogecoin-mainnet.

Ошибки API

Ошибка — код HTTP и тело {"error": {"code": "S002", "message": "…"}}; у S002 ещё есть fields — имена неверных полей. message — для людей, на языке из Accept-Language; в коде опирайтесь на code.

Код HTTPКод ошибкиЧто значит
400S002Неверное поле или тело запроса; имена полей — в error.fields.
401S003Неизвестный X-CVC-Shop, неверная или устаревшая подпись.
403S004Проект не принимает оплату: черновик, режим «Работает» без подтверждённого домена или тариф без приёма оплаты. Только при создании счёта.
404S008Счёта с таким номером или order_id в проекте нет.
409S005Заказ с этим order_id уже открыт с другой суммой или валютой.
409S009Отмена невозможна: у счёта уже есть перевод или его статус не new и не waiting.
429S007Больше 300 новых счетов за час в проекте.
500S006Внутренняя ошибка при создании счёта — повторите позже.

Уведомления (вебхуки)

Адрес задаётся во вкладке «Уведомления»: https-адрес и, если нужно, IP сервера и Host. Смена адреса — с кодом подтверждения из приложения-аутентификатора или из письма. «Отправить тестовое» шлёт ping; без доставленного ping проект нельзя перевести в «Работает».

Уведомление — POST с телом в JSON. Успех — любой ответ 2xx в течение 10 секунд. Иначе повторяем через 1 минуту, 5 минут, 30 минут, 2 часа, 6 часов и 24 часа. Редиректы не выполняются, ответ читается не больше 64 КБ.

Уведомления приходят с разных адресов — не ограничивайте приём по IP, проверяйте подпись.
ЗаголовокОписание
X-CVC-EventСобытие, как event в теле.
X-CVC-Deliveryevent_id и номер попытки.
X-CVC-SignatureПодпись: t=<время Unix>,v1=<подпись>.

v1 — HMAC-SHA256 в шестнадцатеричном виде от строки «t.тело», ключ — секрет вебхуков проекта. Берите тело ровно в тех байтах, что пришли, до разбора JSON, и отклоняйте уведомление, если t отличается от ваших часов больше чем на 5 минут.

Одно и то же уведомление может прийти дважды — обрабатывайте каждый event_id один раз. Порядок доставки не гарантирован: не откатывайте оплаченный заказ из-за более раннего уведомления.

События уведомлений

СобытиеКогда приходит
payment.receivedПеревод на адрес счёта набрал подтверждения. Несёт validation и warnings. На каждый выход транзакции — своё уведомление.
invoice.expiredСрок счёта вышел: перевода не было (status expired) или не доплатили (status received).
invoice.cancelledСчёт отменён через API.
payment.reversedУчтённый перевод пропал из цепочки после реорганизации блоков — разбираемся на нашей стороне.
payment.restoredБлок с переводом вернулся в цепочку, второй раз платёж не учитывается.
pingТестовое уведомление из кабинета.

Все события, кроме ping, можно выключить галочками во вкладке «Уведомления».

Тело уведомления

Суммы — строкой с точкой, без лишних нулей. Пока монета не выбрана, поля монеты равны null. Время — в UTC.

{
    "event": "payment.received",
    "event_id": "evt_0123456789abcdef01234567",
    "created_at": "2026-09-17T12:10:00Z",
    "test": false,
    "validation": true,
    "warnings": [],
    "invoice": {
        "number": "CVC-7F3K2A",
        "order_id": "10482",
        "status": "paid",
        "amount": "49.90",
        "currency": "USD",
        "asset": "DOGE",
        "network": "dogecoin-mainnet",
        "rate": "0.275",
        "amount_crypto": "181.45",
        "received_crypto": "181.45",
        "remaining_crypto": "0",
        "overpaid_crypto": "0",
        "expires_at": "2026-09-17T12:22:00Z",
        "metadata": {
            "cart": "a17"
        }
    },
    "payment": {
        "txid": "c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0",
        "output": 1,
        "asset": "DOGE",
        "network": "dogecoin-mainnet",
        "amount_crypto": "181.45",
        "confirmations": 6,
        "seen_at": "2026-09-17T11:59:02Z"
    },
    "settlement": {
        "mode": "direct",
        "fee_percent": "0.00"
    }
}
ПолеОписание
testtrue — счёт создан в режиме «Тест».
validation, warningsИтог проверки платежа, только в payment.received.
invoice.numberНомер счёта на странице оплаты.
invoice.statusСтатус счёта после события.
invoice.amount_crypto, received_crypto, remaining_crypto, overpaid_cryptoСумма счёта в монете, получено, осталось доплатить, переплата.
invoice.rateКурс монеты к валюте счёта на момент выбора монеты.
invoice.metadatametadata из формы как есть.
payment.outputНомер выхода: у UTXO — выход транзакции на адрес счёта (их в одной транзакции может быть несколько); у EVM — 0 для прямого перевода монеты, 1 и дальше — переводы монеты из контракта в той же транзакции, у токена — 1000000 плюс номер записи журнала в блоке (logIndex). У EVM txid — с 0x. У TRON — так же, но внутренние переводы монеты нумеруются с 1. У Solana — номер перевода в транзакции, у токена — 1000000 плюс он; txid — подпись транзакции. У XRP — 0, txid — хеш транзакции; счёт узнаётся по тегу назначения. У TON — 0, txid — хеш транзакции адреса проекта; счёт узнаётся по комментарию к переводу. У жетона TON — 1000000 плюс номер события в транзакции, txid — хеш транзакции кошелька жетона. У токена XRPL — 1000000, txid — хеш транзакции; счёт — тоже по тегу назначения.
payment.seen_atКогда перевод появился в сети — по этому времени проверяется срок.
settlementКуда идут деньги: direct — сразу на ваш кошелёк, pool — через котёл с комиссией fee_percent.

validation и warnings

Отгружайте заказ только при validation: true. При validation: false деньги всё равно зачислены магазину — решите сами: отгрузить, вернуть разницу или связаться с покупателем.

Предупреждение в warningsЧто значит
incorrect amountСумма вне допуска: недоплата, переплата или доплата на уже закрытый счёт.
incorrect network or tokenПришла не та монета или сеть.
incorrect timeПеревод появился в сети после срока счёта.
СитуацияСтатусvalidationwarnings
Сумма в допуске, в срок, та монетаpaidtrue—
Недоплата, срок идётpartialfalseincorrect amount
Доплатил в срокpaidtrue—
Недоплата, срок доплаты вышелreceivedfalseincorrect amount
Переплата сверх допускаreceivedfalseincorrect amount
Та же монета в другой сети, ровно на суммуreceivedfalseincorrect network or token
Перевод после срока, ровно на суммуreceivedfalseincorrect time
Другая монета, нулевой перевод, любой перевод на оплаченный счёт, после срока — не ровно на суммуне меняется, уведомления нет——

Несколько переводов на один счёт суммируются; допуск суммы задаётся во вкладке «Страница оплаты».

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

Тестовый режим и имитация

В режиме «Тест» счета помечены test: true, а страница оплаты принимает те же монеты, что и в работе. Внизу страницы тестового счёта есть «Имитация оплаты»: оплатить точно, часть или с переплатой, истечь срок, откатить блок и вернуть его — без перевода. Счёт проходит тот же путь, что при настоящей оплате, и магазин получает те же уведомления с test: true.

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

Подтверждение домена

Для режима «Работает» подтвердите, что сайт ваш: положите в корень сайта файл cvc-sci.txt со строкой из вкладки «Подключение» и нажмите «Проверить». Если домен ещё не смотрит на ваш сервер, укажите IP сервера — проверка придёт на него.

Свой домен

На тарифах со своим доменом покупатель платит на вашем адресе, например pay.example.com, и адрес CoinVertus не видит. Перед страницей оплаты стоит ваш сервер (фасад): он держит ваш сертификат и проксирует страницу оплаты на sci.coinvertus.net. Сервер и сертификат — ваши.

  1. Направьте домен на свой сервер и выпустите для него сертификат.
  2. Настройте nginx по образцу ниже: файл /cvc-sci.txt, свои стили и иконку сервер отдаёт сам, пути страницы оплаты проксирует на sci.coinvertus.net, на остальное отвечает 404.
  3. Во вкладке «Подключение» проекта укажите домен и IP своих серверов, с которых они обращаются к sci.coinvertus.net. Смена — с кодом подтверждения.
  4. Отдавайте в /cvc-sci.txt ту же строку, что и для сайта проекта, и нажмите «Проверить».
  5. После подтверждения ссылки на новые счета — из формы и из API — ведут на ваш домен. Форму оплаты можно отправлять на корень своего домена. Если сайт магазина задаёт CSP form-action, добавьте в неё свой домен оплаты: после формы покупатель переходит на него.

Пример nginx для вашего сервера:

server {
    listen 443 ssl;
    server_name pay.example.com;
    ssl_certificate /etc/ssl/pay.example.com/fullchain.pem;
    ssl_certificate_key /etc/ssl/pay.example.com/privkey.pem;

    location = /cvc-sci.txt {
        default_type text/plain;
        return 200 "cvc-sci=P4K7Q2A9-0123456789abcdef0123456789abcdef\n";
    }
    location = /cvc-sci.css {
        root /srv/pay;
    }
    location = /favicon.ico {
        root /srv/pay;
    }

    location ~ "^/(?:$|[A-Za-z0-9_-]{22}$|sci/|api/v1/|language$|css/|js/|fonts/|webfonts/|images/|coin-icon/)" {
        proxy_pass https://sci.coinvertus.net;
        proxy_set_header Host sci.coinvertus.net;
        proxy_ssl_server_name on;
        proxy_ssl_name sci.coinvertus.net;
        proxy_redirect https://sci.coinvertus.net/ /;
    }

    location / {
        return 404;
    }
}
С указанных IP к нам приходят все ваши покупатели: защита от перебора их не блокирует, а лимит счетов с одного IP к ним не применяется. Поэтому лишние пути отсекайте на своём сервере. Если тариф перестанет включать свой домен, магазин не будет принимать новые счета, пока вы не смените тариф или не уберёте домен.

Оформление страницы

На вашем домене страница оплаты после наших стилей подключает /cvc-sci.css — этот файл отдаёт ваш сервер, пустой файл оставляет вид как есть. Логотип, иконка и название CoinVertus на вашем домене не показываются. Опирайтесь на классы из таблицы: остальная разметка может меняться.

СелекторОписание
.cvc-sci-ownВся страница на вашем домене: фон, шрифт, основной цвет — переменная --cvc-color-primary.
.cvc-sci-barВерхняя полоса с выбором языка.
.cvc-sci-brandМесто под ваш логотип слева в полосе: пустой блок, задайте ему размер и фон.
.cvc-sci-columnКолонка по центру: ширина полосы и карточки.
.cvc-sci-cardКарточка счёта.
.cvc-sci-shop-avatarПервая буква названия магазина в шапке счёта.
.cvc-sci-coin-box .cvc-sci-asset-iconВыбранная монета и иконки монет и сетей.
.cvc-sci-copy-value .cvc-sci-addressСумма, адрес и другие реквизиты, которые покупатель копирует.
.cvc-sci-qrQR-код для оплаты.
.btn-loginГлавные кнопки: «Оплатить из кошелька», «Продолжить».
.cvc-sci-actionВторостепенные кнопки: чек, письмо магазину.
.cvc-sci-waiting .cvc-sci-found .cvc-sci-outcome-iconОжидание оплаты, найденный платёж и значок исхода.
.cvc-sci-progressПолоса подтверждений и доплаты.
.cvc-sci-rowsСтроки сведений о платеже на экране исхода.

Пример /cvc-sci.css:

.cvc-sci-own {
    --cvc-color-primary: #1d4ed8;
    background: #f4f1ea;
}
.cvc-sci-brand {
    width: 140px;
    height: 40px;
    background: url(/logo.svg) no-repeat left center / contain;
}
.cvc-sci-card {
    border-radius: 4px;
}

Политика безопасности страницы пускает стили только из файлов вашего домена, картинки и шрифты — с вашего домена или data:. Внешние CDN не подключатся.

Пример на PHP

Подпись и форма:

<?php
$shop = 'P4K7Q2A9';
$formKey = getenv('CVC_SCI_FORM_KEY');
$orderId = '10482';
$amount = '49.90';
$currency = 'USD';
$sign = hash_hmac('sha256', $shop . ':' . $orderId . ':' . $amount . ':' . $currency, $formKey);
?>
<form method="post" action="https://sci.coinvertus.net">
  <input type="hidden" name="shop" value="P4K7Q2A9">
  <input type="hidden" name="order_id" value="10482">
  <input type="hidden" name="amount" value="49.90">
  <input type="hidden" name="currency" value="USD">
  <input type="hidden" name="sign" value="<?= $sign ?>">
  <input type="hidden" name="success_url" value="https://shop.example/orders/10482">
  <button type="submit">Pay with crypto</button>
</form>

Приём уведомления:

<?php
$secret = getenv('CVC_SCI_WEBHOOK_SECRET');
$body = file_get_contents('php://input');

$t = null;
$signatures = [];
foreach (explode(',', $_SERVER['HTTP_X_CVC_SIGNATURE'] ?? '') as $pair) {
    [$name, $value] = array_pad(explode('=', trim($pair), 2), 2, '');
    if ($name === 't') {
        $t = $value;
    } elseif ($name === 'v1') {
        $signatures[] = strtolower($value);
    }
}
$expected = hash_hmac('sha256', $t . '.' . $body, $secret);
$valid = $t !== null && ctype_digit($t) && abs(time() - (int) $t) <= 300
    && array_filter($signatures, fn (string $v1): bool => hash_equals($expected, $v1)) !== [];
if (!$valid) {
    http_response_code(400);
    exit;
}

$hook = json_decode($body, true);
if (($hook['test'] && $liveMode) || !$events->remember($hook['event_id'])) {
    http_response_code(200);
    exit;
}
$orderId = $hook['invoice']['order_id'];
switch ($hook['event']) {
    case 'payment.received':
        $hook['validation']
            ? $orders->markPaid($orderId)
            : $orders->markForReview($orderId, $hook['warnings']);
        break;
    case 'invoice.expired':
        $hook['invoice']['status'] === 'expired'
            ? $orders->cancel($orderId)
            : $orders->markForReview($orderId, ['incorrect amount']);
        break;
    case 'payment.reversed':
        $orders->markForReview($orderId, ['reversed']);
        break;
}
http_response_code(200);

$orders, $events и $liveMode — ваши хранилище заказов, журнал обработанных event_id и признак режима «Работает». Отвечайте 200, только когда уведомление обработано.