# Как подключить эквайринг Monobank без программиста через вайбкодинг

> Пошаговое руководство по подключению онлайн-оплат Monobank на сайт: комплаенс для ФЛП, создание терминала, верификация вебхуков ECDSA, надежный UX и тесты безопасности.

Вайбкодинг кардинально изменил разработку цифровых продуктов. Сегодня, чтобы добавить на сайт прием платежей через Apple Pay, Google Pay или банковские карты, больше не нужно нанимать бэкенд-разработчика или самостоятельно разбираться в криптографических протоколах. Достаточно уметь четко сформулировать бизнес-логику своему AI-агенту (Codex, Antigravity, Cursor или Claude Code) и предоставить ему правильный контекст технической документации.

В этом руководстве разобран полный жизненный цикл подключения интернет-эквайринга Monobank к вашему проекту: от прохождения комплаенса банка и настройки кабинета предпринимателя до генерации защищенного платежного шлюза с криптографической верификацией подписей ECDSA в вебхуках, обработки состояния гонки в интерфейсе и набора обязательных автотестов.

> [!TIP]
> **Видеоверсия руководства:** Если вы предпочитаете наглядный формат, посмотрите [подробное практическое видео на YouTube →](https://youtu.be/GMh_fOCiQ4E), где весь процесс показан на живом экране от первого промпта до реального списания средств.

> [!NOTE]
> **Пакет агентских скилов:** Для максимальной точности генерации кода скачайте официальный пакет знаний для вашего AI-ассистента:  
> [Скачать полный архив monobank-acquiring.zip (38 KB) →](/downloads/monobank-acquiring.zip)

---

## 1. Архитектура Hosted Checkout и жизненный цикл платежа

Monobank Acquiring работает по схеме **Hosted Checkout** (оплата на защищенной странице банка). Это означает, что вашему сайту не требуется сложная и дорогая сертификация безопасности PCI DSS, поскольку платежные данные карт пользователь вводит непосредственно на защищенном домене банка.

Официальная документация Monobank для AI-инструментов доступна по адресу [monobank.ua/api-docs/acquiring/dev/ai-tools/docs--ai-prompts →](https://monobank.ua/api-docs/acquiring/dev/ai-tools/docs--ai-prompts). Сохраните эту ссылку как канонический первоисточник технических требований банка.

### 1.1. Базовый платежный сценарий

```
[ Клиент на сайте ]
       │
       ├─ 1. Нажимает «Оплатить»
       ▼
[ Ваш Сервер ] ──────────────► [ Monobank API: /invoice/create ]
       ▲                              │
       │  получает pageUrl, invoiceId │
       └──────────────────────────────┘
       │
       ├─ 2. Редирект клиента на pageUrl (Hosted Checkout)
       ▼
[ Платежная страница Monobank ] ──► Оплата (Apple Pay / Google Pay / Карта)
       │
       ├─ 3. Асинхронный POST вебхук с подписью x-sign
       ▼
[ Ваш Вебхук-эндпоинт ] ─────────► Проверка ECDSA SHA-256 → Статус "success"
       │
       ├─ 4. Клиент возвращается на /payment-result
       ▼
[ Страница успеха ] ────────────► Отображение доступа / чека
```

- **1. Создание счета (`invoice/create`):** Клиент на вашем сайте выбирает товар или тариф. Серверный обработчик отправляет `POST`-запрос к Monobank API с суммой, назначением платежа и обратными адресами. Банк возвращает уникальный `invoiceId` и ссылку `pageUrl`.
- **2. Редирект на платежную страницу:** Клиент перенаправляется на `pageUrl`, где оплачивает заказ через Apple Pay, Google Pay, приложение Monobank или ввод данных банковской карты.
- **3. Получение Webhook:** После завершения оплаты Monobank автоматически отправляет `POST`-запрос на ваш заранее настроенный `webHookUrl` со статусом транзакции и криптографической цифровой подписью.
- **4. Фиксация статуса (Terminal States):** Ваш сервер обязан реагировать на два финальных статуса: `success` (платеж успешен, открываем доступ или отправляем заказ) и `failure` (ошибка или отклонение платежа банком).

> [!IMPORTANT]
> Статус `expired` (истекло время действия ссылки) **не генерирует вебхук**. Если клиент закрыл платежную страницу и не оплатил, проверять состояние заказа необходимо через резервный опрос API (polling).

---

## 2. Юридические предпосылки и чек-лист комплаенса сайта для ФЛП

Даже если код оплаты написан безупречно, служба безопасности и финансового мониторинга Monobank не активирует боевой прием платежей, если сайт не соответствует требованиям законодательства Украины и правилам платежных систем Visa/Mastercard.

### 2.1. Банковские условия
- **Открытый счет ФЛП или юрлица в Monobank:** Эквайринг подключается исключительно к предпринимательским счетам. На личную черную или белую карту физлица принимать коммерческие платежи по закону запрещено.
- **Подходящие КВЭДы:** В регистрационных данных ФЛП должны быть указаны коды деятельности для интернет-торговли или услуг (например, `47.91` — Розничная торговля через интернет, `62.01`/`62.02` — Компьютерное программирование и консультации, `85.59` — Другие виды образования).

### 2.2. Обязательный чек-лист страниц перед подачей на модерацию

Перед тем как подать заявку на активацию интернет-терминала, убедитесь, что на вашем сайте есть следующие разделы (обычно размещаются в футере):

- [ ] **Публичная оферта (Договор публичной оферты):** Описание предмета договора, момента заключения сделки, прав и обязанностей сторон.
- [ ] **Политика конфиденциальности (Privacy Policy):** Четкое описание того, какие данные клиентов собираются и как они защищаются согласно закону «О защите персональных данных».
- [ ] **Условия возврата средств и доставки:** Порядок возврата товара или средств в течение 14 дней по Закону Украины «О защите прав потребителей», а для цифровых услуг/подписок — правила отказа от услуги.
- [ ] **Полные реквизиты предпринимателя в футере:** Наименование ФЛП или ООО, ИНН / ЕГРПОУ, юридический адрес, контактный номер телефона и рабочий e-mail службы поддержки.
- [ ] **Прозрачные цены и описание:** Каждая кнопка оплаты должна иметь фиксированную цену в гривнах (UAH) и понятное описание того, за что именно платит клиент.

> [!WARNING]
> Если на сайте нет оферты, реквизитов ФЛП или вместо цен стоят заглушки «по договоренности», служба безопасности Monobank отклонит регистрацию терминала на этапе проверки.

---

## 3. Создание веб-терминала в кабинете Monobank Бизнес

Для взаимодействия с платежным API вам необходим персональный ключ доступа — `X-Token`. Он генерируется бесплатно внутри личного кабинета предпринимателя.

### 3.1. Пошаговый алгоритм открытия кассы

1. **Авторизация:** Перейдите на [web.monobank.ua →](https://web.monobank.ua/) и войдите с помощью QR-кода через приложение Monobank на смартфоне.
2. **Переход к кассе:** В левом навигационном меню выберите раздел **«Касса»**.
3. **Добавление инструмента:** Нажмите кнопку **«+ Добавить инструмент»** и выберите вариант **«Оплаты на сайте (собственная разработка)»**.
4. **Регистрация терминала:** Введите название проекта (например, `Мой сайт` или `Основной терминал`) и подтвердите операцию кнопкой **«Подключить»**.
5. **Генерация API-токена:** Откройте созданный терминал, перейдите во вкладку **«Интеграции / API Ключи»**, нажмите **«Создать токен»** и скопируйте сгенерированный ключ.

> 📍 **Навигация в кабинете:** `web.monobank.ua` → `Касса` → `+ Добавить инструмент` → `Оплаты на сайте (собственная разработка)`

![Создание платежного инструмента в кабинете Monobank Kasa](/api/guides-media/automation/monobank-acquiring-vibecoding/images/01-monobank-kasa-add-terminal.webp)

> 📍 **Получение API-ключа:** `Касса` → `Ваш терминал` → `Интеграция` → `Создать X-Token`

![Модальное окно создания и копирования токена доступа X-Token](/api/guides-media/automation/monobank-acquiring-vibecoding/images/02-monobank-create-token.webp)

### 3.2. Железные правила безопасности токенов

- **Никакого хардкода в клиентском коде:** `X-Token` дает прямой доступ к управлению вашими финансами и возвратами. Его категорически запрещено хранить в открытом JavaScript/HTML или публиковать в открытых репозиториях GitHub.
- **Использование переменных окружения:** Храните токен исключительно в файле `.env` на сервере под именем `MONOBANK_TOKEN` или в разделе Secrets вашей хостинг-платформы (Vercel, Render, Railway, Replit, Lovable).
- **Тестовый токен для разработки:** Для начальных экспериментов банк предоставляет отдельный тестовый токен на странице [api.monobank.ua →](https://api.monobank.ua/), который позволяет симулировать транзакции без списания реальных средств.

---

## 4. Официальные AI-промпты Monobank для вайб-кодеров

Команда Monobank разработала набор официальных системных промптов для AI-агентов. Их главное преимущество — точное соответствие актуальным эндпоинтам банка.

![Страница официальной документации Monobank для AI-инструментов](/api/guides-media/automation/monobank-acquiring-vibecoding/images/03-monobank-ai-prompts-page.webp)

> [!WARNING]
> **Главная ловушка новичков — сумма в копейках:** Monobank API принимает все суммы исключительно в минимальных единицах валюты (копейках). $100\text{ грн} = 10\,000\text{ копеек}$. Если вы передадите `amount: 100`, клиент заплатит всего 1 гривну.

### 4.1. Базовый промпт создания платежа

Скопируйте этот промпт и отправьте его в чат вашего AI-ассистента:

```markdown
Я хочу добавить на свой сайт возможность принимать платежи через Monobank.

ЗАДАЧА:
Напиши полный готовый код для создания платежа и перенаправления пользователя на оплату.

ЧТО ДОЛЖНО ПРОИЗОЙТИ:
1. На моем сайте кнопка "Перейти к оплате".
2. Пользователь кликает → создается счет в Monobank.
3. Пользователь переходит на страницу оплаты Monobank.
4. После оплаты он возвращается на мой сайт (https://mysite.com/payment-result).
5. Я проверяю статус платежа и показываю результат.

ТЕСТОВЫЕ ДАННЫЕ:
- Сумма: 100 грн (10000 копеек)
- Описание платежа: "Оплата заказа"
- Куда возвращать пользователя: https://mysite.com/payment-result

МОЙ ТОКЕН MONOBANK:
Переменная окружения MONOBANK_TOKEN из файла .env

ДОКУМЕНТАЦИЯ:
- Создание платежа: https://monobank.ua/api-docs/acquiring/methods/ia/post--api--merchant--invoice--create
- Проверка статуса: https://monobank.ua/api-docs/acquiring/methods/ia/get--api--merchant--invoice--status

Напиши весь код для этого flow.
```

### 4.2. Промпт для настройки Webhook-обработчика

Без вебхука сервер не узнает о факте успешной оплаты, если клиент закроет браузер сразу после списания средств:

```markdown
Мне нужно, чтобы мой сервер автоматически узнавал, когда пользователь оплатил заказ.

ЗАДАЧА:
Настрой автоматическое получение информации о платежах от Monobank (webhook).

ЧТО ДОЛЖНО ПРОИЗОЙТИ:
1. Пользователь оплачивает на странице Monobank.
2. Monobank автоматически отправляет POST-запрос на мой сервер.
3. Мой сервер получает данные: invoiceId, статус (success/failure), сумму.
4. Сервер проверяет криптографическую подпись (ECDSA SHA-256 через заголовок x-sign).
5. Обновляет статус заказа в базе данных и логирует результат.

ВАЖНО:
- Обязательно валидируй заголовок x-sign с помощью публичного ключа банка.
- Обрабатывай сырое тело запроса (raw body Buffer / raw string), а не распарсенный JSON.
- Обрабатывай статусы: success, failure, processing, hold, expired.
- Обеспечь идемпотентность: если один и тот же вебхук придет дважды, не дублируй выдачу товара.

Напиши весь код для надежного webhook-обработчика.
```

---

## 5. Пакет скилов monobank-acquiring: прокачка агента

Если ограничиться только коротким промптом, AI-ассистент напишет базовый код (примерно 6.8 из 10): кнопка сработает, но код не будет содержать проверки криптографических подписей, защиты от подмены цены и обработки сетевых ошибок.

Чтобы получить продакшен-уровень (9.8–10 баллов), в корень проекта добавляется специализированный пакет скилов **`monobank-acquiring`**.

![Аудит платежной интеграции: сравнение безопасности до и после использования скила](/api/guides-media/automation/monobank-acquiring-vibecoding/images/05-codex-skills-audit.webp)

```bash
# Распаковка пакета скилов в проект
curl -L -o monobank-acquiring.zip https://gotburnout.io/downloads/monobank-acquiring.zip
unzip monobank-acquiring.zip -d monobank-acquiring/
```

### 5.1. Анатомия и структура пакета скилов

| Файл скила | Что содержит и за какие задачи отвечает |
| :--- | :--- |
| **`SKILL.md`** | **Центральный манифест:** базовый флоу, авторизация `X-Token`, типы данных и обработка ошибок `400`, `403`, `429`, `500`. |
| **`quickstart.md`** | **Быстрый старт:** пошаговый туториал создания инвойса и фолбек-поллинга с готовыми `curl`-командами. |
| **`invoice.md`** | **Жизненный цикл счетов:** эндпоинты создания инвойса, проверки статуса, отмены и инвалидации ссылок. |
| **`webhook.md`** | **Криптографическая защита:** математически точная верификация ECDSA SHA-256 подписи из заголовка `x-sign`. |
| **`payment.md`** | **Прямые платежи:** списание по сохраненному токену карты, синхронные транзакции и 3DS-проверка. |
| **`wallet.md`** | **Токенизация карт (Wallet):** безопасное сохранение платежного средства клиента в хранилище банка для оплат в один клик. |
| **`fiscal.md`** | **Чеки и пРО:** описание структуры товарной корзины `basketOrder`, расчет налогов, скидок и экспорт чеков в PDF. |
| **`statement.md`** | **Выписки и аналитика:** получение реестра успешных операций за выбранный период с расчетом банковских комиссий. |
| **`merchant.md`** | **Данные мерчанта:** получение публичного ключа банка, управление субмерчантами и кассирами. |
| **`examples/`** | **Готовые серверы:** рабочие примеры серверов на 6 языках (Node.js, Python, Go, PHP, C#, Java). |

---

## 6. Практическая реализация: динамический платежный флоу

В каждом проекте своя структура: цифровые консультации с фиксированными тарифами, интернет-магазин с динамической корзиной или простой лендинг с кнопкой подписки.

Главная ошибка новичков — жестко «зашивать» сумму (например, 1000 грн) прямо в клиентский код кнопки или в тело POST-запроса из браузера. Этот подход создает критическую уязвимость безопасности.

### 6.1. Принцип безопасности: динамическая цена вместо хардкода

- **Никогда не доверяйте сумме из браузера:** Если клиентский JavaScript отправляет на сервер `{ price: 1000 }`, злоумышленник через DevTools или Postman может подменить это значение на `{ price: 1 }` и приобрести товар за 1 гривну.
- **Сервер — единственный источник правды (Single Source of Truth):** Фронтенд передает на бэкенд только идентификатор товара (`productId`), выбранный тариф (`planId: "pro"`) или массив идентификаторов корзины (`items: [{ id: "book_1", qty: 2 }]`).
- **Автоматическая конвертация в копейки:** Сервер извлекает актуальную стоимость из конфигурационного файла или базы данных и самостоятельно умножает ее на 100:
  $$\text{amount} = \text{Math.round}(\text{realPrice} \times 100)$$

### 6.2. Универсальный AI-промпт для адаптации под любой проект

Скопируйте этот промпт и отправьте его в чат вашего AI-агента (Codex, Antigravity, Cursor или Claude Code). Агент сам просканирует файлы вашего сайта, найдет существующие кнопки и цены и построит надежную интеграцию:

```markdown
В корень нашего проекта добавлен официальный пакет знаний Monobank Acquiring в папку /monobank-acquiring.

ЗАДАЧА:
1. Проанализируй архитектуру нашего проекта: найди, где у нас описаны товары, услуги, тарифы или кнопки оформления заказа/оплаты.
2. Создай или интегрируй защищенный серверный эндпоинт создания платежа Monobank:
   - Клиент отправляет ТОЛЬКО идентификатор товара/тарифа (например, tariffId или productId), но НЕ сумму.
   - Сервер находит реальную актуальную цену из нашего конфига или базы данных и переводит ее в копейки (цена * 100).
   - Токен берется безопасно из process.env.MONOBANK_TOKEN.
   - Делается POST-запрос к https://api.monobank.ua/api/merchant/invoice/create.
   - Клиенту возвращается ссылка pageUrl для перехода на чекаут банка.
3. Обнови наши существующие кнопки оплаты на фронтенде:
   - Добавь статус загрузки (индикатор Loader и блокировку кнопки от повторного клика).
   - При успешном ответе выполняй плавный редирект пользователя на полученную ссылку Monobank.
   - Добавь обработку ошибок с понятными уведомлениями.
4. Создай страницу результата оплаты (/payment-result), которая проверяет статус заказа и информирует пользователя об успехе.
5. Напиши юнит-тесты для проверки динамического расчета сумм и создания инвойса.

Все технические требования, структуры запросов и коды валют бери из файлов в папке /monobank-acquiring (особенно SKILL.md и invoice.md).
```

![Работа AI-агента в IDE с анализом структуры и созданием динамического эндпоинта](/api/guides-media/automation/monobank-acquiring-vibecoding/images/04-codex-initial-prompt.webp)

### 6.3. Архитектурный шаблон бэкенда создания инвойса

:::tabs
=== Next.js App Router
```typescript
// app/api/checkout/create-invoice/route.ts
import { NextResponse } from "next/server";

const PRODUCTS_CATALOG: Record<string, { title: string; priceUah: number }> = {
  plan_starter: { title: "Тариф Starter", priceUah: 490 },
  plan_pro:     { title: "Тариф Pro",     priceUah: 990 },
  plan_vip:     { title: "Тариф VIP",     priceUah: 2490 },
};

export async function POST(req: Request) {
  try {
    const { productId } = await req.json();

    // 1. Валидация: цена формируется исключительно на бэкенде
    const product = PRODUCTS_CATALOG[productId];
    if (!product) {
      return NextResponse.json({ error: "Выбранный товар или тариф не найден" }, { status: 400 });
    }

    const amountInKopecks = Math.round(product.priceUah * 100);
    const orderReference = `order_${productId}_${Date.now()}`;
    const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://mysite.com";

    // 2. Запрос к Monobank API
    const response = await fetch("https://api.monobank.ua/api/merchant/invoice/create", {
      method: "POST",
      headers: {
        "X-Token": process.env.MONOBANK_TOKEN!,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        amount: amountInKopecks,
        ccy: 980, // Гривна (ISO 4217)
        merchantPaymInfo: {
          reference: orderReference,
          destination: `Оплата: ${product.title}`,
          comment: `Заказ ${orderReference}`,
        },
        redirectUrl: `${siteUrl}/payment-result?ref=${orderReference}`,
        webHookUrl: `${siteUrl}/api/payment/webhook`,
        validity: 3600, // Счет действует 1 час
      }),
    });

    const data = await response.json();
    if (!response.ok) {
      return NextResponse.json({ error: data.errText || "Ошибка банка при создании счета" }, { status: response.status });
    }

    return NextResponse.json({ checkoutUrl: data.pageUrl, invoiceId: data.invoiceId });
  } catch (error) {
    return NextResponse.json({ error: "Внутренняя ошибка инициализации платежа" }, { status: 500 });
  }
}
```
=== Express / Node.js
```typescript
// server.ts
import express from "express";

const app = express();
app.use(express.json());

const PRODUCTS_CATALOG: Record<string, { title: string; priceUah: number }> = {
  plan_starter: { title: "Тариф Starter", priceUah: 490 },
  plan_pro:     { title: "Тариф Pro",     priceUah: 990 },
  plan_vip:     { title: "Тариф VIP",     priceUah: 2490 },
};

app.post("/api/checkout/create-invoice", async (req, res) => {
  try {
    const { productId } = req.body;

    const product = PRODUCTS_CATALOG[productId];
    if (!product) {
      return res.status(400).json({ error: "Выбранный товар или тариф не найден" });
    }

    const amountInKopecks = Math.round(product.priceUah * 100);
    const orderReference = `order_${productId}_${Date.now()}`;
    const siteUrl = process.env.SITE_URL || "https://mysite.com";

    const response = await fetch("https://api.monobank.ua/api/merchant/invoice/create", {
      method: "POST",
      headers: {
        "X-Token": process.env.MONOBANK_TOKEN!,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        amount: amountInKopecks,
        ccy: 980,
        merchantPaymInfo: {
          reference: orderReference,
          destination: `Оплата: ${product.title}`,
          comment: `Заказ ${orderReference}`,
        },
        redirectUrl: `${siteUrl}/payment-result?ref=${orderReference}`,
        webHookUrl: `${siteUrl}/api/payment/webhook`,
        validity: 3600,
      }),
    });

    const data = await response.json();
    if (!response.ok) {
      return res.status(response.status).json({ error: data.errText || "Ошибка банка" });
    }

    res.json({ checkoutUrl: data.pageUrl, invoiceId: data.invoiceId });
  } catch (error) {
    res.status(500).json({ error: "Внутренняя ошибка инициализации платежа" });
  }
});
```
:::

---

## 7. Безопасная обработка вебхуков и криптографическая подпись ECDSA

Самая ответственная часть любой финансовой интеграции — верификация уведомлений об оплате. Злоумышленник может отправить поддельный HTTP-запрос на ваш адрес `/api/payment/webhook` с фальшивым сообщением об успехе.

Чтобы предотвратить это, Monobank подписывает каждый вебхук с помощью асимметричного алгоритма **ECDSA (кривая secp256r1 / SHA-256)** и передает сигнатуру в HTTP-заголовке `x-sign`.

### 7.1. Почему `JSON.stringify` ломает верификацию подписи

> [!CAUTION]
> **Критическая ловушка `rawBody`:** Для проверки подписи ECDSA требуется **строго оригинальный поток байтов**, который отправил сервер Monobank. Если вы попытаетесь распарсить JSON и снова вызвать `JSON.stringify(req.body)`, порядок ключей, пробелы или переносы строк изменятся. Это приведет к другому хешу SHA-256, и проверка подписи **гарантированно вернет ошибку**!

### 7.2. Реализация верификации вебхука

:::tabs
=== Next.js App Router
```typescript
// app/api/payment/webhook/route.ts
import { NextResponse } from "next/server";
import crypto from "crypto";

let cachedPubKey: string | null = null;

async function getMonobankPubKey(token: string): Promise<string> {
  if (cachedPubKey) return cachedPubKey;

  const res = await fetch("https://api.monobank.ua/api/merchant/pubkey", {
    headers: { "X-Token": token },
    next: { revalidate: 86400 }, // Кешируем публичный ключ на 24 часа
  });
  const data = await res.json();
  cachedPubKey = `-----BEGIN PUBLIC KEY-----\n${data.key}\n-----END PUBLIC KEY-----`;
  return cachedPubKey;
}

export async function POST(req: Request) {
  const signature = req.headers.get("x-sign");
  if (!signature) {
    return new NextResponse("Missing x-sign header", { status: 400 });
  }

  // 1. Получаем сырое неизмененное тело запроса в виде текста
  const rawBody = await req.text();

  try {
    const pubKey = await getMonobankPubKey(process.env.MONOBANK_TOKEN!);

    // 2. Верифицируем подпись ECDSA SHA-256
    const verifier = crypto.createVerify("SHA256");
    verifier.update(rawBody);
    const isValid = verifier.verify(pubKey, Buffer.from(signature, "base64"));

    if (!isValid) {
      console.error("Вебхук отклонен: недействительная подпись x-sign");
      return new NextResponse("Invalid signature", { status: 400 });
    }

    // 3. Парсим данные только после успешной проверки криптографии
    const payload = JSON.parse(rawBody);
    const { invoiceId, status, amount, reference } = payload;

    if (status === "success") {
      // Активируем заказ в базе данных с проверкой дубликатов (идемпотентность)
      console.log(`Заказ ${reference} (${invoiceId}) оплачен на сумму ${amount / 100} грн`);
    }

    return new NextResponse("OK", { status: 200 });
  } catch (error) {
    console.error("Ошибка обработки вебхука:", error);
    return new NextResponse("Internal verification error", { status: 500 });
  }
}
```
=== Express / Node.js
```typescript
// webhook.ts
import express from "express";
import crypto from "crypto";

const app = express();

// Сохраняем сырые байты для верификации криптографической подписи
app.use(express.json({
  verify: (req: any, _res, buf) => {
    req.rawBody = buf.toString("utf-8");
  }
}));

let cachedPubKey: string | null = null;

async function getMonobankPubKey(token: string): Promise<string> {
  if (cachedPubKey) return cachedPubKey;
  const res = await fetch("https://api.monobank.ua/api/merchant/pubkey", {
    headers: { "X-Token": token },
  });
  const data = await res.json();
  cachedPubKey = `-----BEGIN PUBLIC KEY-----\n${data.key}\n-----END PUBLIC KEY-----`;
  return cachedPubKey;
}

app.post("/api/payment/webhook", async (req: any, res) => {
  const signature = req.headers["x-sign"] as string;
  if (!signature) {
    return res.status(400).send("Missing x-sign header");
  }

  try {
    const pubKey = await getMonobankPubKey(process.env.MONOBANK_TOKEN!);
    const rawBody = req.rawBody;

    const verifier = crypto.createVerify("SHA256");
    verifier.update(rawBody);
    const isValid = verifier.verify(pubKey, Buffer.from(signature, "base64"));

    if (!isValid) {
      return res.status(400).send("Invalid signature");
    }

    const { invoiceId, status, amount, reference } = req.body;
    if (status === "success") {
      console.log(`Успешная оплата ${reference} (${invoiceId}) на сумму ${amount / 100} грн`);
    }

    res.sendStatus(200);
  } catch (err) {
    res.status(500).send("Internal verification error");
  }
});
```
:::

---

## 8. Локальное тестирование вебхуков (Localhost & Cloudflare Tunnels)

Частая проблема вайб-кодеров: при запуске сервера на `http://localhost:3000` банк не может доставить вебхук, так как локальная машина не имеет публичного IP-адреса в интернете.

Monobank отправляет вебхуки исключительно на публичные адреса с действующим протоколом `HTTPS`. Чтобы протестировать полный цикл на своем компьютере, пробросьте безопасный туннель.

### 8.1. Быстрый запуск туннеля (без регистрации и бесплатно)

:::tabs
=== Cloudflare (npx untun)
```bash
# Мгновенный HTTPS-туннель на 3000 порт без установки утилит
npx untun@latest tunnel --port 3000
```
=== Cloudflare CLI (cloudflared)
```bash
# Установка через brew на macOS
brew install cloudflared
cloudflared tunnel --url http://localhost:3000
```
=== ngrok
```bash
# Если используете ngrok
ngrok http 3000
```
:::

После запуска вы получите временный публичный HTTPS-адрес:
`https://your-tunnel-name.trycloudflare.com`

### 8.2. Настройка вебхука для локального теста
В коде создания инвойса во время разработки укажите полученный адрес:
```typescript
webHookUrl: "https://your-tunnel-name.trycloudflare.com/api/payment/webhook"
```
Теперь при тестовой оплате банк отправит реальный вебхук прямо на ваш локальный сервер в терминале, и вы сможете убедиться, что подпись `x-sign` успешно проходит проверку.

---

## 9. UX страницы возврата и решение состояния гонки (Race Condition)

Когда клиент оплачивает счет через приложение Monobank или Apple Pay, банк возвращает его на адрес `redirectUrl` (`/payment-result?ref=...`) **мгновенно**.

Однако сетевой вебхук от сервера банка к вашему серверу может задержаться на 1–2 секунды из-за сетевых маршрутов. Если страница результата сразу проверит статус в базе данных, она рискует показать: *«Заказ не оплачен»*, что вызовет панику у клиента (деньги с карты списаны, а сайт сообщает об отсутствии оплаты).

### 9.1. Инженерный паттерн решения гонки
1. **Начальное состояние загрузки:** Страница открывается с нейтральным статусом: `«Подтверждаем оплату в банке...»` и анимированным индикатором.
2. **Короткий поллинг (Short Polling):** Фронтенд делает до 5 быстрых запросов к собственному API каждые 1.5 секунды (`/api/orders/check-status?ref=...`), ожидая, пока вебхук изменит статус заказа в базе на `success`.
3. **Фолбек:** Если за 8 секунд вебхук не дошел, клиент видит сообщение: *«Платеж принят в обработку. Как только банк подтвердит транзакцию, доступ откроется автоматически»*.

### 9.2. Готовый React-компонент страницы результата

```tsx
// app/payment-result/page.tsx
"use client";

import { useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";

export default function PaymentResultPage() {
  const searchParams = useSearchParams();
  const router = useRouter();
  const ref = searchParams.get("ref");

  const [status, setStatus] = useState<"checking" | "success" | "pending" | "failed">("checking");

  useEffect(() => {
    if (!ref) {
      setStatus("failed");
      return;
    }

    let attempts = 0;
    const maxAttempts = 5;

    const interval = setInterval(async () => {
      attempts++;
      try {
        const res = await fetch(`/api/orders/status?ref=${encodeURIComponent(ref)}`);
        const data = await res.json();

        if (data.status === "success") {
          clearInterval(interval);
          setStatus("success");
        } else if (attempts >= maxAttempts) {
          clearInterval(interval);
          setStatus("pending");
        }
      } catch (err) {
        if (attempts >= maxAttempts) {
          clearInterval(interval);
          setStatus("pending");
        }
      }
    }, 1500);

    return () => clearInterval(interval);
  }, [ref]);

  return (
    <div className="max-w-md mx-auto my-16 p-8 rounded-2xl bg-neutral-900 border border-neutral-800 text-center text-white">
      {status === "checking" && (
        <div>
          <div className="w-12 h-12 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
          <h2 className="text-xl font-semibold mb-2">Подтверждаем оплату...</h2>
          <p className="text-sm text-neutral-400">Получаем статус транзакции от банка. Подождите несколько секунд.</p>
        </div>
      )}

      {status === "success" && (
        <div>
          <div className="w-12 h-12 bg-emerald-500/20 text-emerald-400 rounded-full flex items-center justify-center mx-auto mb-4 text-2xl font-bold">✓</div>
          <h2 className="text-xl font-semibold mb-2">Оплата успешна!</h2>
          <p className="text-sm text-neutral-400 mb-6">Ваш заказ #{ref} успешно подтвержден.</p>
          <button onClick={() => router.push("/dashboard")} className="px-6 py-2.5 rounded-xl bg-amber-500 hover:bg-amber-400 text-black font-semibold transition">
            Перейти в кабинет
          </button>
        </div>
      )}

      {status === "pending" && (
        <div>
          <div className="w-12 h-12 bg-amber-500/20 text-amber-400 rounded-full flex items-center justify-center mx-auto mb-4 text-2xl font-bold">⏳</div>
          <h2 className="text-xl font-semibold mb-2">Платеж в обработке</h2>
          <p className="text-sm text-neutral-400 mb-6">Средства зарезервированы. Подтверждение поступит в течение 1–2 минут.</p>
          <button onClick={() => router.push("/")} className="px-6 py-2.5 rounded-xl bg-neutral-800 hover:bg-neutral-700 text-white font-medium transition">
            На главную
          </button>
        </div>
      )}
    </div>
  );
}
```

---

## 10. Расширенные возможности: встроенное пРО, холдирование и Wallet

Monobank Acquiring предоставляет полный спектр инструментов для сложных бизнес-моделей:

![Финальная страница оплаты Monobank Hosted Checkout с Apple Pay и картами](/api/guides-media/automation/monobank-acquiring-vibecoding/images/06-monobank-checkout-page.webp)

### 10.1. Программное РО: бесплатный встроенный Checkbox в Кассе Monobank

Для большинства украинских ФЛП 2-й и 3-й групп обязательная фискализация онлайн-продаж является юридическим требованием.

Главное преимущество Monobank для предпринимателей — **бесплатная встроенная интеграция с сервисом пРО Checkbox**:
- **Включение в один клик:** В кабинете `web.monobank.ua` перейдите в настройки созданного терминала и активируйте переключатель **«Фискализация через Checkbox»**. Monobank сам бесплатно создает кассу и подписывает чеки вашим КЭП.
- **Автоматическая фискализация:** Если у вас стандартный каталог товаров с одинаковой ставкой, вам даже не нужно менять код создания платежа — чек автоматически формируется по полю `destination` в назначении платежа.
- **Расширенная фискализация через API:** Если вы продаете товары с разными ставками НДС или подакцизные позиции с кодами УКТ ВЭД, передавайте массив `basketOrder` внутри объекта `merchantPaymInfo`:

```json
"merchantPaymInfo": {
  "reference": "order_1001",
  "destination": "Оплата онлайн-курса",
  "customerEmails": ["client@example.com"],
  "basketOrder": [
    {
      "name": "Доступ к курсу по вайбкодингу",
      "qty": 1,
      "sum": 99000,
      "code": "SKU-COURSE-01",
      "unit": "шт.",
      "total": 99000
    }
  ]
}
```

### 10.2. Двухэтапная оплата (Hold)

Если вы продаете физические товары, которые могут отсутствовать на складе, используйте режим холдирования:

- **Блокировка:** Передайте `paymentType: "hold"` при создании инвойса. Средства замораживаются на карте покупателя на срок до 9 дней.
- **Списание (Finalize):** После проверки наличия товара вызовите `/api/merchant/invoice/finalize`. Можно списать как полную сумму, так и меньшую (например, если одной позиции не оказалось).
- **Отмена:** Если товара нет, вызовите `/api/merchant/invoice/cancel` — платеж отменяется без каких-либо банковских комиссий для покупателя.

### 10.3. Сохранение карт и подписки (Wallet)

Если вы запускаете SaaS-сервис с регулярной ежемесячной подпиской, передайте `saveCardData: true` при создании первого инвойса. После успешной оплаты в вебхуке вернется `walletId`. Используйте этот токен для последующих автоматических списаний без повторного ввода данных карты клиентом.

---

## 11. Матрица безопасности и автоматические тесты (Vitest / Jest)

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

Перед переходом в боевой режим запустите тестовую матрицу безопасности:

| Тест безопасности | Что именно проверяется | Ожидаемое поведение системы |
| :--- | :--- | :--- |
| **1. Защита от изменения цены (Price Tampering)** | Клиент отправляет `productId: "vip"`, но пытается подсунуть в запрос `amount: 100` (1 грн) | Сервер игнорирует поле `amount` от клиента, берет реальную цену из конфига (2490 грн = 249 000 коп). Если `productId` подделан — возвращает `HTTP 400`. |
| **2. Блокировка без подписи** | На эндпоинт `/api/payment/webhook` поступает POST-запрос без заголовка `x-sign` | Запрос немедленно блокируется с ответом `HTTP 400 Bad Request`. Никакой обработки заказа не происходит. |
| **3. Блокировка фальшивых подписей** | Злоумышленник отправил фейковую подпись в `x-sign` со статусом `status: "success"` | Проверка `crypto.verify(SHA256, ...)` возвращает `false`. Сервер возвращает `HTTP 400/401`, доступ или товар не выдаются. |
| **4. Идемпотентность вебхука (Duplicate Delivery)** | Monobank отправил одинаковый вебхук `success` дважды или трижды подряд из-за сетевого лага | Доступ или товар активируются **только один раз**. Повторный вебхук не вызывает дублирования выдачи, возвращая банку `HTTP 200 OK`. |
| **5. Устойчивость к состоянию гонки (Race Condition)** | Вебхук от банка пришел раньше, чем завершился начальный HTTP-запрос создания инвойса в базе | Обработчик вебхука устойчив к отсутствию записи в БД (использует `UPSERT` или создает заказ на лету). |
| **6. Соблюдение рейт-лимитов (Rate Limit Buffer)** | При сбое вебхука запускается поллинг статуса `/api/merchant/invoice/status` | Запросы выполняются с паузой **не менее 15 секунд**, что защищает терминал от блокировки `HTTP 429 Too Many Requests`. |

### 11.1. Готовый набор автоматических тестов для вашего проекта

Попросите своего AI-агента добавить следующий тестовый набор в проект (`monobank-acquiring.test.ts`):

```typescript
import { describe, it, expect } from "vitest";
import crypto from "crypto";

const { publicKey, privateKey } = crypto.generateKeyPairSync("ec", {
  namedCurve: "prime256v1",
  publicKeyEncoding: { type: "spki", format: "pem" },
  privateKeyEncoding: { type: "pkcs8", format: "pem" },
});

describe("Monobank Acquiring Security Tests", () => {
  it("Тест 1: Не разрешает клиенту манипулировать ценой", () => {
    const CATALOG: Record<string, { priceUah: number }> = { plan_pro: { priceUah: 990 } };
    const clientPayload = { productId: "plan_pro", amount: 100 }; // Попытка подсунуть 1 грн
    
    const safeAmount = Math.round(CATALOG[clientPayload.productId].priceUah * 100);
    expect(safeAmount).toBe(99000); // 990.00 грн, а не 1.00 грн
  });

  it("Тест 2: Отклоняет вебхук без обязательного заголовка x-sign", () => {
    const headers: Record<string, string> = {};
    const hasSignature = Boolean(headers["x-sign"]);
    expect(hasSignature).toBe(false);
  });

  it("Тест 3: Успешно верифицирует валидную цифровую подпись банка", () => {
    const rawPayload = JSON.stringify({ invoiceId: "inv_123", status: "success", amount: 99000 });
    
    const signer = crypto.createSign("SHA256");
    signer.update(rawPayload);
    const validSignatureBase64 = signer.sign(privateKey, "base64");

    const verifier = crypto.createVerify("SHA256");
    verifier.update(rawPayload);
    const isValid = verifier.verify(publicKey, Buffer.from(validSignatureBase64, "base64"));

    expect(isValid).toBe(true);
  });

  it("Тест 4: Блокирует поддельную подпись или модифицированное тело запроса", () => {
    const originalPayload = JSON.stringify({ invoiceId: "inv_123", status: "success", amount: 99000 });
    const tamperedPayload = JSON.stringify({ invoiceId: "inv_123", status: "success", amount: 1000 });

    const signer = crypto.createSign("SHA256");
    signer.update(originalPayload);
    const signature = signer.sign(privateKey, "base64");

    const verifier = crypto.createVerify("SHA256");
    verifier.update(tamperedPayload);
    const isValid = verifier.verify(publicKey, Buffer.from(signature, "base64"));

    expect(isValid).toBe(false);
  });

  it("Тест 5: Идемпотентность — повторный вебхук не выдает товар дважды", () => {
    const processedOrders = new Set<string>();
    
    function handleOrder(invoiceId: string): { processed: boolean } {
      if (processedOrders.has(invoiceId)) {
        return { processed: false }; // Дубль отклонен
      }
      processedOrders.add(invoiceId);
      return { processed: true };
    }

    expect(handleOrder("inv_001").processed).toBe(true);  // Первый вебхук
    expect(handleOrder("inv_001").processed).toBe(false); // Повторный приход дубликата
    expect(processedOrders.size).toBe(1);
  });
});
```

---

## 12. Финальный инженерный чек-лист перед запуском

Проверьте свой платежный модуль по этим 10 пунктам перед переключением на боевые платежи:

- [ ] **Комплаенс сайта:** В футере добавлены ссылки на Публичную оферту, Политику конфиденциальности, Условия возврата и полные реквизиты ФЛП с ИНН.
- [ ] **Суммы в копейках:** Все значения `amount` умножены на 100 ($1\text{ грн} = 100\text{ коп}$) через `Math.round`.
- [ ] **Безопасность ключа:** Токен вынесен в `.env` под именем `MONOBANK_TOKEN` и добавлен в `.gitignore`.
- [ ] **Цены из бэкенда (SSOT):** Клиент передает только идентификатор товара, сумма берется исключительно из базы или конфига.
- [ ] **Обработка rawBody:** Вебхук верифицирует оригинальный текстовый или бинарный буфер (`req.text()` или `req.rawBody`), избегая повторной сериализации через `JSON.stringify`.
- [ ] **Криптографическая защита:** Вебхук верифицирует подпись `x-sign` через публичный ключ банка алгоритмом `SHA256` ECDSA.
- [ ] **Публичный URL для вебхука:** Сервер доступен извне через действительный SSL-сертификат HTTPS (для локальных тестов подходит Cloudflare Tunnel или ngrok).
- [ ] **Идемпотентность:** Повторный приход одного и того же вебхука не вызывает повторную выдачу товара или подписки.
- [ ] **UX страницы результата:** На `/payment-result` настроен лоадер ожидания и короткий поллинг статуса, чтобы защитить пользователя от состояния гонки.
- [ ] **Реальный платеж на 1–5 грн:** Проведена успешная тестовая транзакция реальной картой, проверено списание средств и отображение в кабинете банка.

---

## Полезные ресурсы и файлы для скачивания

- [Скачать пакет скилов monobank-acquiring.zip (38 KB) →](/downloads/monobank-acquiring.zip)
- [Официальная страница AI Промптов Monobank →](https://monobank.ua/api-docs/acquiring/dev/ai-tools/docs--ai-prompts)
- [Личный кабинет Monobank Бизнес →](https://web.monobank.ua/)
- [Практическое видео на YouTube с полным разбором флоу →](https://youtu.be/GMh_fOCiQ4E)
- [Портал тестирования API Monobank →](https://api.monobank.ua/)