# How to Integrate Monobank Acquiring Without a Developer via Vibe-Coding

> A step-by-step guide to integrating Monobank online payments: FOP compliance, terminal setup, ECDSA webhook verification, robust UX, and security test matrix.

Vibe-coding has fundamentally transformed digital product development. Today, adding online card payments, Apple Pay, or Google Pay to your website no longer requires hiring a specialized backend engineer or deciphering complex cryptographic protocols. All you need is the ability to clearly communicate your business logic to an AI agent (Codex, Antigravity, Cursor, or Claude Code) and provide it with the right technical context.

This guide walks you through the complete lifecycle of integrating Monobank Acquiring into your project: from bank compliance requirements and merchant portal setup to generating a production-grade payment gateway with ECDSA SHA-256 webhook signature verification, frontend race condition handling, and automated security tests.

> [!TIP]
> **Video Walkthrough:** If you prefer visual learning, check out the [step-by-step video on YouTube →](https://youtu.be/GMh_fOCiQ4E), demonstrating the entire workflow on a live screen from the initial prompt to real payment settlement.

> [!NOTE]
> **Agent Skill Package:** For maximum code generation accuracy, download the official knowledge package for your AI assistant:  
> [Download full monobank-acquiring.zip archive (38 KB) →](/downloads/monobank-acquiring.zip)

---

## 1. Hosted Checkout Architecture and Payment Lifecycle

Monobank Acquiring operates under a **Hosted Checkout** model (payment processing takes place on the bank's secure page). This eliminates the need for expensive and stringent PCI DSS certification on your server, as customer payment details are entered directly on Monobank's encrypted domain.

The official Monobank documentation for AI tools is available at [monobank.ua/api-docs/acquiring/dev/ai-tools/docs--ai-prompts →](https://monobank.ua/api-docs/acquiring/dev/ai-tools/docs--ai-prompts). Keep this reference handy as the canonical source of bank specifications.

### 1.1. Core Payment Flow

```
[ Client on Website ]
       │
       ├─ 1. Clicks "Pay"
       ▼
[ Your Server ] ──────────────► [ Monobank API: /invoice/create ]
       ▲                              │
       │  receives pageUrl, invoiceId │
       └──────────────────────────────┘
       │
       ├─ 2. Redirects customer to pageUrl (Hosted Checkout)
       ▼
[ Monobank Checkout Page ] ───► Payment (Apple Pay / Google Pay / Card)
       │
       ├─ 3. Asynchronous POST webhook with x-sign header
       ▼
[ Your Webhook Endpoint ] ────► ECDSA SHA-256 Verification → Status "success"
       │
       ├─ 4. Customer returns to /payment-result
       ▼
[ Result Page ] ──────────────► Display order status / access delivery
```

- **1. Invoice Creation (`invoice/create`):** The customer selects a product or subscription tier on your site. Your server issues a `POST` request to Monobank API with the amount, payment destination, and return URLs. The bank returns a unique `invoiceId` and a hosted `pageUrl`.
- **2. Redirect to Payment Page:** The customer is redirected to `pageUrl`, completing the checkout via Apple Pay, Google Pay, Monobank mobile app, or manual card entry.
- **3. Webhook Delivery:** Once processed, Monobank sends a `POST` request to your pre-configured `webHookUrl` containing the transaction status and a cryptographic signature.
- **4. Terminal Status Settlement:** Your server must handle two terminal states: `success` (payment confirmed, grant access or fulfill order) and `failure` (payment declined or aborted).

> [!IMPORTANT]
> The `expired` status (checkout link timeout) **does not trigger a webhook**. If a customer closes the payment page without paying, track invoice abandonment through fallback polling.

---

## 2. Legal Requirements and Website Compliance Checklist (FOP / LLC)

Even with flawless code, Monobank's security and financial monitoring departments will decline production terminal activation if your website lacks mandatory legal disclosures required by Ukrainian law and Visa/Mastercard rules.

### 2.1. Banking Prerequisites
- **Active Business Account in Monobank (FOP or Legal Entity):** Acquiring cannot be linked to personal consumer cards. You must have an active business account (ФОП or ТОВ) in Monobank.
- **Valid Economic Activity Codes (KVED):** Your registered business activities must include internet commerce or relevant service codes (e.g., `47.91` — Retail sale via mail order or internet, `62.01`/`62.02` — Computer programming and consultancy, `85.59` — Other education).

### 2.2. Pre-Moderation Website Checklist

Ensure your website includes the following documents (typically linked in the global footer) before submitting your terminal for review:

- [ ] **Public Offer Agreement (Terms of Service):** Clear description of services or products, contract inception point, user rights, and obligations.
- [ ] **Privacy Policy:** Explicit statements on what user data is collected and how it is secured pursuant to personal data protection laws.
- [ ] **Refund and Delivery Policy:** Clear return and refund terms (14-day statutory return policy under the Consumer Rights Protection Law), or digital service cancellation terms.
- [ ] **Full Merchant Credentials in Footer:** Official entity name (ФОП / ТОВ), Tax ID / USREOU (ІПН / ЄДРПОУ), registered address, customer support phone number, and official e-mail.
- [ ] **Transparent Pricing:** Every payment button must display fixed prices in UAH with an unambiguous description of what the customer is buying.

> [!WARNING]
> If your site lacks an offer agreement, business credentials, or displays placeholder prices like "contact for quote," Monobank compliance will reject your acquiring application.

---

## 3. Creating a Web Terminal in Monobank Business Cabinet

To interact with the API, you need a merchant authentication key — `X-Token`. It is generated free of charge inside the web portal.

### 3.1. Step-by-Step Terminal Setup

1. **Authorization:** Open [web.monobank.ua →](https://web.monobank.ua/) and log in via QR code using your Monobank mobile app.
2. **Open Kasa:** In the left sidebar navigation, select **"Каса" (Cash Desk)**.
3. **Add Tool:** Click **"+ Додати інструмент" (+ Add Tool)** and select **"Оплати на сайті (власна розробка)" (Website Payments - Custom Integration)**.
4. **Register Terminal:** Enter a descriptive project name (e.g., `My Website` or `Production Gateway`) and confirm by clicking **"Підключити" (Connect)**.
5. **Generate API Token:** Open your newly created terminal, switch to the **"Інтеграції / API Ключі" (Integrations)** tab, click **"Створити токен" (Create Token)**, and copy the secret key.

> 📍 **Portal Navigation:** `web.monobank.ua` → `Каса` → `+ Додати інструмент` → `Оплати на сайті (власна розробка)`

![Creating a payment tool in Monobank Kasa](/api/guides-media/automation/monobank-acquiring-vibecoding/images/01-monobank-kasa-add-terminal.webp)

> 📍 **Obtaining API Key:** `Каса` → `Your Terminal` → `Інтеграція` → `Створити X-Token`

![Modal for creating and copying X-Token](/api/guides-media/automation/monobank-acquiring-vibecoding/images/02-monobank-create-token.webp)

### 3.2. Token Security Best Practices

- **Never Hardcode in Client-Side Code:** `X-Token` grants direct administrative control over funds and refunds. Never expose it in client HTML/JS or commit it to public GitHub repositories.
- **Environment Variables Only:** Store your token exclusively on your server in `.env` as `MONOBANK_TOKEN` or in the Secrets manager of your deployment platform (Vercel, Render, Railway, Replit, Lovable).
- **Test Token for Staging:** For development and sandbox testing, Monobank provides a dedicated mock token at [api.monobank.ua →](https://api.monobank.ua/), allowing simulated transactions without moving real funds.

---

## 4. Official Monobank AI Prompts for Vibe-Coders

The Monobank team has published official system prompts for AI code generation agents, calibrated to the bank's active API endpoints.

![Official Monobank AI Tools and Prompts documentation](/api/guides-media/automation/monobank-acquiring-vibecoding/images/03-monobank-ai-prompts-page.webp)

> [!WARNING]
> **Common Beginner Trap — Currency in Minor Units (Kopecks):** Monobank API expects all monetary values in minor currency units (kopecks). $100\text{ UAH} = 10\,000\text{ kopecks}$. If you pass `amount: 100`, the customer will be billed only 1 UAH.

### 4.1. Base Payment Creation Prompt

Copy and paste this prompt into your AI assistant:

```markdown
I want to add Monobank payment acceptance to my website.

TASK:
Write complete, ready-to-run code to create a payment and redirect the customer to checkout.

REQUIRED FLOW:
1. Customer clicks "Proceed to Checkout" on my site.
2. Clicking sends a request → payment invoice is created in Monobank.
3. Customer is redirected to Monobank hosted checkout.
4. After payment, customer returns to https://mysite.com/payment-result.
5. My application checks order status and shows the result.

TEST DATA:
- Amount: 100 UAH (10000 kopecks)
- Description: "Order payment"
- Return URL: https://mysite.com/payment-result

MONOBANK TOKEN:
Environment variable MONOBANK_TOKEN from .env

DOCUMENTATION:
- Invoice Create: https://monobank.ua/api-docs/acquiring/methods/ia/post--api--merchant--invoice--create
- Invoice Status: https://monobank.ua/api-docs/acquiring/methods/ia/get--api--merchant--invoice--status

Generate full production-ready code for this flow.
```

### 4.2. Webhook Handler Setup Prompt

Without a webhook, your server will fail to confirm payments when users close the browser immediately after checkout:

```markdown
I need my backend to automatically receive payment confirmations from Monobank via webhook.

TASK:
Implement an automated webhook handler for Monobank acquiring.

REQUIRED FLOW:
1. Customer pays on the Monobank checkout page.
2. Monobank sends an asynchronous POST request to my server.
3. My server receives invoiceId, status (success/failure), and amount.
4. Server cryptographically verifies the ECDSA SHA-256 signature from header x-sign.
5. Updates order status in the database and logs the result.

CRITICAL REQUIREMENTS:
- Read raw request body (raw Buffer / unparsed string) to verify the x-sign header.
- Handle statuses: success, failure, processing, hold, expired.
- Guarantee idempotency: if the same webhook arrives multiple times, do not duplicate order fulfillment.

Generate clean, robust webhook handler code.
```

---

## 5. The monobank-acquiring Skill Package: Upgrading Your Agent

Relying solely on brief prompts yields basic code (approx. 6.8 out of 10): the button works, but lacks cryptographic signature validation, price tampering safeguards, and network resilience.

To attain production-grade quality (9.8–10 points), inject the specialized **`monobank-acquiring`** skill package into your project root.

![Payment integration audit comparison before and after using skill](/api/guides-media/automation/monobank-acquiring-vibecoding/images/05-codex-skills-audit.webp)

```bash
# Unpack the skill package into your workspace
curl -L -o monobank-acquiring.zip https://gotburnout.io/downloads/monobank-acquiring.zip
unzip monobank-acquiring.zip -d monobank-acquiring/
```

### 5.1. Anatomy of the Skill Package

| Skill File | Contents and Operational Responsibility |
| :--- | :--- |
| **`SKILL.md`** | **Master Manifest:** Baseline flow, `X-Token` auth, data schemas, and error codes `400`, `403`, `429`, `500`. |
| **`quickstart.md`** | **Quickstart Guide:** Step-by-step invoice creation and fallback polling walkthrough with `curl` snippets. |
| **`invoice.md`** | **Invoice Lifecycle:** Endpoints for creating, checking status, cancelling, and invalidating links. |
| **`webhook.md`** | **Cryptographic Security:** Exact mathematical ECDSA SHA-256 signature verification for `x-sign`. |
| **`payment.md`** | **Direct Payments:** Token-based recurring charges, synchronous payments, and 3DS verification. |
| **`wallet.md`** | **Card Tokenization (Wallet):** Secure storage of payment methods in the bank's vault for 1-click checkout. |
| **`fiscal.md`** | **Receipts & pRRO:** `basketOrder` structure, tax calculation, discounts, and PDF receipt downloads. |
| **`statement.md`** | **Statements & Analytics:** Transaction registry query over date ranges with fee accounting. |
| **`merchant.md`** | **Merchant Data:** Public key retrieval, submerchant setup, and cashier control. |
| **`examples/`** | **Ready Servers:** Working reference servers in 6 languages (Node.js, Python, Go, PHP, C#, Java). |

---

## 6. Practical Implementation: Dynamic Pricing Architecture

Every project is unique: digital consultancies with fixed tiers, e-commerce stores with dynamic shopping carts, or simple donation buttons.

A frequent beginner pitfall is hardcoding payment amounts (e.g. 1000 UAH) directly in client-side code or sending the price in a client POST payload. This creates a severe security vulnerability.

### 6.1. Security Principle: Dynamic Backend Pricing

- **Never Trust Client-Supplied Amounts:** If client-side JavaScript sends `{ price: 1000 }`, an attacker can modify DevTools or Postman payloads to `{ price: 1 }`, purchasing premium items for 1 UAH.
- **Server as the Single Source of Truth (SSOT):** The frontend sends only a product ID (`productId`), tier identifier (`planId: "pro"`), or cart reference (`items: [{ id: "book_1", qty: 2 }]`).
- **Automatic Conversion to Minor Units:** The server resolves the authoritative price from a database or configuration and multiplies it by 100:
  $$\text{amount} = \text{Math.round}(\text{realPrice} \times 100)$$

### 6.2. Universal AI Prompt for Any Codebase

Provide this prompt to your AI assistant (Codex, Antigravity, Cursor, or Claude Code) to inspect your codebase and wire up the payment flow safely:

```markdown
The official Monobank Acquiring skill package has been added to /monobank-acquiring.

TASK:
1. Analyze our codebase: locate where products, subscription plans, pricing, or checkout buttons are defined.
2. Build or update a secure backend endpoint to create Monobank invoices:
   - Client sends ONLY product/tier identifier (e.g. tariffId or productId), NEVER the monetary amount.
   - Server resolves authoritative price from database/config and converts to kopecks (price * 100).
   - Reads token securely from process.env.MONOBANK_TOKEN.
   - Makes POST request to https://api.monobank.ua/api/merchant/invoice/create.
   - Returns pageUrl to client for hosted checkout redirect.
3. Update frontend checkout buttons:
   - Add loading state with button lock against duplicate clicks.
   - Smoothly redirect to the returned Monobank pageUrl.
   - Add error handling with informative notifications.
4. Implement a payment result page (/payment-result) confirming order settlement.
5. Write unit tests verifying dynamic price calculation and invoice generation.

Refer to /monobank-acquiring files (specifically SKILL.md and invoice.md) for schemas.
```

![AI agent analyzing project structure and generating dynamic backend endpoint](/api/guides-media/automation/monobank-acquiring-vibecoding/images/04-codex-initial-prompt.webp)

### 6.3. Backend Invoice Creation Implementations

:::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 Plan", priceUah: 490 },
  plan_pro:     { title: "Pro Plan",     priceUah: 990 },
  plan_vip:     { title: "VIP Plan",     priceUah: 2490 },
};

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

    // 1. Validation: Authoritative price resolved on the server
    const product = PRODUCTS_CATALOG[productId];
    if (!product) {
      return NextResponse.json({ error: "Selected plan or item not found" }, { 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. Request to 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, // UAH (ISO 4217)
        merchantPaymInfo: {
          reference: orderReference,
          destination: `Payment for: ${product.title}`,
          comment: `Order ${orderReference}`,
        },
        redirectUrl: `${siteUrl}/payment-result?ref=${orderReference}`,
        webHookUrl: `${siteUrl}/api/payment/webhook`,
        validity: 3600, // 1 hour validity
      }),
    });

    const data = await response.json();
    if (!response.ok) {
      return NextResponse.json({ error: data.errText || "Bank invoice creation failed" }, { status: response.status });
    }

    return NextResponse.json({ checkoutUrl: data.pageUrl, invoiceId: data.invoiceId });
  } catch (error) {
    return NextResponse.json({ error: "Internal payment initialization 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 Plan", priceUah: 490 },
  plan_pro:     { title: "Pro Plan",     priceUah: 990 },
  plan_vip:     { title: "VIP Plan",     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: "Selected plan or item not found" });
    }

    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: `Payment for: ${product.title}`,
          comment: `Order ${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 || "Bank error" });
    }

    res.json({ checkoutUrl: data.pageUrl, invoiceId: data.invoiceId });
  } catch (error) {
    res.status(500).json({ error: "Internal payment initialization error" });
  }
});
```
:::

---

## 7. Secure Webhook Handling and ECDSA Cryptographic Signatures

The webhook handler is the most critical component of any financial integration. An attacker could forge a plain HTTP request to `/api/payment/webhook` with a fake success payload.

Monobank secures webhooks by signing each payload using **ECDSA (secp256r1 curve / SHA-256)**, passed in the HTTP header `x-sign`.

### 7.1. Why `JSON.stringify` Breaks Signature Verification

> [!CAUTION]
> **Critical `rawBody` Caveat:** ECDSA verification requires the **exact, unmutated byte stream** emitted by Monobank's servers. If you parse JSON and call `JSON.stringify(req.body)`, key ordering, whitespace, or line breaks change. This alters the SHA-256 hash, causing verification to **fail every time**!

### 7.2. Production Webhook Verification Implementations

:::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 }, // Cache public key for 24 hours
  });
  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. Obtain original unparsed payload as text
  const rawBody = await req.text();

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

    // 2. Verify ECDSA SHA-256 signature
    const verifier = crypto.createVerify("SHA256");
    verifier.update(rawBody);
    const isValid = verifier.verify(pubKey, Buffer.from(signature, "base64"));

    if (!isValid) {
      console.error("Webhook rejected: Invalid signature");
      return new NextResponse("Invalid signature", { status: 400 });
    }

    // 3. Parse JSON only after successful cryptographic verification
    const payload = JSON.parse(rawBody);
    const { invoiceId, status, amount, reference } = payload;

    if (status === "success") {
      // Fulfill order with idempotency check against duplicates
      console.log(`Order ${reference} (${invoiceId}) confirmed: ${amount / 100} UAH`);
    }

    return new NextResponse("OK", { status: 200 });
  } catch (error) {
    console.error("Webhook 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();

// Retain raw byte buffer for cryptographic verification
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(`Confirmed ${reference} (${invoiceId}): ${amount / 100} UAH`);
    }

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

---

## 8. Localhost Webhook Testing (Cloudflare Tunnels & ngrok)

When running your app on `http://localhost:3000`, Monobank cannot deliver webhooks because your machine lacks a public IP.

Monobank requires public endpoints with valid `HTTPS`. Expose your local port via a secure tunnel during development.

### 8.1. Instant Tunnel Setup (No Install Required)

:::tabs
=== Cloudflare (npx untun)
```bash
# Instant public HTTPS tunnel to port 3000 without installing utilities
npx untun@latest tunnel --port 3000
```
=== Cloudflare CLI (cloudflared)
```bash
# Install via brew on macOS
brew install cloudflared
cloudflared tunnel --url http://localhost:3000
```
=== ngrok
```bash
# Using ngrok
ngrok http 3000
```
:::

This produces an ephemeral public URL like:
`https://your-tunnel-name.trycloudflare.com`

### 8.2. Configuring Local Webhooks
Pass your tunnel URL when generating invoices in development:
```typescript
webHookUrl: "https://your-tunnel-name.trycloudflare.com/api/payment/webhook"
```
Test payments will now hit your terminal locally, allowing you to debug `x-sign` validation in real time.

---

## 9. Return Page UX and Race Condition Resolution

When a user completes payment via Apple Pay or the Monobank app, the browser returns to `redirectUrl` (`/payment-result?ref=...`) **instantly**.

However, the bank's background webhook may experience a 1–2 second network delay. If the return page immediately queries your database, it may incorrectly display: *"Order not paid,"* creating user confusion.

### 9.1. Engineering Pattern for Race Conditions
1. **Initial Pending State:** Open the page with a neutral status: `"Verifying payment with bank..."` and an active spinner.
2. **Short Polling:** Issue up to 5 quick queries every 1.5 seconds (`/api/orders/check-status?ref=...`), waiting for the webhook to flag the order as `success`.
3. **Graceful Fallback:** If unconfirmed after 8 seconds, display: *"Payment received and processing. Access will unlock automatically within 1–2 minutes."*

### 9.2. Production React Result Component

```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">Verifying Payment...</h2>
          <p className="text-sm text-neutral-400">Waiting for confirmation from Monobank. Please hold on.</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">Payment Successful!</h2>
          <p className="text-sm text-neutral-400 mb-6">Order #{ref} has been confirmed.</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">
            Go to Dashboard
          </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">Payment Processing</h2>
          <p className="text-sm text-neutral-400 mb-6">Funds have been reserved. Confirmation usually takes under 2 minutes.</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">
            Return Home
          </button>
        </div>
      )}
    </div>
  );
}
```

---

## 10. Advanced Features: Embedded pRRO, Hold, and Wallet

Monobank Acquiring natively supports advanced e-commerce mechanics:

![Monobank Hosted Checkout page with Apple Pay and card support](/api/guides-media/automation/monobank-acquiring-vibecoding/images/06-monobank-checkout-page.webp)

### 10.1. Software Fiscalization: Free Embedded Checkbox in Monobank Kasa

Online fiscalization (pRRO) is mandatory for Ukrainian FOP entities in groups 2 and 3.

Monobank provides a **free built-in Checkbox integration**:
- **One-Click Activation:** Inside `web.monobank.ua`, toggle **"Фіскалізація через Checkbox" (Fiscalization via Checkbox)** on your terminal. Monobank auto-provisions the cash register and signs receipts with your electronic key.
- **Zero Code Required for Basic Items:** For standard products with uniform tax rates, receipts are generated automatically from the invoice `destination` field.
- **Custom Basket via API:** For multi-tier tax rates or items requiring customs codes (УКТ ЗЕД), pass `basketOrder` in `merchantPaymInfo`:

```json
"merchantPaymInfo": {
  "reference": "order_1001",
  "destination": "Online Course Purchase",
  "customerEmails": ["client@example.com"],
  "basketOrder": [
    {
      "name": "Vibe-Coding Mastery Course",
      "qty": 1,
      "sum": 99000,
      "code": "SKU-COURSE-01",
      "unit": "pcs",
      "total": 99000
    }
  ]
}
```

### 10.2. Two-Stage Pre-Authorization (Hold)

For physical goods subject to warehouse availability checks:

- **Reservation:** Set `paymentType: "hold"` during invoice creation. Funds are held on the customer's card for up to 9 days.
- **Settlement (Finalize):** Call `/api/merchant/invoice/finalize` to capture the full or reduced amount.
- **Cancellation:** If out of stock, call `/api/merchant/invoice/cancel` to release the hold without merchant fees.

### 10.3. Card Tokenization and Subscriptions (Wallet)

For recurring SaaS memberships, pass `saveCardData: true` during initial checkout. After payment, Monobank returns a `walletId` via webhook, enabling subsequent one-click or automated recurring charges.

---

## 11. Security Test Matrix and Automated Tests (Vitest / Jest)

Payments represent maximum operational liability. Test against this matrix before deploying:

| Security Test | Vector Under Test | Expected System Behavior |
| :--- | :--- | :--- |
| **1. Price Tampering Prevention** | Client sends `productId: "vip"`, but injects `amount: 100` (1 UAH) | Server discards client amount, resolving true price from config (2490 UAH = 249,000 kopecks). Returns `HTTP 400` if ID invalid. |
| **2. Unsigned Webhook Block** | POST request hits `/api/payment/webhook` without `x-sign` header | Blocked immediately with `HTTP 400 Bad Request`. No state change in DB. |
| **3. Forged Signature Rejection** | Attacker delivers forged signature with `status: "success"` | `crypto.verify(SHA256, ...)` fails. Server returns `HTTP 400/401`. Order remains unpaid. |
| **4. Webhook Idempotency** | Monobank delivers duplicate `success` webhooks due to network lag | Access granted **only once**. Repeat webhooks return `HTTP 200 OK` without duplicate fulfillment. |
| **5. Race Condition Resilience** | Webhook arrives before invoice creation finishes writing to DB | Handler uses `UPSERT` or self-heals without throwing 500 errors. |
| **6. Rate Limit Protection** | Fallback polling `/api/merchant/invoice/status` during webhook downtime | Polls spaced at **>= 15 seconds** to prevent `HTTP 429 Too Many Requests`. |

### 11.1. Automated Test Suite (`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("Test 1: Prevents client price tampering", () => {
    const CATALOG: Record<string, { priceUah: number }> = { plan_pro: { priceUah: 990 } };
    const clientPayload = { productId: "plan_pro", amount: 100 }; // Attacker injects 1 UAH
    
    const safeAmount = Math.round(CATALOG[clientPayload.productId].priceUah * 100);
    expect(safeAmount).toBe(99000); // 990.00 UAH enforced
  });

  it("Test 2: Rejects webhook missing x-sign header", () => {
    const headers: Record<string, string> = {};
    const hasSignature = Boolean(headers["x-sign"]);
    expect(hasSignature).toBe(false);
  });

  it("Test 3: Successfully verifies legitimate bank signature", () => {
    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("Test 4: Rejects tampered payload or forged signature", () => {
    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("Test 5: Idempotency prevents double fulfillment", () => {
    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);  // Initial delivery
    expect(handleOrder("inv_001").processed).toBe(false); // Duplicate ignored
    expect(processedOrders.size).toBe(1);
  });
});
```

---

## 12. Final Pre-Launch Engineering Checklist

Verify your payment module against these 10 items before switching to live payments:

- [ ] **Site Compliance:** Footer contains links to Terms of Service, Privacy Policy, Refund Policy, and legal business credentials with Tax ID.
- [ ] **Amounts in Kopecks:** All `amount` fields multiplied by 100 ($1\text{ UAH} = 100\text{ kopecks}$) using `Math.round`.
- [ ] **Secret Token Isolation:** API key placed in `.env` as `MONOBANK_TOKEN` and verified in `.gitignore`.
- [ ] **Backend Pricing (SSOT):** Client sends only product identifiers; amounts are computed strictly on the backend.
- [ ] **Raw Body Parsing:** Webhook verifies unmutated raw text/buffer (`req.text()` or `req.rawBody`), avoiding `JSON.stringify` re-serialization.
- [ ] **Cryptographic Verification:** Signature validated against bank public key via ECDSA SHA-256.
- [ ] **Public HTTPS URL:** Webhook endpoint is publicly reachable over valid HTTPS (tested via Cloudflare Tunnel or ngrok).
- [ ] **Idempotent Storage:** Duplicate webhooks do not double-fulfill purchases or issue extra credits.
- [ ] **Race Condition Handling:** `/payment-result` implements a loading state with short polling.
- [ ] **Real 1 UAH Test Payment:** Conducted a successful live test with a real card to confirm bank settlement.

---

## Resources and Downloads

- [Download Agent Skill Package: monobank-acquiring.zip (38 KB) →](/downloads/monobank-acquiring.zip)
- [Official Monobank AI Prompts Documentation →](https://monobank.ua/api-docs/acquiring/dev/ai-tools/docs--ai-prompts)
- [Monobank Business Merchant Portal →](https://web.monobank.ua/)
- [Video Walkthrough on YouTube →](https://youtu.be/GMh_fOCiQ4E)
- [Monobank API Testing Sandbox →](https://api.monobank.ua/)