# Cómo integrar la pasarela de pagos Monobank sin programador mediante vibe-coding

> Guía paso a paso para integrar pagos online de Monobank en su web: cumplimiento legal para autónomos, creación del terminal, verificación de webhooks ECDSA, UX optimizada y pruebas de seguridad.

El vibe-coding ha transformado radicalmente el desarrollo de productos digitales. Hoy en día, añadir pagos con tarjeta, Apple Pay o Google Pay a su sitio web ya no exige contratar a un desarrollador backend especializado ni descifrar complejos protocolos criptográficos. Solo necesita articular claramente la lógica de negocio a su agente de IA (Codex, Antigravity, Cursor o Claude Code) y proporcionarle el contexto técnico adecuado.

Esta guía detalla el ciclo completo de integración de la pasarela de pagos Monobank Acquiring en su proyecto: desde los requisitos de cumplimiento bancario y la configuración del portal para empresas, hasta la generación de una pasarela protegida con verificación criptográfica de firmas ECDSA en webhooks, gestión de condiciones de carrera en frontend y pruebas automatizadas de seguridad.

> [!TIP]
> **Versión en vídeo:** Si prefiere el aprendizaje visual, consulte el [vídeo práctico paso a paso en YouTube →](https://youtu.be/GMh_fOCiQ4E), donde se muestra todo el proceso en pantalla desde la primera instrucción hasta el cobro real de fondos.

> [!NOTE]
> **Paquete de habilidades para agentes:** Para maximizar la precisión en la generación de código, descargue el paquete oficial de conocimientos para su asistente de IA:  
> [Descargar archivo completo monobank-acquiring.zip (38 KB) →](/downloads/monobank-acquiring.zip)

---

## 1. Arquitectura Hosted Checkout y ciclo de vida del pago

Monobank Acquiring opera bajo el modelo **Hosted Checkout** (el procesamiento del pago se realiza en una página segura del banco). Esto elimina la necesidad de costosas y complejas certificaciones PCI DSS en su servidor, ya que el cliente introduce sus datos bancarios directamente en el dominio cifrado de Monobank.

La documentación oficial de Monobank para herramientas de IA está disponible en [monobank.ua/api-docs/acquiring/dev/ai-tools/docs--ai-prompts →](https://monobank.ua/api-docs/acquiring/dev/ai-tools/docs--ai-prompts). Guarde este enlace como fuente canónica de especificaciones técnicas.

### 1.1. Flujo básico de pago

```
[ Cliente en la web ]
       │
       ├─ 1. Hace clic en «Pagar»
       ▼
[ Su Servidor ] ─────────────► [ Monobank API: /invoice/create ]
       ▲                              │
       │  recibe pageUrl, invoiceId   │
       └──────────────────────────────┘
       │
       ├─ 2. Redirige al cliente a pageUrl (Hosted Checkout)
       ▼
[ Pasarela Monobank ] ────────► Pago (Apple Pay / Google Pay / Tarjeta)
       │
       ├─ 3. Webhook POST asíncrono con cabecera x-sign
       ▼
[ Endpoint de Webhook ] ──────► Verificación ECDSA SHA-256 → Estado "success"
       │
       ├─ 4. El cliente regresa a /payment-result
       ▼
[ Página de resultado ] ──────► Confirmación del pedido y entrega del producto
```

- **1. Creación de factura (`invoice/create`):** El cliente selecciona un producto o tarifa. El backend emite una petición `POST` a la API de Monobank con el importe, concepto y URLs de retorno. El banco devuelve un identificador único `invoiceId` y un enlace `pageUrl`.
- **2. Redirección a la pasarela de pago:** El cliente es redirigido a `pageUrl`, completando el pago cómodamente mediante Apple Pay, Google Pay, la aplicación de Monobank o introduciendo su tarjeta.
- **3. Recepción de Webhook:** Tras procesar el pago, Monobank envía automáticamente una petición `POST` a su `webHookUrl` con el estado de la transacción y una firma digital criptográfica.
- **4. Confirmación de estados terminales:** El servidor debe gestionar dos estados definitivos: `success` (pago confirmado, desbloqueo de acceso o envío del pedido) y `failure` (error o rechazo del pago por la entidad emisora).

> [!IMPORTANT]
> El estado `expired` (caducidad del enlace de pago) **no emite un webhook**. Si el usuario cierra la pasarela sin abonar el importe, controle el abandono mediante sondeos periódicos (polling).

---

## 2. Requisitos legales y lista de verificación de cumplimiento web

Aunque el código esté perfectamente escrito, los departamentos de seguridad y prevención de blanqueo de Monobank no activarán el terminal en producción si el sitio web carece de la documentación legal exigida por la normativa vigente y los esquemas Visa/Mastercard.

### 2.1. Prerrequisitos bancarios
- **Cuenta corporativa activa en Monobank (Autónomo o Sociedad):** La pasarela solo se vincula a cuentas profesionales. Por ley, no se permite recibir pagos comerciales en tarjetas personales estándar.
- **Epígrafes de actividad económica pertinentes:** El registro mercantil o fiscal debe contemplar el comercio electrónico o la prestación de servicios digitales (por ejemplo, venta online, consultoría informática o formación).

### 2.2. Lista de comprobación previa a la moderación

Asegúrese de que su web incluye los siguientes apartados (habitualmente en el pie de página):

- [ ] **Términos y Condiciones / Contrato de Adhesión:** Objeto del servicio o producto, momento de perfeccionamiento del contrato, derechos y deberes.
- [ ] **Política de Privacidad:** Información precisa sobre el tratamiento de datos personales conforme al Reglamento General de Protección de Datos (RGPD) o leyes locales aplicables.
- [ ] **Política de Devolución y Envíos:** Plazos y condiciones de desistimiento (garantía legal de 14 días para consumidores) o política de cancelación de suscripciones digitales.
- [ ] **Datos identificativos completos en el footer:** Razón social, NIF / CIF / Código de registro fiscal, domicilio legal, teléfono y correo electrónico de atención al cliente.
- [ ] **Precios transparentes y descripción:** Cada botón de compra debe indicar un importe fijo y una descripción inequívoca del producto adquirido.

> [!WARNING]
> Si el portal carece de aviso legal, datos fiscales o utiliza precios ambiguos («a consultar»), Monobank denegará la activación del terminal de producción.

---

## 3. Creación del terminal web en Monobank Empresas

Para comunicarse con la API se necesita una clave secreta de autenticación: `X-Token`. Se genera de forma gratuita en el panel de control para empresas.

### 3.1. Pasos para abrir la caja virtual

1. **Autenticación:** Acceda a [web.monobank.ua →](https://web.monobank.ua/) e inicie sesión escaneando el código QR con la app móvil de Monobank.
2. **Acceso a Kasa:** En el menú lateral izquierdo, seleccione **«Каса» (Caja)**.
3. **Añadir instrumento:** Pulse **«+ Додати інструмент» (+ Añadir instrumento)** y seleccione la opción **«Оплати на сайті (власна розробка)» (Cobros en web - Desarrollo propio)**.
4. **Registro del terminal:** Asigne un nombre identificativo (por ejemplo, `Mi Tienda Online` o `Pasarela Principal`) y confirme con **«Підключити» (Conectar)**.
5. **Generación del token:** Abra el terminal creado, diríjase a la pestaña **«Інтеграції / API Ключі» (Integraciones / Claves API)**, haga clic en **«Створити токен» (Crear token)** y copie la clave secreta.

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

![Creación del instrumento de cobro en Monobank Kasa](/api/guides-media/automation/monobank-acquiring-vibecoding/images/01-monobank-kasa-add-terminal.webp)

> 📍 **Obtención de la clave API:** `Каса` → `Su Terminal` → `Інтеграція` → `Створити X-Token`

![Ventana modal de creación y copia del token X-Token](/api/guides-media/automation/monobank-acquiring-vibecoding/images/02-monobank-create-token.webp)

### 3.2. Reglas fundamentales de seguridad para tokens

- **Nunca incluya el token en código cliente:** `X-Token` otorga control total para gestionar fondos y emitir reembolsos. Jamás lo exponga en HTML/JavaScript ni lo suba a repositorios públicos de GitHub.
- **Variables de entorno estrictas:** Guarde el token únicamente en el servidor dentro del archivo `.env` bajo el nombre `MONOBANK_TOKEN` o en la sección de secretos de su plataforma de hosting (Vercel, Render, Railway, Replit, Lovable).
- **Token de pruebas para desarrollo:** Monobank proporciona un token de pruebas en [api.monobank.ua →](https://api.monobank.ua/) para simular transacciones de ensayo sin transferir fondos reales.

---

## 4. Prompts oficiales de Monobank para agentes de IA

El equipo de Monobank ha diseñado un catálogo de instrucciones predefinidas para modelos de IA, optimizadas para sus endpoints actuales.

![Página de documentación de herramientas de IA de Monobank](/api/guides-media/automation/monobank-acquiring-vibecoding/images/03-monobank-ai-prompts-page.webp)

> [!WARNING]
> **Trampa frecuente: importes en céntimos (kópeks):** La API de Monobank expresa todas las cantidades en unidades monetarias mínimas. $100\text{ UAH} = 10\,000\text{ céntimos}$. Si envía `amount: 100`, el cliente solo abonará 1 UAH.

### 4.1. Prompt básico para creación de facturas

Copie este prompt y péguelo en su asistente de IA:

```markdown
Quiero integrar la pasarela de pagos Monobank en mi sitio web.

TAREA:
Escribe el código completo y listo para producción para generar una orden de pago y redirigir al usuario.

FLUJO REQUERIDO:
1. En mi web hay un botón "Proceder al pago".
2. Al pulsar se envía una petición → se crea la factura en Monobank.
3. El usuario es redirigido a la pasarela alojada de Monobank.
4. Tras pagar, regresa a mi web (https://mysite.com/payment-result).
5. Mi backend valida el estado de la transacción y muestra el resultado.

DATOS DE PRUEBA:
- Importe: 100 UAH (10000 céntimos)
- Concepto: "Pago de pedido"
- URL de retorno: https://mysite.com/payment-result

MI TOKEN DE MONOBANK:
Variable de entorno MONOBANK_TOKEN en .env

DOCUMENTACIÓN:
- Creación de factura: https://monobank.ua/api-docs/acquiring/methods/ia/post--api--merchant--invoice--create
- Consulta de estado: https://monobank.ua/api-docs/acquiring/methods/ia/get--api--merchant--invoice--status

Genera el código limpio y robusto para este flujo.
```

### 4.2. Prompt para el controlador de Webhooks

Sin webhook, su servidor no podrá confirmar el pago si el usuario cierra el navegador nada más completarse la transacción:

```markdown
Necesito que mi servidor reciba confirmaciones de pago automáticas desde Monobank mediante webhook.

TAREA:
Implementa un controlador seguro de webhooks para Monobank.

FLUJO REQUERIDO:
1. El usuario paga en la página de Monobank.
2. Monobank envía una petición POST asíncrona a mi servidor.
3. Mi servidor recibe: invoiceId, status (success/failure) e importe.
4. El servidor verifica la firma digital ECDSA SHA-256 usando la cabecera x-sign y la clave pública del banco.
5. Actualiza el pedido en la base de datos y registra el resultado.

REQUISITOS CRÍTICOS:
- Lee el cuerpo en bruto (raw body Buffer / texto sin parsear) para validar la firma.
- Gestiona los estados: success, failure, processing, hold, expired.
- Garantiza idempotencia: si llega el mismo webhook repetido, evita duplicar la entrega del producto.

Genera el código listo para producción.
```

---

## 5. Paquete de habilidades monobank-acquiring: optimización del agente

Un prompt simple produce un resultado funcional pero básico (aproximadamente 6.8 de 10): el botón responde, pero carece de verificación de firmas criptográficas, protección contra alteración de precios y gestión de latencias.

Para alcanzar calidad de grado empresarial (9.8–10 puntos), añada el paquete de habilidades **`monobank-acquiring`** a la raíz de su proyecto.

![Auditoría de integración de pagos antes y después de aplicar la habilidad](/api/guides-media/automation/monobank-acquiring-vibecoding/images/05-codex-skills-audit.webp)

```bash
# Descarga y descompresión del paquete de habilidades
curl -L -o monobank-acquiring.zip https://gotburnout.io/downloads/monobank-acquiring.zip
unzip monobank-acquiring.zip -d monobank-acquiring/
```

### 5.1. Anatomía del paquete de habilidades

| Archivo | Contenido y función operativa |
| :--- | :--- |
| **`SKILL.md`** | **Manifiesto central:** flujo base, autenticación `X-Token`, esquemas de datos y códigos de error `400`, `403`, `429`, `500`. |
| **`quickstart.md`** | **Guía rápida:** tutorial paso a paso para crear facturas y configurar sondeos de reserva con snippets `curl`. |
| **`invoice.md`** | **Ciclo de vida de facturas:** endpoints para crear, consultar, cancelar e invalidar enlaces. |
| **`webhook.md`** | **Seguridad criptográfica:** validación matemática exacta de firmas ECDSA SHA-256 en cabeceras `x-sign`. |
| **`payment.md`** | **Pagos directos:** cargos recurrentes mediante token, pagos síncronos y verificación 3D Secure. |
| **`wallet.md`** | **Tokenización de tarjetas (Wallet):** almacenamiento seguro de métodos de pago en el banco para compras en 1 clic. |
| **`fiscal.md`** | **Facturación y fiscalización:** estructura de cesta `basketOrder`, cálculo de impuestos, descuentos y descarga de facturas en PDF. |
| **`statement.md`** | **Extractos y analítica:** consulta del registro de transacciones con liquidación de comisiones bancarias. |
| **`merchant.md`** | **Datos de comercio:** obtención de claves públicas, gestión de subcomercios y cajeros. |
| **`examples/`** | **Servidores de referencia:** ejemplos completos en 6 lenguajes (Node.js, Python, Go, PHP, C#, Java). |

---

## 6. Implementación práctica: arquitectura con precios dinámicos

Cada negocio presenta casuísticas distintas: tarifas cerradas de suscripción, comercios electrónicos con carritos variables o botones de contribución puntual.

El error más peligroso de principiante consiste en fijar el importe en el cliente o enviar el precio dentro de la petición POST del navegador.

### 6.1. Principio de seguridad: Precio dinámico en backend

- **Jamás confíe en el importe enviado por el navegador:** Si el frontend envía `{ price: 1000 }`, cualquier atacante puede interceptar la petición y sustituirlo por `{ price: 1 }` para obtener el servicio por una fracción de su valor.
- **El servidor como única fuente de verdad (SSOT):** La interfaz solo remite un identificador de producto (`productId`), tarifa (`planId: "pro"`) o array de cesta (`items: [{ id: "book_1", qty: 2 }]`).
- **Conversión automática a céntimos:** El backend obtiene el valor real de su base de datos o configuración y lo multiplica por 100:
  $$\text{amount} = \text{Math.round}(\text{realPrice} \times 100)$$

### 6.2. Prompt universal para adaptar cualquier proyecto

Envíe esta instrucción a su agente de IA (Codex, Antigravity, Cursor o Claude Code) para que examine el proyecto y conecte la pasarela de forma blindada:

```markdown
Hemos incorporado el paquete de habilidades oficial de Monobank Acquiring en /monobank-acquiring.

TAREA:
1. Examina la arquitectura del proyecto: localiza dónde se definen productos, servicios, tarifas y botones de pago.
2. Desarrolla o adapta un endpoint seguro en el servidor para crear órdenes de cobro en Monobank:
   - El cliente envía ÚNICAMENTE el identificador del producto o plan, NUNCA el precio.
   - El servidor resuelve el precio fidedigno en el backend y lo multiplica por 100.
   - Lee el token de forma segura desde process.env.MONOBANK_TOKEN.
   - Efectúa una petición POST a https://api.monobank.ua/api/merchant/invoice/create.
   - Devuelve al cliente el enlace pageUrl para la redirección al checkout.
3. Actualiza los botones de pago en la interfaz:
   - Añade indicador de carga (spinner) y bloqueo de clics repetidos.
   - Redirige fluidamente a la URL de pago de Monobank.
   - Gestiona posibles errores con avisos visuales comprensibles.
4. Implementa la página de confirmación (/payment-result).
5. Escribe pruebas unitarias que verifiquen el cálculo de importes y la generación de facturas.

Utiliza los esquemas de /monobank-acquiring (especialmente SKILL.md e invoice.md).
```

![Agente de IA analizando la estructura del proyecto y generando el endpoint seguro](/api/guides-media/automation/monobank-acquiring-vibecoding/images/04-codex-initial-prompt.webp)

### 6.3. Plantillas de servidor con precio dinámico

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

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

    // 1. Validación: el precio se determina exclusivamente en el servidor
    const product = PRODUCTS_CATALOG[productId];
    if (!product) {
      return NextResponse.json({ error: "Producto o plan no encontrado" }, { 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. Llamada a la API de Monobank
    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, // Hryvnia ucraniana (ISO 4217)
        merchantPaymInfo: {
          reference: orderReference,
          destination: `Pago de: ${product.title}`,
          comment: `Pedido ${orderReference}`,
        },
        redirectUrl: `${siteUrl}/payment-result?ref=${orderReference}`,
        webHookUrl: `${siteUrl}/api/payment/webhook`,
        validity: 3600, // Validez de 1 hora
      }),
    });

    const data = await response.json();
    if (!response.ok) {
      return NextResponse.json({ error: data.errText || "Error del banco al crear la orden" }, { status: response.status });
    }

    return NextResponse.json({ checkoutUrl: data.pageUrl, invoiceId: data.invoiceId });
  } catch (error) {
    return NextResponse.json({ error: "Error interno al iniciar el pago" }, { 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: "Plan Básico",       priceUah: 490 },
  plan_pro:     { title: "Plan Profesional",  priceUah: 990 },
  plan_vip:     { title: "Plan 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: "Producto o plan no encontrado" });
    }

    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: `Pago de: ${product.title}`,
          comment: `Pedido ${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 || "Error del banco" });
    }

    res.json({ checkoutUrl: data.pageUrl, invoiceId: data.invoiceId });
  } catch (error) {
    res.status(500).json({ error: "Error interno al iniciar el pago" });
  }
});
```
:::

---

## 7. Tratamiento seguro de Webhooks y firma criptográfica ECDSA

El punto más sensible de cualquier integración financiera es la validación de notificaciones. Un atacante podría remitir una petición HTTP simulada a `/api/payment/webhook` anunciando una compra supuestamente abonada.

Para neutralizar este vector, Monobank firma cada notificación mediante el algoritmo asimétrico **ECDSA (curva secp256r1 / SHA-256)** a través de la cabecera `x-sign`.

### 7.1. Por qué `JSON.stringify` invalida la firma

> [!CAUTION]
> **Peligro crítico con `rawBody`:** La verificación ECDSA exige **el flujo exacto de bytes emitido por el servidor de Monobank**. Si parsea el JSON y vuelve a serializarlo mediante `JSON.stringify(req.body)`, el orden de las claves o los espacios en blanco cambian. Como consecuencia, el hash SHA-256 diferirá y la validación **fallará invariablemente**.

### 7.2. Implementación de verificación en Next.js y 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 }, // Caché de la clave pública durante 24h
  });
  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. Lectura del cuerpo original sin mutaciones en formato texto
  const rawBody = await req.text();

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

    // 2. Validación de la firma ECDSA SHA-256
    const verifier = crypto.createVerify("SHA256");
    verifier.update(rawBody);
    const isValid = verifier.verify(pubKey, Buffer.from(signature, "base64"));

    if (!isValid) {
      console.error("Webhook rechazado: firma x-sign no válida");
      return new NextResponse("Invalid signature", { status: 400 });
    }

    // 3. Parseo del JSON exclusivamente tras validar la firma criptográfica
    const payload = JSON.parse(rawBody);
    const { invoiceId, status, amount, reference } = payload;

    if (status === "success") {
      // Registrar la entrega del pedido con protección contra duplicados (idempotencia)
      console.log(`Pedido ${reference} (${invoiceId}) completado por ${amount / 100} UAH`);
    }

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

// Preservar los bytes intactos para la comprobación de firma
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(`Confirmación de ${reference} (${invoiceId}): ${amount / 100} UAH`);
    }

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

---

## 8. Pruebas locales de Webhooks (Localhost y Cloudflare Tunnels)

Al ejecutar la aplicación en `http://localhost:3000`, Monobank no puede enviar notificaciones porque su entorno local carece de una IP pública enrutada.

Dado que el banco exige endpoints públicos con protocolo `HTTPS`, recurra a un túnel seguro durante el desarrollo.

### 8.1. Despliegue inmediato del túnel (gratuito y sin registro)

:::tabs
=== Cloudflare (npx untun)
```bash
# Túnel HTTPS instantáneo al puerto 3000 sin instalar binarios
npx untun@latest tunnel --port 3000
```
=== Cloudflare CLI (cloudflared)
```bash
# Instalación vía brew en macOS
brew install cloudflared
cloudflared tunnel --url http://localhost:3000
```
=== ngrok
```bash
# Si utiliza ngrok
ngrok http 3000
```
:::

Obtendrá un dominio público efímero:
`https://your-tunnel-name.trycloudflare.com`

### 8.2. Configuración en desarrollo
En el endpoint de creación de orden, defina:
```typescript
webHookUrl: "https://your-tunnel-name.trycloudflare.com/api/payment/webhook"
```
Los cobros de prueba impactarán directamente en su terminal local, facilitando la depuración de firmas en tiempo real.

---

## 9. UX de la página de retorno y resolución de condiciones de carrera

Cuando un cliente completa el abono en Apple Pay o en la app de Monobank, es devuelto a `redirectUrl` (`/payment-result?ref=...`) de forma **inmediata**.

No obstante, la notificación HTTP de Monobank a su servidor puede demorarse entre 1 y 2 segundos por latencia de red. Si la página de destino consulta inmediatamente la base de datos, podría mostrar erróneamente: *«Pedido pendiente de pago»*, alarmando al usuario.

### 9.1. Patrón arquitectónico de mitigación
1. **Estado de espera inicial:** La página abre con un mensaje neutral: `«Confirmando pago con el banco...»` y un indicador visual de carga.
2. **Sondeo corto (Short Polling):** El frontend realiza hasta 5 consultas a intervalos de 1.5 segundos (`/api/orders/check-status?ref=...`), aguardando a que el webhook marque el pedido como `success`.
3. **Mecanismo de escape:** Si tras 8 segundos no hay confirmación definitiva, se informa: *«Pago en proceso de liquidación. El acceso se activará en 1-2 minutos»*.

### 9.2. Componente React para la página de confirmación

```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">Verificando pago...</h2>
          <p className="text-sm text-neutral-400">Recibiendo estado desde Monobank. Espere unos instantes.</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">¡Pago realizado con éxito!</h2>
          <p className="text-sm text-neutral-400 mb-6">El pedido #{ref} ha sido confirmado correctamente.</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">
            Ir al panel principal
          </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">Pago en tramitación</h2>
          <p className="text-sm text-neutral-400 mb-6">Importe retenido. La acreditación finalizará en 1-2 minutos.</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">
            Volver a la portada
          </button>
        </div>
      )}
    </div>
  );
}
```

---

## 10. Prestaciones avanzadas: Facturación integrada, Retención y Wallet

Monobank Acquiring cubre escenarios comerciales avanzados:

![Página de pago alojada de Monobank con Apple Pay y tarjetas](/api/guides-media/automation/monobank-acquiring-vibecoding/images/06-monobank-checkout-page.webp)

### 10.1. Facturación electrónica automática (pRRO Checkbox)

La fiscalización de cobros es obligatoria para la mayoría de profesionales que operan en Ucrania.

Monobank incluye una **integración nativa y sin coste con Checkbox**:
- **Activación en un clic:** En el panel de control del terminal active **«Фіскалізація через Checkbox»**. El banco emite y suscribe las facturas oficiales automáticamente con su certificado digital.
- **Sin código adicional para productos estándar:** Las facturas se generan partiendo del campo `destination`.
- **Desglose de cesta mediante API:** Si comercializa productos con tipos impositivos diferenciados, adjunte el array `basketOrder` en `merchantPaymInfo`:

```json
"merchantPaymInfo": {
  "reference": "order_1001",
  "destination": "Acceso al curso online",
  "customerEmails": ["cliente@example.com"],
  "basketOrder": [
    {
      "name": "Curso Avanzado de Vibe-Coding",
      "qty": 1,
      "sum": 99000,
      "code": "SKU-COURSE-01",
      "unit": "ud",
      "total": 99000
    }
  ]
}
```

### 10.2. Autorización previa en dos fases (Hold)

Idóneo para productos físicos dependientes de control de inventario:

- **Retención:** Asigne `paymentType: "hold"` al crear la orden. El dinero queda congelado en la tarjeta del comprador hasta 9 días.
- **Captura definitiva (Finalize):** Llame al endpoint `/api/merchant/invoice/finalize` para liquidar el importe total o parcial.
- **Liberación:** Si no hay stock, invoque `/api/merchant/invoice/cancel` para liberar los fondos sin comisiones.

### 10.3. Tokenización y suscripciones recurrentes (Wallet)

Para suscripciones SaaS periódicas, transmita `saveCardData: true` en el primer pago. Tras completarse, el webhook notificará un identificador `walletId`, que servirá para procesar cobros recurrentes desatendidos.

---

## 11. Matriz de seguridad y pruebas automatizadas (Vitest / Jest)

El procesamiento de pagos no tolera errores. Ejecute esta suite de validación antes del lanzamiento:

| Prueba | Vector evaluado | Comportamiento esperado |
| :--- | :--- | :--- |
| **1. Protección contra alteración de precio** | El cliente envía `productId: "vip"`, pero inyecta `amount: 100` (1 UAH) | El servidor ignora el importe del cliente y aplica la tarifa oficial (2490 UAH = 249.000 céntimos). Devuelve `HTTP 400` ante IDs inexistentes. |
| **2. Bloqueo de peticiones sin firma** | Se recibe un POST en `/api/payment/webhook` sin la cabecera `x-sign` | Bloqueo fulminante con código `HTTP 400 Bad Request`. Sin alteraciones en BD. |
| **3. Rechazo de firmas fraudulentas** | Un atacante envía una firma manipulada con `status: "success"` | `crypto.verify(SHA256, ...)` resulta falso. Retorno `HTTP 400/401`. Pedido no entregado. |
| **4. Idempotencia en webhooks duplicados** | Monobank reintenta la entrega del webhook `success` por congestión de red | El producto o acceso se asigna **una única vez**. Los duplicados devuelven `HTTP 200 OK` sin duplicar entregas. |
| **5. Resistencia a condiciones de carrera** | El webhook llega antes de que concluya el registro inicial en la base de datos | El handler emplea `UPSERT` o resuelve el registro sin producir errores 500. |
| **6. Prevención de saturación de peticiones** | Fallback de consulta `/api/merchant/invoice/status` | Intervalo mínimo de sondeo de **15 segundos** para evitar bloqueos `HTTP 429 Too Many Requests`. |

### 11.1. Suite de pruebas automatizadas (`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: Impide la manipulación de precios desde el cliente", () => {
    const CATALOG: Record<string, { priceUah: number }> = { plan_pro: { priceUah: 990 } };
    const clientPayload = { productId: "plan_pro", amount: 100 }; // Intento de inyectar 1 UAH
    
    const safeAmount = Math.round(CATALOG[clientPayload.productId].priceUah * 100);
    expect(safeAmount).toBe(99000); // 990.00 UAH forzadas
  });

  it("Test 2: Rechaza peticiones sin la cabecera obligatoria x-sign", () => {
    const headers: Record<string, string> = {};
    const hasSignature = Boolean(headers["x-sign"]);
    expect(hasSignature).toBe(false);
  });

  it("Test 3: Valida con éxito una firma digital auténtica del banco", () => {
    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: Bloquea firmas falsificadas o payloads alterados", () => {
    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: Idempotencia evita duplicación de entregas ante reintentos", () => {
    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);  // Primer evento
    expect(handleOrder("inv_001").processed).toBe(false); // Duplicado descartado
    expect(processedOrders.size).toBe(1);
  });
});
```

---

## 12. Lista de verificación técnica final antes del lanzamiento

Compruebe su módulo de cobros contra estos 10 puntos antes de habilitar el tráfico de producción:

- [ ] **Cumplimiento legal del sitio:** El pie de página enlaza a Condiciones del Servicio, Privacidad, Devoluciones e identificación fiscal con NIF/CIF.
- [ ] **Importes en céntimos:** Valores de `amount` multiplicados por 100 ($1\text{ UAH} = 100\text{ céntimos}$) mediante `Math.round`.
- [ ] **Aislamiento de credenciales:** Clave API almacenada en `.env` como `MONOBANK_TOKEN` e ignorada en `.gitignore`.
- [ ] **Precios desde backend (SSOT):** El cliente solo envía el identificador del producto; los importes se calculan en el servidor.
- [ ] **Lectura del cuerpo en bruto:** El webhook valida texto o búferes binarios puros (`req.text()` o `req.rawBody`), sin serialización intermedia via `JSON.stringify`.
- [ ] **Criptografía robusta:** Firma verificada contra la clave pública del banco mediante ECDSA SHA-256.
- [ ] **Dirección HTTPS pública:** Endpoint de webhook accesible en un dominio con certificado SSL válido (comprobado vía Cloudflare Tunnel o ngrok).
- [ ] **Almacenamiento idempotente:** Las notificaciones duplicadas no duplican la entrega de productos o accesos.
- [ ] **Mitigación de condición de carrera:** `/payment-result` cuenta con estado de carga transitorio y sondeo corto.
- [ ] **Pago real de prueba de 1 a 5 UAH:** Transacción completada con tarjeta bancaria real para ratificar la liquidación en cuenta.

---

## Recursos y enlaces de descarga

- [Descargar paquete de habilidades para agentes: monobank-acquiring.zip (38 KB) →](/downloads/monobank-acquiring.zip)
- [Documentación oficial de Prompts de IA de Monobank →](https://monobank.ua/api-docs/acquiring/dev/ai-tools/docs--ai-prompts)
- [Portal de Monobank Empresas →](https://web.monobank.ua/)
- [Vídeo explicativo paso a paso en YouTube →](https://youtu.be/GMh_fOCiQ4E)
- [Entorno de pruebas Sandbox de Monobank →](https://api.monobank.ua/)