Skip to main content
Guide contents

Guide contents

Time to study: 20 min
#monobank#acquiring#vibecoding#ai_tools#payments
NEWBeginner20 min

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.

Published:
FOR AI AGENTChatGPTClaude

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 →, 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) →


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 →. Keep this reference handy as the canonical source of bank specifications.

1.1. Core Payment Flow

terminal
[ 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.


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 → 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 ЗбільшитиCreating a payment tool in Monobank KasaCreating a payment tool in Monobank Kasa

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

Modal for creating and copying X-Token ЗбільшитиModal for creating and copying X-TokenModal for creating and copying X-Token

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 →, 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 ЗбільшитиOfficial Monobank AI Tools and Prompts documentationOfficial Monobank AI Tools and Prompts documentation
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 ЗбільшитиPayment integration audit comparison before and after using skillPayment integration audit comparison before and after using skill
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 FileContents and Operational Responsibility
SKILL.mdMaster Manifest: Baseline flow, X-Token auth, data schemas, and error codes 400, 403, 429, 500.
quickstart.mdQuickstart Guide: Step-by-step invoice creation and fallback polling walkthrough with curl snippets.
invoice.mdInvoice Lifecycle: Endpoints for creating, checking status, cancelling, and invalidating links.
webhook.mdCryptographic Security: Exact mathematical ECDSA SHA-256 signature verification for x-sign.
payment.mdDirect Payments: Token-based recurring charges, synchronous payments, and 3DS verification.
wallet.mdCard Tokenization (Wallet): Secure storage of payment methods in the bank's vault for 1-click checkout.
fiscal.mdReceipts & pRRO: basketOrder structure, tax calculation, discounts, and PDF receipt downloads.
statement.mdStatements & Analytics: Transaction registry query over date ranges with fee accounting.
merchant.mdMerchant 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 ЗбільшитиAI agent analyzing project structure and generating dynamic backend endpointAI agent analyzing project structure and generating dynamic backend endpoint

6.3. Backend Invoice Creation Implementations

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 }); } }

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

Warning

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

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 }); } }

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)

bash
# Instant public HTTPS tunnel to port 3000 without installing utilities npx untun@latest tunnel --port 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 ЗбільшитиMonobank Hosted Checkout page with Apple Pay and card supportMonobank Hosted Checkout page with Apple Pay and card support

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 TestVector Under TestExpected System Behavior
1. Price Tampering PreventionClient 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 BlockPOST request hits /api/payment/webhook without x-sign headerBlocked immediately with HTTP 400 Bad Request. No state change in DB.
3. Forged Signature RejectionAttacker delivers forged signature with status: "success"crypto.verify(SHA256, ...) fails. Server returns HTTP 400/401. Order remains unpaid.
4. Webhook IdempotencyMonobank delivers duplicate success webhooks due to network lagAccess granted only once. Repeat webhooks return HTTP 200 OK without duplicate fulfillment.
5. Race Condition ResilienceWebhook arrives before invoice creation finishes writing to DBHandler uses UPSERT or self-heals without throwing 500 errors.
6. Rate Limit ProtectionFallback polling /api/merchant/invoice/status during webhook downtimePolls 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

This guide is completely free. If it saved you an evening, you can support the project's growth.
Support the author