# Як підключити еквайринг 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)$$
  Якщо ви зміните ціну в базі або оголосите знижку, рахунок Monobank згенерується з новою правильною сумою без втручання в платіжний шлюз.

### 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. Реалізація верифікації вебхука

Нижче наведено правильну реалізацію отримання сирого тіла та верифікації для Next.js App Router та Express:

:::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. Швидкий запуск тунелю (без реєстрації та безкоштовно)

Скористайтеся Cloudflare Tunnel (`cloudflared`) або `untun`:

:::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(data.status === "processing" ? "pending" : "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";

// Генерація тестової пари ECDSA secp256r1 для симуляції підписів Monobank
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/)