# OrvenPay: documentación completa de la API > OrvenPay es una pasarela de pagos en criptomonedas custodial. Tu servidor crea facturas por API (`POST https://api.orvenpay.com/v1/invoices` con la cabecera `X-Api-Key`), el cliente paga en la página alojada (`invoiceUrl`) a una dirección única por factura, y recibes webhooks firmados con HMAC-SHA256 (`X-OrvenPay-Signature`) en cada cambio de estado; lo cobrado se acredita a tu saldo y lo retiras a tu libreta. También hay una API Gateway compatible con plugins (`https://api.orvenpay.com/gateway`, `api_key` + `verify_hash`). Este archivo es la documentación COMPLETA y autosuficiente (fuente única de https://orvenpay.com/docs), pensada para personas y agentes de IA. Revisión: 2026-11-08. - API nativa: `https://api.orvenpay.com/v1` - API Gateway: `https://api.orvenpay.com/gateway` (endpoints `https://api.orvenpay.com/gateway/api/v1/...`) - Página de pago: `https://orvenpay.com/invoice/{id}` - OpenAPI 3.1: https://orvenpay.com/openapi.json - Índice: https://orvenpay.com/llms.txt · HTML: https://orvenpay.com/docs ## Instrucciones para agentes de IA 1. Crea facturas SÓLO en el servidor (`POST /v1/invoices`, cabecera `X-Api-Key`). Nunca expongas la clave en el navegador o en apps. 2. Usa importes como strings decimales (`"49.90"`), nunca floats. `orderNumber` = id estable del pedido (idempotencia). 3. Redirige al cliente a `invoice.invoiceUrl` (tiendas estándar: `address` es `null`; sólo White Label recibe la dirección). 4. Implementa el webhook leyendo el cuerpo CRUDO, verifica `X-OrvenPay-Signature` (`t=,v1=`, HMAC-SHA256 de `t + "." + cuerpo` con el secreto `whsec_...`, ventana 300 s, comparación en tiempo constante) y responde 2xx en < 10 s. 5. Entrega el pedido sólo con `invoice.completed`. Trata `invoice.mismatch` (pago parcial acreditado) según la política del comercio. Haz el manejador idempotente por (`data.id`, `event`). 6. Configura `ORVENPAY_API_URL`, `ORVENPAY_API_KEY` y `ORVENPAY_WEBHOOK_SECRET` como variables de entorno; no inventes valores. 7. Decide por `error.code`, no por el texto. Reintenta sólo 409 CONFLICT/TRANSACTION_CONFLICT, 429 (respeta `Retry-After`), 5xx. ## Contenido 1. [Introducción y conceptos](https://orvenpay.com/docs#introduccion) - Qué es OrvenPay, modelo custodial, dirección única por factura, saldos, comisión y fee de red. 2. [Inicio rápido en 5 minutos](https://orvenpay.com/docs#inicio-rapido) - Cuenta, tienda, API key, primera factura, redirección y webhook verificado. 3. [Referencia rápida de endpoints](https://orvenpay.com/docs#referencia-rapida) - Todos los endpoints públicos en una tabla: método, ruta, autenticación y propósito. 4. [Autenticación y API keys](https://orvenpay.com/docs#autenticacion) - X-Api-Key en la API nativa, api_key en la API Gateway, rotación y buenas prácticas. 5. [Facturas](https://orvenpay.com/docs#facturas) - Crear (fiat o cripto), parámetros, importes y redondeo, idempotencia por orderNumber, expiración, metadata, consulta, listado y cancelación. 6. [Página de pago alojada](https://orvenpay.com/docs#checkout) - Qué hace la página de pago alojada (invoiceUrl) y endpoints públicos del checkout (estado, elección de moneda, SSE). 7. [White Label](https://orvenpay.com/docs#white-label) - Plan por tienda: datos de pago completos por API (dirección, importe, QR), white_label en /shops y tu marca en el checkout; comisión 1 % por defecto. 8. [Estados y ciclo de vida](https://orvenpay.com/docs#estados) - Máquina de estados de la factura, eventos, tolerancia de pago insuficiente, sobrepagos, pagos tardíos y casos límite. 9. [Webhooks](https://orvenpay.com/docs#webhooks) - Eventos, payload, cabeceras, verificación HMAC-SHA256 (Node.js, Python, PHP, Go), cuerpo crudo, reintentos, orden, idempotencia y protección anti-replay. 10. [API Gateway](https://orvenpay.com/docs#api-gateway) - Capa compatible con plugins y SDK de pasarelas (api_key + verify_hash HMAC-SHA1): endpoints, parámetros, respuestas estándar/White Label, callbacks, verificación y códigos de error. 11. [Saldos y retiros](https://orvenpay.com/docs#saldos-retiros) - Saldos por moneda, maduración de créditos, libreta de direcciones con activación, mínimos, fees, revisión, límite diario, pausa e idempotencia de retiros. 12. [Monedas y redes](https://orvenpay.com/docs#monedas) - Monedas soportadas con red, contrato, confirmaciones, decimales, mínimos y fee de red; monedas fiat de precio. 13. [Comisiones y mínimos](https://orvenpay.com/docs#comisiones) - Comisión por plan (0,5 % estándar, 1 % White Label), quién paga comisión y fee de red, cómo se descuenta del crédito y ejemplos numéricos. 14. [Límites y rate limits](https://orvenpay.com/docs#limites) - Peticiones por minuto por API key e IP, cupo de facturas por cuenta, tamaños máximos y cabeceras de rate limit. 15. [Errores](https://orvenpay.com/docs#errores) - Formato de error y catálogo completo de códigos de la API nativa y pública. 16. [Seguridad](https://orvenpay.com/docs#seguridad) - Buenas prácticas: secretos, verificación de webhooks, no confiar en el navegador, importes, idempotencia y respuesta ante incidentes. 17. [Recetas de integración](https://orvenpay.com/docs#recetas) - Código completo listo para copiar: bots de Telegram (python-telegram-bot, grammY), Next.js, Express, PHP/Laravel y plugins de e-commerce vía API Gateway. 18. [Pruebas](https://orvenpay.com/docs#pruebas) - Checklist antes de producción, webhooks en local con un túnel, firmar payloads de prueba y pago real de bajo importe. 19. [Preguntas frecuentes](https://orvenpay.com/docs#faq) - Respuestas cortas a las dudas más comunes de integración. 20. [Changelog](https://orvenpay.com/docs#changelog) - Cambios relevantes de la API y la documentación. --- ## 1. Introducción y conceptos *Qué es OrvenPay, modelo custodial, dirección única por factura, saldos, comisión y fee de red.* OrvenPay es una pasarela de pagos en criptomonedas **custodial**: tu servidor (web, app o bot) crea una **factura** por API, el cliente paga en la blockchain y OrvenPay detecta el pago, espera las confirmaciones, **acredita el importe a tu saldo** (descontando comisión y fee de red) y te avisa con un **webhook firmado**. Retiras tu saldo cuando quieras a direcciones de tu libreta. *URLs base* | Recurso | URL | | --- | --- | | API nativa (JSON, cabecera `X-Api-Key`) | `https://api.orvenpay.com/v1` | | API Gateway (formato `api_key` + `verify_hash`, para plugins y SDK) | `https://api.orvenpay.com/gateway` (endpoints en `https://api.orvenpay.com/gateway/api/v1/...`) | | Endpoints públicos del checkout (sin clave) | `https://api.orvenpay.com/public` | | Página de pago alojada | `https://orvenpay.com/invoice/{id}` | | Documentación HTML | `https://orvenpay.com/docs` | | Documentación completa en Markdown (para IAs) | `https://orvenpay.com/llms-full.txt` (alias `https://orvenpay.com/docs.md`) | | Índice llms.txt | `https://orvenpay.com/llms.txt` | | OpenAPI 3.1 | `https://orvenpay.com/openapi.json` | ### Conceptos clave - **Cuenta**: tu usuario en el panel (https://orvenpay.com/dashboard). Los **saldos** y los **retiros** son de la cuenta (compartidos por todas sus tiendas). - **Tienda**: agrupa API keys, secreto de webhooks, URLs por defecto (`callbackUrl`, `successUrl`, `failUrl`), monedas activas, quién paga la comisión (`feePaidBy`) y el fee de red (`networkFeePaidBy`) y la expiración por defecto (`defaultExpireMin`, 60 min). Cada tienda tiene plan **estándar** o **White Label**. - **Factura** (`invoice`): una orden de cobro con un `orderNumber` único por tienda. Puede tener precio en fiat (`sourceCurrency` + `sourceAmount`, el cliente elige la cripto) o en cripto (`currency` + `amount`). - **Dirección única por factura**: cuando se fija la moneda, la plataforma deriva una dirección de depósito NUEVA (HD) sólo para esa factura. El pago se identifica por la dirección, no por un memo. Nunca reutilices una dirección de una factura para otra. - **Pago** (`payment`): cada transacción on-chain detectada hacia la dirección de la factura. Una factura puede recibir varios pagos (se suman). - **Confirmaciones**: la factura pasa a `COMPLETED` cuando lo recibido y CONFIRMADO alcanza el importe (con una tolerancia del 0,5 %). Las confirmaciones exigidas dependen de la red (ver [Monedas y redes](https://orvenpay.com/docs#monedas)). - **Saldo**: lo cobrado se acredita en la misma moneda (no hay conversión). `available` = disponible, `locked` = reservado por retiros en curso, `withdrawable` = retirable ya (los créditos tardan 1 h en "madurar" por seguridad ante reorganizaciones). - **Comisión**: porcentaje sobre lo cobrado. Por defecto **0.5 %** (plan estándar) y **1 %** (plan White Label). La paga el comercio (se descuenta del crédito) o el cliente (se suma al importe), según `feePaidBy` de la tienda. - **Fee de red de la factura**: cubre el coste real de mover los fondos desde la dirección de depósito (barrido). Lo paga el cliente por defecto (`networkFeePaidBy: CUSTOMER`, se suma al importe) o el comercio. Siempre se descuenta del crédito: si lo pagó el cliente, ya venía incluido en lo recibido. - **Webhooks**: notificaciones HTTP POST firmadas a tu `callbackUrl` en cada cambio de estado. Son la fuente de verdad para entregar el pedido. ### Flujo completo **Secuencia** ```text Tu servidor OrvenPay API Cliente / blockchain | POST /v1/invoices | | |---------------------------->| crea factura (NEW) | |<----------------------------| { invoice.invoiceUrl } | | redirige al cliente --------------------------------------->| abre invoiceUrl, elige moneda, | |<------------------------------| paga a la dirección única | | detecta tx -> PENDING | |<--- webhook invoice.pending-| | | | N confirmaciones -> COMPLETED | | | acredita saldo (- comisión) | |<-- webhook invoice.completed| ------------------------------>| redirige a successUrl | verifica firma, entrega | | | el pedido, responde 200 | | ``` > **Convenciones.** JSON en `camelCase` en la API nativa (`snake_case` en la API Gateway). Los importes son **strings decimales** (nunca floats) y se devuelven normalizados sin ceros finales (`"49.90"` se devuelve como `"49.9"`). Las fechas son ISO 8601 en UTC (timestamps Unix en segundos en la API Gateway). Los ids son cadenas opacas alfanuméricas (cuid), p. ej. `cmhx3v8qk0004l70gq2b9d7ma`. --- ## 2. Inicio rápido en 5 minutos *Cuenta, tienda, API key, primera factura, redirección y webhook verificado.* 1. Crea una cuenta en https://orvenpay.com/register y una **tienda** en *Panel → Tiendas → Nueva tienda*. Indica la URL de webhooks (`callbackUrl`, debe ser pública: no se aceptan localhost ni IPs privadas), las URLs de retorno y las monedas que aceptas. 2. En la tienda, crea una **API key** (`op_live_...`, se muestra **una sola vez**) y copia el **secreto de webhooks** (`whsec_...`). 3. Guarda ambos en variables de entorno de tu servidor: `ORVENPAY_API_KEY`, `ORVENPAY_WEBHOOK_SECRET` y `ORVENPAY_API_URL=https://api.orvenpay.com`. 4. Crea una factura desde tu backend (abajo) y redirige al cliente a `invoice.invoiceUrl`. 5. Implementa el endpoint de webhooks: verifica `X-OrvenPay-Signature`, y cuando llegue `invoice.completed` marca el pedido como pagado. Responde `200` rápido. 6. Prueba de extremo a extremo con un importe pequeño en una red barata (p. ej. `USDT_BEP20` o `TRX`, mínimo 1 USD). Ver [Pruebas](https://orvenpay.com/docs#pruebas). **cURL** ```bash curl -X POST https://api.orvenpay.com/v1/invoices \ -H "X-Api-Key: $ORVENPAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "orderNumber": "10245", "orderName": "Plan Pro mensual", "sourceCurrency": "USD", "sourceAmount": "49.90", "allowedCurrencies": ["USDT_TRC20", "USDT_BEP20", "BTC"], "successUrl": "https://mitienda.com/pedido/10245/gracias", "metadata": { "userId": "u_981", "plan": "pro" } }' ``` **Node.js** ```js // Node 18+ (fetch nativo). Sólo en el servidor. const res = await fetch(process.env.ORVENPAY_API_URL + '/v1/invoices', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Api-Key': process.env.ORVENPAY_API_KEY }, body: JSON.stringify({ orderNumber: '10245', orderName: 'Plan Pro mensual', sourceCurrency: 'USD', sourceAmount: '49.90', metadata: { userId: 'u_981' }, }), }); const body = await res.json(); if (!res.ok) throw new Error(body.error.code + ': ' + body.error.message); // 201 = creada, 200 = ya existía (misma petición repetida) console.log(body.invoice.invoiceUrl); ``` **Python** ```python import os, requests r = requests.post( os.environ["ORVENPAY_API_URL"] + "/v1/invoices", headers={"X-Api-Key": os.environ["ORVENPAY_API_KEY"]}, json={ "orderNumber": "10245", "orderName": "Plan Pro mensual", "sourceCurrency": "USD", "sourceAmount": "49.90", "metadata": {"userId": "u_981"}, }, timeout=15, ) body = r.json() if r.status_code >= 400: raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}") print(body["invoice"]["invoiceUrl"]) ``` **PHP** ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Api-Key: ' . getenv('ORVENPAY_API_KEY')], CURLOPT_POSTFIELDS => json_encode([ 'orderNumber' => '10245', 'orderName' => 'Plan Pro mensual', 'sourceCurrency' => 'USD', 'sourceAmount' => '49.90', ]), ]); $body = json_decode(curl_exec($ch), true); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($status >= 400) { throw new RuntimeException($body['error']['code'] . ': ' . $body['error']['message']); } header('Location: ' . $body['invoice']['invoiceUrl'], true, 303); ``` ### Respuesta (201 Created) **201 Created** ```json { "invoice": { "id": "cmhx3v8qk0004l70gq2b9d7ma", "storeId": "cmhx2a1bc0001l70g5s6t7u8v", "storeName": null, "source": "API", "orderNumber": "10245", "orderName": "Plan Pro mensual", "description": null, "email": "cliente@example.com", "status": "NEW", "overpaid": false, "sourceCurrency": "USD", "sourceAmount": "49.9", "currency": null, "symbol": null, "network": null, "rate": null, "amount": null, "baseAmount": null, "amountReceived": "0", "pendingAmount": null, "address": null, "derivationIndex": null, "confirmations": 0, "requiredConfirmations": 1, "allowedCurrencies": ["USDT_TRC20", "USDT_BEP20", "BTC"], "feePercent": "0.5", "commission": null, "feeAmount": "0", "feeAmountUsd": "0.2495", "feePaidBy": "MERCHANT", "networkFee": null, "networkFeePaidBy": "CUSTOMER", "networkFeeCharged": "0", "callbackUrl": "https://mitienda.com/api/orvenpay/webhook", "successCallbackUrl": null, "failCallbackUrl": null, "successUrl": "https://mitienda.com/pedido/10245/gracias", "failUrl": "https://mitienda.com/pedido/10245", "metadata": { "userId": "u_981", "plan": "pro" }, "invoiceUrl": "https://orvenpay.com/invoice/cmhx3v8qk0004l70gq2b9d7ma", "whiteLabel": false, "expiresAt": "2026-11-08T15:00:00.000Z", "paidAt": null, "createdAt": "2026-11-08T14:00:00.000Z", "updatedAt": "2026-11-08T14:00:00.000Z", "payments": [] }, "existing": false } ``` > **Listo.** Con esto ya cobras. El resto de la documentación detalla cada campo, estado, error y caso límite. Si usas un agente de IA, dale la URL `https://orvenpay.com/llms-full.txt`: contiene todo en un único archivo. --- ## 3. Referencia rápida de endpoints *Todos los endpoints públicos en una tabla: método, ruta, autenticación y propósito.* *API nativa (base https://api.orvenpay.com)* | Método | Ruta | Auth | Propósito | | --- | --- | --- | --- | | POST | `/v1/invoices` | `X-Api-Key` | Crear factura (idempotente por `orderNumber`). | | GET | `/v1/invoices` | `X-Api-Key` | Listar facturas de la tienda (cursor, filtros `status`, `orderNumber`). | | GET | `/v1/invoices/{id}` | `X-Api-Key` | Consultar una factura (incluye `payments`). | | POST | `/v1/invoices/{id}/cancel` | `X-Api-Key` | Cancelar una factura en estado `NEW`. | | GET | `/v1/currencies` | `X-Api-Key` | Monedas activas, tasa USD, fee de retiro y disponibilidad para la tienda. | | GET | `/v1/balances` | `X-Api-Key` | Saldos de la cuenta por moneda. | | POST | `/v1/withdrawals` | `X-Api-Key` | Solicitar un retiro a una dirección activa de tu libreta. | | GET | `/v1/withdrawals/{id}` | `X-Api-Key` | Consultar un retiro. | *Endpoints públicos (sin clave)* | Método | Ruta | Propósito | | --- | --- | --- | | GET | `/public/config` | Configuración pública: marca, monedas activas, comisiones publicadas, retiros pausados. | | GET | `/public/invoices/{id}` | Estado de una factura para la página de pago (datos seguros para el cliente). | | POST | `/public/invoices/{id}/select-currency` | El cliente elige moneda en una factura fiat (`{"currency":"USDT_TRC20"}`). | | GET | `/public/invoices/{id}/events` | Server-Sent Events: `event: invoice` en cada cambio. | | GET | `/public/stores/{id}/logo` | Logo de una tienda White Label (checkout con marca propia). | *API Gateway (base https://api.orvenpay.com/gateway)* | Método | Ruta | Propósito | | --- | --- | --- | | GET/POST | `/gateway/api/v1/invoices/new` | Crear factura (parámetros `snake_case`, `api_key`). | | GET | `/gateway/api/v1/operations` | Listar operaciones (facturas y retiros) paginadas. | | GET | `/gateway/api/v1/operations/{id}` | Consultar una operación. | | GET/POST | `/gateway/api/v1/operations/withdraw` | Retiro (desactivado por defecto). | | GET | `/gateway/api/v1/operations/fee/{psys_cid}` | Fee de red de retiro. | | GET | `/gateway/api/v1/operations/fee-plan/{psys_cid}` | Planes de fee de retiro. | | GET | `/gateway/api/v1/operations/commission/{psys_cid}` | Comisión y fee de retiro. | | GET | `/gateway/api/v1/balances` · `/balances/{psys_cid}` | Saldo(s). | | GET | `/gateway/api/v1/currencies` · `/currencies/{fiat}` | Monedas con precios, mínimos y comisión. | | GET | `/gateway/api/v1/shops` | Datos de la tienda (`white_label`). | Especificación completa y tipada: [openapi.json](https://orvenpay.com/openapi.json). Las rutas del panel (`/api/...`, sesión de usuario) y las de administración no forman parte de la API pública: no las uses desde integraciones. --- ## 4. Autenticación y API keys *X-Api-Key en la API nativa, api_key en la API Gateway, rotación y buenas prácticas.* Cada tienda tiene sus propias **API keys** con formato `op_live_` + 43 caracteres base64url. Envía la clave en la cabecera `X-Api-Key` en cada petición a `/v1`: **Cabecera** ```http X-Api-Key: op_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` - Se crean en *Panel → Tiendas → (tu tienda) → API keys* (pide contraseña o 2FA). El valor completo se muestra **una sola vez**; después sólo se ve el prefijo (12 caracteres) y `lastUsedAt`. - Máximo 20 claves activas por tienda. Revocar es inmediato: la siguiente petición con esa clave devuelve `401 INVALID_API_KEY`. - La **API Gateway** acepta la clave como parámetro `api_key` en la query string (lo habitual en plugins), en el cuerpo (`application/x-www-form-urlencoded` o JSON) o en la cabecera `X-Api-Key`. - Alcance: una clave identifica a **una tienda** (facturas de esa tienda) y a su **cuenta** (saldos y retiros de la cuenta, compartidos por todas sus tiendas). - Una clave permite consultar saldos y **solicitar retiros** (sólo a direcciones ya activadas de tu libreta, sin 2FA): trátala como una credencial financiera. *Errores de autenticación* | HTTP | code | Causa | | --- | --- | --- | | 401 | `INVALID_API_KEY` | Falta la clave, no existe, supera 200 caracteres o está revocada. | | 403 | `ACCOUNT_SUSPENDED` | La cuenta propietaria está suspendida. | | 403 | `STORE_INACTIVE` | La tienda está desactivada. | ### Rotación de claves 1. Crea una clave nueva en el panel. 2. Despliega la nueva clave en tus servidores (variable de entorno) y verifica que funciona. 3. Revoca la clave antigua. 4. API Gateway: los callbacks `verify_hash` se firman con la clave que **creó** cada factura (aunque se revoque después). Mantén la clave antigua en tu verificador hasta que sus facturas abiertas terminen (máximo 24 h de vida + ventana de pagos tardíos). ### Secreto de webhooks Cada tienda tiene un **secreto de webhooks** (`whsec_...`) distinto de las API keys. Se ve y se rota en el panel (pide reautenticación). La rotación es inmediata: todos los envíos posteriores, incluidos los reintentos pendientes, se firman con el secreto nuevo. Para rotar sin perder webhooks, acepta durante unos minutos firmas válidas con cualquiera de los dos secretos. > **Nunca en el frontend.** No pongas la API key ni el secreto de webhooks en JavaScript del navegador, apps móviles, repositorios o logs. Crea las facturas siempre desde tu servidor o bot y envía al cliente sólo `invoiceUrl`. Si una clave se filtra: revócala al momento y revisa *Panel → Retiros*. --- ## 5. Facturas *Crear (fiat o cripto), parámetros, importes y redondeo, idempotencia por orderNumber, expiración, metadata, consulta, listado y cancelación.* ### Crear una factura `POST /v1/invoices` · auth: `X-Api-Key` Crea una factura. Cuerpo JSON (`Content-Type: application/json`, máximo 256 KB). Responde `201 Created` con `{ "invoice": Invoice, "existing": false }`, o `200 OK` con `"existing": true` si repites la misma petición (ver idempotencia). *Parámetros del cuerpo* | Campo | Tipo | Oblig. | Restricciones y comportamiento | | --- | --- | --- | --- | | `orderNumber` | string o number | sí | Tu id de pedido. Se recorta y se convierte a string; 1-128 caracteres. **Único por tienda** (clave de idempotencia). | | `orderName` | string | no | Máx. 255. Concepto visible para el cliente en la página de pago. | | `description` | string | no | Máx. 1000. Texto adicional. | | `sourceCurrency` | string | no | Máx. 10, se pasa a mayúsculas. Moneda del precio: fiat (USD, EUR, GBP, RUB, UAH, BRL, MXN, ARS, COP, CLP, PEN, VES, TRY, INR, CNY, JPY, KRW, CAD, AUD, CHF, PLN, NGN, KZT, IDR, PHP, VND, THB, AED, SAR) o un código cripto. Si envías `sourceAmount` sin `sourceCurrency` se asume `USD`. | | `sourceAmount` | decimal (string recomendado) | uno de los dos modos | Precio en `sourceCurrency`. > 0; hasta 18 dígitos enteros y 18 decimales; sin notación científica. Si `sourceCurrency` es cripto, equivale a `amount` + `currency`. | | `currency` | string | no* | Máx. 20. Código de moneda (`USDT_TRC20`) o su `psysCid` (`USDT_TRX`). Fija la cripto: la factura nace con importe y dirección. Si se omite en modo fiat, el cliente elige en la página de pago. *Obligatorio con `amount`. | | `amount` | decimal | uno de los dos modos | Importe en cripto (base, antes de comisión/fee si los paga el cliente). Requiere `currency`. Se ignora si envías `sourceAmount`. | | `allowedCurrencies` | string[] | no | Máx. 20 elementos. Limita las monedas ofrecidas al cliente (códigos o psysCid). Un código desconocido da `400 INVALID_CURRENCY`; si ninguna está disponible, `400 NO_CURRENCIES_AVAILABLE`. | | `callbackUrl` | URL http(s) | no | Máx. 2048. URL de webhooks de ESTA factura; si se omite (o `""`/`null`) se usa la de la tienda. No puede apuntar a redes internas (localhost, 10.x, 192.168.x, *.local...). | | `successUrl` | URL http(s) | no | Retorno tras pagar. Por defecto, la de la tienda. | | `failUrl` | URL http(s) | no | Retorno si expira o se cancela. Por defecto, la de la tienda. | | `email` | string (email) | no | Máx. 254. Email del cliente, para tu referencia. | | `expireMin` | integer | no | 1-1440 minutos. Por defecto `defaultExpireMin` de la tienda (60). | | `metadata` | object | no | Objeto JSON libre, máx. 4 KB serializado. Se devuelve tal cual en la factura y en cada webhook (útil para `userId`, `chatId`, etc.). No guardes secretos. | ### Modos de importe - **Fiat, el cliente elige moneda** (recomendado): `sourceCurrency` + `sourceAmount`, sin `currency`. La factura nace con `currency: null`, `amount: null`, `address: null`. Al elegir moneda en la página de pago se fija el tipo de cambio, el importe y una dirección nueva. - **Fiat con moneda fijada**: `sourceCurrency` + `sourceAmount` + `currency`. Importe y dirección se calculan al crear. - **Cripto**: `currency` + `amount` (o `sourceCurrency` cripto + `sourceAmount`). `amount` es el importe base en cripto. - Sin ninguno de los modos: `400 AMOUNT_REQUIRED`. `amount` sin `currency`: `400 CURRENCY_REQUIRED`. `sourceCurrency` cripto distinta de `currency`: `400 CURRENCY_MISMATCH`. Fiat no soportado: `400 INVALID_SOURCE_CURRENCY`. ### Cálculo del importe y redondeo **Fórmulas (todo con decimales exactos, nunca floats)** ```text rate = precio de 1 unidad cripto en sourceCurrency (USD×tipo fiat). En modo cripto: USD por unidad. baseAmount = redondeoARRIBA(sourceAmount / rate, displayDecimals) // o redondeoARRIBA(amount, displayDecimals) comisionCli = feePaidBy == CUSTOMER ? redondeoARRIBA(baseAmount × feePercent / 100, displayDecimals) : 0 feeRedCli = networkFeePaidBy == CUSTOMER ? networkFee : 0 amount = baseAmount + comisionCli + feeRedCli // lo que debe pagar el cliente pendingAmount = max(amount − amountReceived, 0) ``` - `displayDecimals` por moneda: BTC/LTC/ETH/BNB 8, TRX 6, stablecoins 4 (ver [Monedas](https://orvenpay.com/docs#monedas)). Todo redondeo de importes a cobrar es **hacia arriba**. - `networkFee` (fee de red de la factura) = max(suelo de la moneda, coste real estimado del barrido × 1,40). Si la estimación tiene más de 30 min, max(suelo, coste × 2). - El tipo de cambio se fija en el momento en que se fija la moneda (al crear o al elegirla) y **no cambia** durante la vida de la factura. - La tasa cripto/USD viene de oráculos de mercado; los tipos fiat se actualizan cada hora y se aceptan hasta 6 h. Si no hay tasa fresca: `503 RATES_UNAVAILABLE` (reintenta en unos minutos). - Mínimos: el importe base en USD debe ser ≥ el mínimo de la moneda (BTC 5 USD, LTC 1 USD, TRX 1 USD, USDT_TRC20 5 USD, ETH 3 USD, USDT_ERC20 3 USD, USDC_ERC20 3 USD, BNB 1 USD, USDT_BEP20 1 USD) y ≥ 3 × fee de red. Si no: `400 AMOUNT_TOO_SMALL` con `details: { currency, minAmount, minUsd }`. Sin moneda fijada basta con que alguna moneda disponible lo cumpla (en la página de pago sólo se ofrecen las que lo cumplen). - Máximo por factura: 10.000.000 USD (`400 AMOUNT_TOO_LARGE`). ### Idempotencia (orderNumber) - `orderNumber` es único por tienda. Repetir `POST /v1/invoices` con el mismo `orderNumber` y **los mismos datos de importe** (`sourceAmount`+`sourceCurrency`, `currency`, `amount`) devuelve la factura existente con `200 OK` y `"existing": true` (no se crea otra ni se consume otra dirección). Esto hace seguros los reintentos por timeout. - Mismo `orderNumber` con importe o moneda distintos: `409 DUPLICATE_ORDER`. Si el pedido cambió, usa un `orderNumber` nuevo (p. ej. `10245-2`). - La factura devuelta puede estar en cualquier estado (incluso `EXPIRED`): comprueba `status` antes de redirigir. Para un nuevo intento de pago tras expirar, crea otra factura con otro `orderNumber`. - Excepción: con `sourceCurrency` cripto + `sourceAmount`, el reintento devuelve `409` en lugar de la factura existente; para importes en cripto usa `currency` + `amount`. - En la API Gateway el duplicado devuelve el error `107` salvo que envíes `return_existing=1`. ### Respuesta: objeto Invoice *Campos de Invoice* | Campo | Tipo | Descripción | | --- | --- | --- | | `id` | string | Id de la factura (cuid). Úsalo para consultas y como clave de idempotencia de tus webhooks. | | `storeId` / `storeName` | string / null | Tienda (el nombre es `null` en la API). | | `source` | `API` · `GATEWAY` · `DASHBOARD` | Origen. Decide el formato de webhook: `GATEWAY` usa callbacks `verify_hash`; el resto, webhooks OrvenPay. | | `orderNumber` / `orderName` / `description` / `email` | string / null | Lo que enviaste. | | `status` | enum | `NEW`, `PENDING`, `COMPLETED`, `MISMATCH`, `EXPIRED`, `CANCELLED`, `ERROR` (ver [Estados](https://orvenpay.com/docs#estados)). | | `overpaid` | boolean | `true` si se recibió más de `amount × 1,005`. | | `sourceCurrency` / `sourceAmount` | string / null | Precio original (null en modo cripto). | | `currency` / `symbol` / `network` | string / null | Moneda fijada (null hasta que se elige). | | `rate` | decimal / null | Precio de 1 unidad cripto en `sourceCurrency` (o USD en modo cripto). | | `amount` | decimal / null | Total a pagar por el cliente en cripto. | | `baseAmount` | decimal / null | Importe base (sin recargos al cliente). | | `amountReceived` | decimal | Suma de pagos detectados (confirmados o no). | | `pendingAmount` | decimal / null | `max(amount − amountReceived, 0)`. | | `address` | string / null | Dirección de depósito única. **Sólo White Label**; en tiendas estándar es siempre `null` (el cliente la ve en la página de pago). | | `derivationIndex` | integer / null | Índice interno de derivación (informativo). | | `confirmations` / `requiredConfirmations` | integer | Confirmaciones actuales (mínimo entre pagos) y exigidas por la red. | | `allowedCurrencies` | string[] | Restricción enviada (vacío = todas). | | `feePercent` | decimal | Comisión aplicada a esta factura. | | `commission` | decimal / null | Comisión estimada (o la recargada al cliente). | | `feeAmount` | decimal | Comisión realmente cobrada (se fija al acreditar). | | `feeAmountUsd` | decimal | Comisión estimada en USD al crear. | | `feePaidBy` / `networkFeePaidBy` | `MERCHANT` · `CUSTOMER` | Quién paga cada concepto (copiado de la tienda al crear). | | `networkFee` | decimal / null | Fee de red de la factura. | | `networkFeeCharged` | decimal | Fee de red realmente descontado al acreditar. | | `callbackUrl` / `successCallbackUrl` / `failCallbackUrl` | string / null | URLs de notificación (las dos últimas sólo vía API Gateway). | | `successUrl` / `failUrl` | string / null | URLs de retorno del cliente. | | `metadata` | object / null | Lo que enviaste. | | `invoiceUrl` | string | Página de pago alojada: `https://orvenpay.com/invoice/{id}`. | | `whiteLabel` | boolean | `true` si la tienda tiene el plan White Label (la respuesta incluye datos de pago completos). | | `expiresAt` / `paidAt` / `createdAt` / `updatedAt` | ISO 8601 / null | Fechas UTC. `paidAt` se fija al completar. | | `payments` | Payment[] | Sólo en crear/consultar/cancelar (no en el listado). Ver abajo. | #### Objeto Payment **Payment** ```json { "id": "cmhx3w1zt0006l70g0p4n2kxe", "invoiceId": "cmhx3v8qk0004l70gq2b9d7ma", "currency": "USDT_TRC20", "txHash": "5e1c3a9f0b7d24e6c8a1f3b5d7e9c0a2b4d6f8e0a1c3e5b7d9f1a3c5e7b9d1f3", "logIndex": 0, "fromAddress": "TBf1Wm7cQ2zR8pLx4NvJ6sD3kHy9aEtU5o", "toAddress": "TQ7m4pVb2Xr9KsN3wJd8YfHc6LtUe1gAzR", "amount": "50.91", "confirmations": 19, "blockNumber": "67012345", "status": "CONFIRMED", "explorerUrl": "https://tronscan.org/#/transaction/5e1c3a9f0b7d24e6c8a1f3b5d7e9c0a2b4d6f8e0a1c3e5b7d9f1a3c5e7b9d1f3", "createdAt": "2026-11-08T14:08:02.000Z", "updatedAt": "2026-11-08T14:09:41.000Z" } ``` En tiendas estándar `toAddress` es `null` (igual que `address`). `status` de un pago: `DETECTED` (visto, aún sin confirmaciones suficientes), `CONFIRMED` (final) u `ORPHANED` (invalidado por una reorganización de la cadena; no cuenta). `blockNumber` es string (entero grande). ### Expiración - `expiresAt = creación + expireMin`. La cuenta atrás de la página de pago usa `expiresAt` y la hora del servidor. - Si al expirar hay un pago detectado aún sin confirmar, la factura sigue `PENDING` hasta 2 h más (desde la expiración o el último pago detectado) esperando confirmaciones. - Pagos tardíos: un pago que llega a una factura `EXPIRED` dentro de las 24 h siguientes a `expiresAt` se procesa igualmente (la factura puede pasar a `COMPLETED` o `MISMATCH` y recibirás el webhook correspondiente). - Al expirar sin pago: estado `EXPIRED` y webhook `invoice.expired`. El cliente es redirigido a `failUrl` desde la página de pago. ### Consultar `GET /v1/invoices/{id}` · auth: `X-Api-Key` Devuelve `{ "invoice": Invoice }` con `payments`. `404 NOT_FOUND` si no existe o es de otra tienda. Úsalo como respaldo de los webhooks (p. ej. al volver el cliente a `successUrl`), nunca en bucles agresivos: respeta el rate limit. ### Listar y paginar `GET /v1/invoices?limit=20&cursor=&status=&orderNumber=` · auth: `X-Api-Key` *Parámetros de query* | Parámetro | Tipo | Descripción | | --- | --- | --- | | `limit` | integer 1-100 | Por defecto 20. | | `cursor` | string | Valor `nextCursor` de la página anterior. | | `status` | string | Uno o varios estados separados por coma: `COMPLETED,MISMATCH`. Estado desconocido: `400 VALIDATION_ERROR`. | | `orderNumber` | string | Coincidencia exacta. | Respuesta: `{ "items": Invoice[], "nextCursor": string | null }`, ordenado por fecha de creación descendente. Repite con `cursor=nextCursor` hasta que sea `null`. Los elementos del listado no incluyen `payments`. ### Cancelar `POST /v1/invoices/{id}/cancel` · auth: `X-Api-Key` Sólo facturas en `NEW` (sin pagos detectados). Devuelve `{ "invoice": Invoice }` con `status: "CANCELLED"` y envía el webhook `invoice.cancelled`. En otro estado: `409 INVALID_STATUS`. Un pago que llegue después a una factura cancelada **no se acredita automáticamente** (queda para revisión de soporte): cancela sólo si estás seguro de que el cliente no va a pagar. **cURL** ```bash curl https://api.orvenpay.com/v1/invoices/cmhx3v8qk0004l70gq2b9d7ma -H "X-Api-Key: $ORVENPAY_API_KEY" curl "https://api.orvenpay.com/v1/invoices?status=COMPLETED,MISMATCH&limit=50" -H "X-Api-Key: $ORVENPAY_API_KEY" curl -X POST https://api.orvenpay.com/v1/invoices/cmhx3v8qk0004l70gq2b9d7ma/cancel -H "X-Api-Key: $ORVENPAY_API_KEY" ``` **Node.js** ```js const API = process.env.ORVENPAY_API_URL; const headers = { 'X-Api-Key': process.env.ORVENPAY_API_KEY }; // Recorre todas las facturas completadas let cursor = null; do { const qs = new URLSearchParams({ status: 'COMPLETED', limit: '100', ...(cursor ? { cursor } : {}) }); const page = await fetch(API + '/v1/invoices?' + qs, { headers }).then((r) => r.json()); for (const inv of page.items) console.log(inv.orderNumber, inv.amountReceived, inv.currency); cursor = page.nextCursor; } while (cursor); ``` --- ## 6. Página de pago alojada *Qué hace la página de pago alojada (invoiceUrl) y endpoints públicos del checkout (estado, elección de moneda, SSE).* Redirige al cliente a `invoiceUrl` (`https://orvenpay.com/invoice/cmhx3v8qk0004l70gq2b9d7ma`). La página: - Muestra las monedas disponibles con el importe estimado de cada una (sólo las que cumplen el mínimo) si la factura no tiene moneda. - Muestra importe exacto, red, dirección, QR, desglose (base, comisión y fee de red si los paga el cliente), cuenta atrás y avisos de red (p. ej. "envía sólo USDT por TRC20"). - Se actualiza en tiempo real (Server-Sent Events) con confirmaciones y estado; si el pago parcial está dentro de la tolerancia no pide más al cliente. - Al completarse redirige a `successUrl`; al expirar o cancelarse ofrece volver a `failUrl`. - Español o inglés según el navegador; tema claro u oscuro. - El QR codifica BIP21 (`bitcoin:`/`litecoin:` con `amount`), EIP-681 para ETH/BNB nativos y `tron:` para TRX. Para **tokens** (USDT, USDC) el QR lleva sólo la dirección (algunas wallets enviarían al contrato); para tokens EVM hay además un enlace profundo de wallet. ### Endpoints públicos del checkout Base `https://api.orvenpay.com/public`, sin autenticación y pensados para el navegador del cliente (CORS restringido a los orígenes de la plataforma). Para tu backend usa siempre `/v1`. *Endpoints públicos* | Endpoint | Rate limit | Descripción | | --- | --- | --- | | `GET /public/config` | 60/min por IP | Marca (`brandName`), `registrationOpen`, `maintenanceMode`, `withdrawalsPaused`, `pushPublicKey`, `feePercent`, `whiteLabelFeePercent` y `currencies[]` (`code`, `name`, `symbol`, `network`, `icon`, `networkIcon`, `withdrawalFee`). | | `GET /public/invoices/{id}` | 120/min por IP | `{ invoice }` con los campos seguros para el cliente: `whiteLabel`, `branding` (`{ name, color, logoUrl }` sólo en tiendas White Label; `null` en las estándar), estado, importes, desglose, `minAcceptedAmount`, `withinTolerance`, `paymentUri`, `walletUri`, confirmaciones, `expiresAt`, `serverTime`, `availableCurrencies[]` (con `amountEstimate`) y `payments[]`. Id inválido o inexistente: `404 INVOICE_NOT_FOUND`. | | `POST /public/invoices/{id}/select-currency` | 20/min por IP | Cuerpo `{"currency":"USDT_TRC20"}`. Fija moneda, importe y dirección. Errores: `409 CURRENCY_ALREADY_SELECTED` (ya tenía otra), `409 INVALID_STATUS`, `410 INVOICE_EXPIRED`, `400 CURRENCY_NOT_AVAILABLE`, `400 AMOUNT_TOO_SMALL`, `403 STORE_INACTIVE`. Repetirlo con la misma moneda es idempotente. | | `GET /public/stores/{id}/logo` | 120/min por IP | Logo de una tienda White Label (la URL llega en `branding.logoUrl`). `404 LOGO_NOT_FOUND` si la tienda no es White Label o no tiene logo. | | `GET /public/invoices/{id}/events` | 30/min por IP; máx. 3 conexiones por IP y 1 por factura | SSE (`text/event-stream`): envía `event: invoice` con el mismo objeto que `GET /public/invoices/{id}` cada vez que cambia (sondeo cada 3 s), comentarios `: ping` y cierra al llegar a un estado final o a los 30 min. Excedido: `429 TOO_MANY_STREAMS`. | --- ## 7. White Label *Plan por tienda: datos de pago completos por API (dirección, importe, QR), white_label en /shops y tu marca en el checkout; comisión 1 % por defecto.* White Label es un plan **por tienda**. Las tiendas estándar pagan la comisión normal (0.5 % por defecto) y su API devuelve sólo la URL de la página de pago alojada. Las tiendas White Label tienen su propia comisión (1 % por defecto) y obtienen los datos completos del pago para mostrarlo dentro de tu web, app o bot, además de tu marca en la página de pago alojada. *Diferencias por plan (por tienda)* | | Estándar | White Label | | --- | --- | --- | | Comisión por defecto | 0.5 % | 1 % | | Activación | Inmediata | Se solicita desde el panel de la tienda y lo aprueba la plataforma | | `POST /v1/invoices` | `address: null` siempre; el cliente paga en `invoiceUrl` | Datos completos: `address`, `amount`, `pendingAmount`, `rate`, confirmaciones... | | `invoices/new` (API Gateway) | Sólo `txn_id`, `invoice_url`, `invoice_total_sum` | `txn_id`, `invoice_url`, `amount`, `pending_amount`, `wallet_hash`, `psys_cid`, `currency`, `status`, `source_currency`, `source_rate`, `expire_utc`, `expected_confirmations`, `qr_code`, `verify_hash`, `invoice_commission`, `invoice_sum`, `invoice_total_sum` | | `GET /gateway/api/v1/shops` | `white_label: false` | `white_label: true` (los plugins muestran el pago dentro de la tienda) | | Campo `whiteLabel` de la factura | `false` | `true` | | Página de pago alojada | Marca de la plataforma | Tu marca: nombre, color y logo | | Webhooks, saldos, retiros | Iguales | Iguales | - Comisiones vigentes publicadas en `GET https://api.orvenpay.com/public/config` → `feePercent` y `whiteLabelFeePercent` (pueden existir acuerdos personalizados; la de cada factura está en `invoice.feePercent`). - Para mostrar el pago en tu propia interfaz (White Label) **fija la moneda al crear** (`currency`): así la respuesta trae `address` y `amount`. Si la factura es fiat sin moneda, puedes fijarla después con `POST /public/invoices/{id}/select-currency` y leer de nuevo `GET /v1/invoices/{id}`. - Muestra siempre importe EXACTO, moneda y **red**, la dirección con botón de copiar y la cuenta atrás hasta `expiresAt`. Recomienda enviar un poco antes de expirar y avisa de que un envío por otra red no se acredita. - Aunque uses tu propia interfaz, `invoiceUrl` sigue funcionando como alternativa. - La comisión White Label puede fijarse por tienda (override acordado con la plataforma); la aplicada a cada factura queda en `invoice.feePercent` y en `invoice_commission_percentage` de `GET /gateway/api/v1/currencies`. - Los webhooks (OrvenPay y callbacks de la API Gateway) son iguales en ambos planes. **White Label: POST /v1/invoices con currency** ```json { "invoice": { "id": "cmhx4b2rn0009l70g8w1c5e3ds", "storeId": "cmhx2a1bc0001l70g5s6t7u8v", "storeName": null, "source": "API", "orderNumber": "10246", "orderName": "Plan Pro mensual", "description": null, "email": null, "status": "NEW", "overpaid": false, "sourceCurrency": "USD", "sourceAmount": "49.9", "currency": "USDT_TRC20", "symbol": "USDT", "network": "TRC20", "rate": "0.9998", "amount": "50.91", "baseAmount": "49.91", "amountReceived": "0", "pendingAmount": "50.91", "address": "TQ7m4pVb2Xr9KsN3wJd8YfHc6LtUe1gAzR", "derivationIndex": 1532, "confirmations": 0, "requiredConfirmations": 19, "allowedCurrencies": [], "feePercent": "1", "commission": "0.4991", "feeAmount": "0", "feeAmountUsd": "0.499", "feePaidBy": "MERCHANT", "networkFee": "1", "networkFeePaidBy": "CUSTOMER", "networkFeeCharged": "0", "callbackUrl": null, "successCallbackUrl": null, "failCallbackUrl": null, "successUrl": null, "failUrl": null, "metadata": null, "invoiceUrl": "https://orvenpay.com/invoice/cmhx4b2rn0009l70g8w1c5e3ds", "whiteLabel": true, "expiresAt": "2026-11-08T15:00:00.000Z", "paidAt": null, "createdAt": "2026-11-08T14:00:00.000Z", "updatedAt": "2026-11-08T14:00:00.000Z", "payments": [] }, "existing": false } ``` > **Cómo activarlo.** Solicítalo en *Panel → Tiendas → (tu tienda) → White Label*; la plataforma revisa y aprueba la solicitud. Allí configuras también el nombre, el color y el logo (PNG, JPEG o WebP, máx. 256 KB); la marca se muestra en el checkout sólo mientras el plan está activo, sin la marca de la plataforma. --- ## 8. Estados y ciclo de vida *Máquina de estados de la factura, eventos, tolerancia de pago insuficiente, sobrepagos, pagos tardíos y casos límite.* **Máquina de estados** ```text pago detectado confirmado >= amount×(1−0,5%) NEW ───────────────────────────────> PENDING ─────────────────────────────> COMPLETED (final, acreditada) │ \ │ \ expira con pago parcial │ \ (pago ya confirmado al verse) │ \ confirmado (tras 2 h de gracia │ └──────────────────────────────────┼─────> si quedaban pagos sin confirmar) │ │ MISMATCH (final, acreditada) │ expira sin pagos │ todos los pagos invalidados ▼ ▼ por reorganización EXPIRED <──────────────────────────────── NEW / EXPIRED │ pago tardío (≤ 24 h tras expiresAt) └──────────────> COMPLETED o MISMATCH NEW ──(cancelar por API/panel)──> CANCELLED (final) ERROR (final, incidencia interna: contacta soporte) ``` *Estados* | Estado | Final | Webhook | Significado | Qué hacer | | --- | --- | --- | --- | --- | | `NEW` | no | - | Creada; sin moneda elegida o sin pagos detectados. | Esperar. | | `PENDING` | no | `invoice.pending` | Pago detectado en la cadena, esperando confirmaciones. | Mostrar "pago recibido, confirmando". **No entregar todavía.** | | `COMPLETED` | sí | `invoice.completed` | Recibido y confirmado ≥ importe con tolerancia. Acreditado a tu saldo (menos comisión y fee de red). Puede llevar `overpaid: true`. | Entregar el pedido. | | `MISMATCH` | sí | `invoice.mismatch` | Expiró con un pago parcial confirmado (por debajo de la tolerancia). Lo recibido se acredita igualmente. | Decidir: entrega parcial, pedir la diferencia con una factura nueva o reembolsar fuera de la plataforma. Usa `amountReceived`. | | `EXPIRED` | sí* | `invoice.expired` | Venció sin pago confirmado. *Puede pasar a `COMPLETED`/`MISMATCH` con un pago tardío. | Liberar stock/reserva; ofrecer una factura nueva. | | `CANCELLED` | sí | `invoice.cancelled` | Cancelada antes de recibir pagos. | Nada. | | `ERROR` | sí | - | Incidencia interna. | Contactar soporte con el id. | - **No asumas el orden**: si un pago se detecta ya confirmado, la factura puede pasar de `NEW` a `COMPLETED` sin `invoice.pending`. Si un pago se invalida por una reorganización, una factura `PENDING` puede volver a `NEW` (sin webhook). - **Tolerancia de pago insuficiente**: 0.5 %. Con lo confirmado ≥ `amount × (1 − 0,005)` la factura se completa (la página pública lo expone como `minAcceptedAmount`). - **Sobrepago**: si lo recibido supera `amount × 1,005`, la factura se completa con `overpaid: true`. Todo lo recibido y confirmado hasta completar se acredita. - **Varios pagos**: se suman. Un pago que se confirme DESPUÉS de que la factura sea final (`COMPLETED`/`MISMATCH`) no se acredita automáticamente: queda registrado para revisión de soporte. - **Red o moneda equivocada** (p. ej. USDT por ERC20 a una factura TRC20, o TRX nativo a una factura USDT_TRC20): no se acredita automáticamente; queda como pago huérfano para revisión de soporte. - Los estados finales no cambian salvo `EXPIRED` con un pago tardío. Trata `COMPLETED` y `MISMATCH` como "dinero acreditado". --- ## 9. Webhooks *Eventos, payload, cabeceras, verificación HMAC-SHA256 (Node.js, Python, PHP, Go), cuerpo crudo, reintentos, orden, idempotencia y protección anti-replay.* Cuando una factura creada por la API nativa (o desde el panel) cambia de estado, OrvenPay envía un `POST` JSON a su `callbackUrl` (o a la `callbackUrl` de la tienda si la factura no tiene una). Las facturas creadas por la API Gateway usan otro formato (callbacks con `verify_hash`, ver [API Gateway](https://orvenpay.com/docs#api-gateway)). Sin URL configurada no se envía nada. *Eventos* | Evento | Cuándo | data.status | | --- | --- | --- | | `invoice.pending` | Se detectó un pago; faltan confirmaciones. | `PENDING` | | `invoice.completed` | Pagada y confirmada (también tras un pago tardío). **Único evento que debe disparar la entrega.** | `COMPLETED` | | `invoice.mismatch` | Expiró con pago parcial confirmado (acreditado). | `MISMATCH` | | `invoice.expired` | Expiró sin pago confirmado. | `EXPIRED` | | `invoice.cancelled` | Cancelada por API o panel. | `CANCELLED` | ### Petición **Ejemplo de entrega** ```http POST /api/orvenpay/webhook HTTP/1.1 Host: mitienda.com Content-Type: application/json User-Agent: OrvenPay-Webhooks/1.0 X-OrvenPay-Event: invoice.completed X-OrvenPay-Delivery: cmhx3x9ab0007l70gh8j2k4lm X-OrvenPay-Signature: t=1794146981,v1=9c1f0e5a7b3d2c4e6f8a0b1c3d5e7f9a2b4c6d8e0f1a3b5c7d9e1f3a5b7c9d0e { "event": "invoice.completed", "createdAt": "2026-11-08T14:09:41.000Z", "data": { "id": "cmhx3v8qk0004l70gq2b9d7ma", "storeId": "cmhx2a1bc0001l70g5s6t7u8v", "orderNumber": "10245", "orderName": "Plan Pro mensual", "status": "COMPLETED", "overpaid": false, "currency": "USDT_TRC20", "network": "TRC20", "amount": "50.91", "amountReceived": "50.91", "feeAmount": "0.25455", "address": "TQ7m4pVb2Xr9KsN3wJd8YfHc6LtUe1gAzR", "sourceCurrency": "USD", "sourceAmount": "49.9", "rate": "0.9998", "confirmations": 19, "requiredConfirmations": 19, "metadata": { "userId": "u_981", "plan": "pro" }, "paidAt": "2026-11-08T14:09:41.000Z", "expiresAt": "2026-11-08T15:00:00.000Z" } } ``` *Cabeceras* | Cabecera | Valor | | --- | --- | | `Content-Type` | `application/json` | | `User-Agent` | `OrvenPay-Webhooks/1.0` | | `X-OrvenPay-Event` | Nombre del evento (igual que `event` del cuerpo). | | `X-OrvenPay-Delivery` | Id de la entrega. Es el mismo en todos los reintentos de esa entrega; un reenvío manual desde el panel crea una entrega nueva con otro id. | | `X-OrvenPay-Signature` | `t=,v1=` donde `v1 = HMAC_SHA256(secreto_webhooks, t + "." + cuerpo_crudo)` en hexadecimal minúsculas (64 caracteres). `t` es la hora de ESTE intento. | *Cuerpo: campos de data* | Campo | Tipo | Notas | | --- | --- | --- | | `event` | string | Evento. | | `createdAt` | ISO 8601 | Momento del cambio de estado (estable entre reintentos). | | `data.id` | string | Id de la factura. | | `data.storeId`, `data.orderNumber`, `data.orderName` | string | Para localizar tu pedido. | | `data.status`, `data.overpaid` | enum, boolean | Estado tras el cambio. | | `data.currency`, `data.network` | string / null | Moneda y red. | | `data.amount` | decimal | Total esperado (`"0"` si aún no había moneda). | | `data.amountReceived` | decimal | Total recibido. | | `data.feeAmount` | decimal | Comisión cobrada (se fija al acreditar). | | `data.address` | string / null | Dirección de depósito (`null` si no hay moneda). No la necesitas para conciliar: usa `id` u `orderNumber`. | | `data.sourceCurrency`, `data.sourceAmount`, `data.rate` | string / decimal | Precio original y tipo aplicado (`"0"` si no aplica). | | `data.confirmations`, `data.requiredConfirmations` | integer | Confirmaciones. | | `data.metadata` | object / null | Lo que enviaste al crear. | | `data.paidAt`, `data.expiresAt` | ISO 8601 / null | Fechas. | ### Verificar la firma 1. Lee el **cuerpo crudo** exactamente como llegó (bytes/string), ANTES de parsear JSON. Re-serializar el JSON cambia los bytes y rompe la firma. 2. Separa la cabecera `X-OrvenPay-Signature` por comas en pares `clave=valor`; obtén `t` y `v1`. 3. Rechaza si `|ahora − t| > 300` segundos (protección anti-replay; sincroniza el reloj con NTP). 4. Calcula `HMAC-SHA256(secreto, t + "." + cuerpo_crudo)` en hex y compáralo con `v1` en **tiempo constante**. 5. Si no coincide responde `400`/`401` y no proceses nada. **Node.js (Express)** ```js import crypto from 'node:crypto'; import express from 'express'; const app = express(); const SECRET = process.env.ORVENPAY_WEBHOOK_SECRET; // whsec_... export function verifyOrvenPaySignature(rawBody, header, secret = SECRET, toleranceSec = 300) { if (!header) return false; const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('='))); const t = Number(parts.t); if (!Number.isInteger(t) || !/^[0-9a-f]{64}$/.test(parts.v1 || '')) return false; if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; const expected = crypto.createHmac('sha256', secret).update(t + '.' + rawBody).digest('hex'); return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(parts.v1, 'hex')); } // express.raw: el cuerpo llega como Buffer SIN parsear (imprescindible) app.post('/api/orvenpay/webhook', express.raw({ type: 'application/json' }), async (req, res) => { const raw = req.body.toString('utf8'); if (!verifyOrvenPaySignature(raw, req.get('X-OrvenPay-Signature'))) return res.status(400).send('bad signature'); const evt = JSON.parse(raw); // Idempotencia: procesa cada (factura, evento) una sola vez if (evt.event === 'invoice.completed') { await markOrderPaid(evt.data.orderNumber, evt.data.id, evt.data.amountReceived, evt.data.currency); } res.sendStatus(200); // responde en < 10 s; tareas largas, a una cola }); ``` **Next.js (App Router)** ```ts // app/api/orvenpay/webhook/route.ts import { createHmac, timingSafeEqual } from 'node:crypto'; export const runtime = 'nodejs'; export async function POST(req: Request) { const raw = await req.text(); // cuerpo crudo const header = req.headers.get('x-orvenpay-signature') ?? ''; const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('='))); const t = Number(parts.t); const v1 = String(parts.v1 ?? ''); const expected = createHmac('sha256', process.env.ORVENPAY_WEBHOOK_SECRET!).update(t + '.' + raw).digest('hex'); const valid = /^[0-9a-f]{64}$/.test(v1) && Math.abs(Date.now() / 1000 - t) <= 300 && timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(v1, 'hex')); if (!valid) return new Response('bad signature', { status: 400 }); const evt = JSON.parse(raw) as { event: string; data: { id: string; orderNumber: string; status: string } }; if (evt.event === 'invoice.completed') { // await markOrderPaid(evt.data.orderNumber) (idempotente) } return new Response('ok'); } ``` **Python** ```python import hashlib, hmac, json, os, time from flask import Flask, request, abort app = Flask(__name__) SECRET = os.environ["ORVENPAY_WEBHOOK_SECRET"].encode() def verify_orvenpay_signature(raw: bytes, header: str, tolerance: int = 300) -> bool: try: parts = dict(p.strip().split("=", 1) for p in header.split(",")) t = int(parts["t"]) v1 = parts["v1"] except (KeyError, ValueError, AttributeError): return False if abs(time.time() - t) > tolerance: return False expected = hmac.new(SECRET, str(t).encode() + b"." + raw, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, v1) @app.post("/api/orvenpay/webhook") def orvenpay_webhook(): raw = request.get_data() # bytes crudos, sin parsear if not verify_orvenpay_signature(raw, request.headers.get("X-OrvenPay-Signature", "")): abort(400) evt = json.loads(raw) if evt["event"] == "invoice.completed": mark_order_paid(evt["data"]["orderNumber"]) # idempotente return "ok", 200 ``` **PHP** ```php $tolerance) { return false; } $expected = hash_hmac('sha256', $t . '.' . $raw, $secret); return hash_equals($expected, $parts['v1']); } $raw = file_get_contents('php://input'); // cuerpo crudo $sig = $_SERVER['HTTP_X_ORVENPAY_SIGNATURE'] ?? ''; if (!verify_orvenpay_signature($raw, $sig, getenv('ORVENPAY_WEBHOOK_SECRET'))) { http_response_code(400); exit('bad signature'); } $evt = json_decode($raw, true); if ($evt['event'] === 'invoice.completed') { mark_order_paid($evt['data']['orderNumber']); // idempotente } http_response_code(200); echo 'ok'; ``` **Go** ```go package webhook import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "math" "net/http" "os" "strconv" "strings" "time" ) func VerifyOrvenPaySignature(raw []byte, header, secret string, tolerance float64) bool { parts := map[string]string{} for _, p := range strings.Split(header, ",") { kv := strings.SplitN(strings.TrimSpace(p), "=", 2) if len(kv) == 2 { parts[kv[0]] = kv[1] } } t, err := strconv.ParseInt(parts["t"], 10, 64) if err != nil || math.Abs(float64(time.Now().Unix()-t)) > tolerance { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(strconv.FormatInt(t, 10) + ".")) mac.Write(raw) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(parts["v1"])) } func Handler(w http.ResponseWriter, r *http.Request) { raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil || !VerifyOrvenPaySignature(raw, r.Header.Get("X-OrvenPay-Signature"), os.Getenv("ORVENPAY_WEBHOOK_SECRET"), 300) { http.Error(w, "bad signature", http.StatusBadRequest) return } var evt struct { Event string Data struct { ID string OrderNumber string Status string } } if err := json.Unmarshal(raw, &evt); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } if evt.Event == "invoice.completed" { // markOrderPaid(evt.Data.OrderNumber) (idempotente) } w.WriteHeader(http.StatusOK) } ``` > **El error más común: el cuerpo ya parseado.** Express con `express.json()`, Next.js con `req.json()`, Laravel con `$request->all()` o FastAPI con un modelo Pydantic te dan el JSON ya parseado. Firma SIEMPRE sobre el cuerpo crudo: `express.raw()`, `await req.text()`, `$request->getContent()` / `php://input`, `await request.body()`. ### Reintentos, timeouts y orden - Se considera entregado con cualquier respuesta **2xx** recibida en menos de **10 s**. Cualquier otra cosa (3xx, 4xx, 5xx, timeout, error de red/TLS) es un fallo. **No se siguen redirecciones**: usa la URL final. - Hasta **8 intentos** por entrega durante unas 43 h; después queda `FAILED` y puedes reenviarla desde *Panel → Facturas → (factura) → Webhooks*. - Destinos internos (IPs privadas, localhost) se bloquean y fallan de forma permanente (protección SSRF): la URL debe resolver a una IP pública. - **Orden garantizado por factura y URL**: el siguiente evento de una factura no se envía hasta que el anterior se entregue o falle definitivamente. Así nunca recibes `expired` antes que `pending`. - Entregas *al menos una vez*: puedes recibir el mismo evento más de una vez (reintento tras un timeout tuyo, reenvío manual). Diseña el manejador idempotente. *Calendario de reintentos* | Intento | Cuándo | | --- | --- | | 1 | inmediato (en cuanto se produce el evento) | | 2 | 1 min después del intento 1 | | 3 | 5 min después del intento 2 | | 4 | 15 min después del intento 3 | | 5 | 1 h después del intento 4 | | 6 | 6 h después del intento 5 | | 7 | 12 h después del intento 6 | | 8 | 24 h después del intento 7 | ### Manejo idempotente recomendado 1. Verifica la firma y el timestamp. 2. Busca tu pedido por `data.orderNumber` (o guarda `data.id` al crear la factura y busca por él). Ignora facturas que no reconozcas (responde 200). 3. Aplica la transición sólo si avanza: guarda en tu BD el estado procesado por factura; si ya procesaste ese `(data.id, event)` responde 200 sin repetir efectos. 4. Entrega el pedido SÓLO con `invoice.completed` (y decide qué hacer con `invoice.mismatch`). Comprueba, si quieres, que `data.currency`/`data.amountReceived` son coherentes con tu pedido. 5. Responde 200 en < 10 s y procesa tareas lentas (emails, provisión) en segundo plano. 6. Opcional: ante cualquier duda, confirma con `GET /v1/invoices/{id}` (fuente de verdad). --- ## 10. API Gateway *Capa compatible con plugins y SDK de pasarelas (api_key + verify_hash HMAC-SHA1): endpoints, parámetros, respuestas estándar/White Label, callbacks, verificación y códigos de error.* Además de la API v1, OrvenPay expone la **API Gateway**: el formato `api_key` + `verify_hash` (HMAC-SHA1) que usan muchos plugins de e-commerce (WooCommerce, PrestaShop, WHMCS...) y SDK de pasarelas cripto. Configura en tu plugin la **URL base** `https://api.orvenpay.com/gateway` y una API key de tu tienda; el plugin llamará a `https://api.orvenpay.com/gateway/api/v1/...`. Para integraciones nuevas recomendamos la API nativa (/v1). - URL base para plugins/SDK: `https://api.orvenpay.com/gateway`. Endpoints reales: `https://api.orvenpay.com/gateway/api/v1/`. - Autenticación: `api_key` en la query string (habitual), en el cuerpo (form o JSON) o cabecera `X-Api-Key`. Misma API key que la API nativa. - Parámetros: se aceptan en la query string y/o en el cuerpo (`application/x-www-form-urlencoded` o JSON); si un parámetro está en ambos, gana el cuerpo. - Respuesta: `{ "status": "success", "data": {...} }`. Importes como **string**, timestamps Unix (segundos) como número. - Error: `{ "status": "error", "data": { "name", "message", "code" } }` (ver tabla de códigos abajo). En errores de validación, `message` es un JSON con `{"campo":"motivo"}` (los SDK PHP hacen `json_decode(message)`). - Rate limit: 120 peticiones/min por API key (429 con `Retry-After`). - Las facturas creadas aquí tienen `source: "GATEWAY"` y notifican con **callbacks `verify_hash`** (no con webhooks OrvenPay). ### Crear factura: invoices/new `GET|POST /gateway/api/v1/invoices/new` · auth: `api_key` *Parámetros de invoices/new* | Parámetro | Oblig. | Descripción | | --- | --- | --- | | `api_key` | sí | API key de la tienda. | | `order_number` | sí | Id de pedido, 1-128 caracteres, único por tienda. | | `order_name` | no | Máx. 255. Concepto. | | `description` | no | Máx. 1000. | | `source_currency` | no | Fiat del precio (por defecto `USD` si hay `source_amount`). Un código cripto convierte `source_amount` en importe cripto. | | `source_amount` | uno de los dos | Precio en `source_currency` (decimal). Si está presente, `amount` se ignora. | | `amount` | uno de los dos | Importe en cripto; requiere `currency`/`psys_cid`. | | `currency` o `psys_cid` | no* | Moneda (psys_cid o código interno). Sin ella, el cliente elige en la página de pago. *Obligatoria con `amount`. | | `allowed_psys_cids` | no | Lista separada por comas, p. ej. `USDT_TRX,USDT_BSC,BTC`. | | `callback_url` | no | URL de callbacks (todos los estados). Si contiene `json=true` en la query, el callback se envía como JSON; si no, como formulario. No puede apuntar a redes internas. | | `success_callback_url` | no | Recibe además el callback de `completed`. | | `fail_callback_url` | no | Recibe además los callbacks de `expired`, `cancelled` y pago parcial expirado. | | `success_invoice_url` (alias `success_url`) | no | Retorno del cliente tras pagar. | | `fail_invoice_url` (alias `cancel_url`, `fail_url`) | no | Retorno si falla/expira. | | `email` | no | Máx. 254. | | `expire_min` | no | Minutos de validez (entero, máximo 1440). | | `return_existing` | no | `1`/`true`: si el `order_number` ya existe, devuelve esa factura en lugar del error 107. | | `redirect_to_invoice` | no | `1`/`true`: responde `302` con `Location` = página de pago (útil en enlaces directos). | | `language`, `plugin`, `version` | no | Aceptados e ignorados. | **cURL** ```bash curl "https://api.orvenpay.com/gateway/api/v1/invoices/new?api_key=$ORVENPAY_API_KEY&order_number=10245&order_name=Plan%20Pro&source_currency=USD&source_amount=49.90&callback_url=https%3A%2F%2Fmitienda.com%2Fcallback.php%3Fjson%3Dtrue" ``` **PHP** ```php getenv('ORVENPAY_API_KEY'), 'order_number' => '10245', 'order_name' => 'Plan Pro mensual', 'source_currency' => 'USD', 'source_amount' => '49.90', 'callback_url' => 'https://mitienda.com/callback.php', ]); $res = json_decode(file_get_contents($base . '/api/v1/invoices/new?' . $query), true); if ($res['status'] !== 'success') { throw new RuntimeException($res['data']['message'] . ' (' . $res['data']['code'] . ')'); } header('Location: ' . $res['data']['invoice_url']); ``` **Node.js** ```js const BASE = 'https://api.orvenpay.com/gateway'; const qs = new URLSearchParams({ api_key: process.env.ORVENPAY_API_KEY, order_number: '10245', order_name: 'Plan Pro mensual', source_currency: 'USD', source_amount: '49.90', callback_url: 'https://mitienda.com/callback?json=true', }); const res = await fetch(BASE + '/api/v1/invoices/new?' + qs).then((r) => r.json()); if (res.status !== 'success') throw new Error(res.data.message); console.log(res.data.txn_id, res.data.invoice_url); ``` **Python** ```python import os, requests BASE = "https://api.orvenpay.com/gateway" r = requests.get(BASE + "/api/v1/invoices/new", params={ "api_key": os.environ["ORVENPAY_API_KEY"], "order_number": "10245", "order_name": "Plan Pro mensual", "source_currency": "USD", "source_amount": "49.90", "callback_url": "https://mitienda.com/callback?json=true", }, timeout=15).json() if r["status"] != "success": raise RuntimeError(r["data"]["message"]) print(r["data"]["invoice_url"]) ``` #### Respuesta en tiendas estándar Sólo `txn_id` (id de la factura), `invoice_url` (página de pago alojada) e `invoice_total_sum` (total en cripto; cadena vacía si el cliente aún no eligió moneda). Redirige al cliente a `invoice_url`. **Tienda estándar** ```json { "status": "success", "data": { "txn_id": "cmhx3v8qk0004l70gq2b9d7ma", "invoice_url": "https://orvenpay.com/invoice/cmhx3v8qk0004l70gq2b9d7ma", "invoice_total_sum": "" } } ``` #### Respuesta en tiendas White Label Datos completos para mostrar el pago dentro de tu web o bot. Envía `currency`/`psys_cid` para recibir dirección e importe (sin moneda, `wallet_hash`, `amount` y `qr_code` llegan vacíos). `qr_code` es un PNG en data URI; `source_rate` = unidades de cripto por 1 unidad de `source_currency`; `expire_utc` es Unix en segundos. `verify_hash` se calcula sobre el resto de campos de `data` con el mismo algoritmo que los callbacks en modo formulario. **Tienda White Label** ```json { "status": "success", "data": { "txn_id": "cmhx4b2rn0009l70g8w1c5e3ds", "invoice_url": "https://orvenpay.com/invoice/cmhx4b2rn0009l70g8w1c5e3ds", "amount": "50.91", "pending_amount": "50.91", "wallet_hash": "TQ7m4pVb2Xr9KsN3wJd8YfHc6LtUe1gAzR", "psys_cid": "USDT_TRX", "currency": "USDT_TRX", "status": "new", "source_currency": "USD", "source_rate": "1.000200", "expire_utc": 1794150000, "expected_confirmations": "19", "qr_code": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...", "verify_hash": "2b7d0c4e9a1f3e5d7c9b0a2f4e6d8c1b3a5f7e9d", "invoice_commission": "1", "invoice_sum": "49.91", "invoice_total_sum": "50.91" } } ``` *Importes en formato Gateway* | Campo | Significado | | --- | --- | | `invoice_total_sum` | Total que paga el cliente (= `amount` de la factura nativa). | | `invoice_sum` | Parte del comercio: `invoice_total_sum − invoice_commission`. | | `invoice_commission` | Si el cliente paga recargos: la diferencia sobre la base (comisión y/o fee de red). Si todo lo paga el comercio: la comisión %. | | `pending_amount` | Lo que falta por recibir. | | `amount` (callbacks) | Importe **recibido** (en `new` sin pagos, el importe de la factura). | ### Callbacks y verify_hash - `callback_url` (o la URL de webhooks de la tienda) recibe cada cambio de estado: `pending`, `completed`, `mismatch`, `expired`, `cancelled`. `success_callback_url` recibe además `completed`; `fail_callback_url` recibe además `expired`, `cancelled` y el pago parcial expirado. - Formato: `application/x-www-form-urlencoded` por defecto; JSON si la URL contiene `json=true`. Todos los valores son strings y las claves van **ordenadas** como `ksort()` de PHP, con `verify_hash` al final. - Firma: `verify_hash = HMAC-SHA1(clave, mensaje)` en hex, donde la clave es la **API key que creó la factura** (si no la hay, el secreto de webhooks de la tienda) y el mensaje es: en formulario, `serialize()` de PHP del array ordenado sin `verify_hash`; en JSON, `JSON.stringify` del objeto sin `verify_hash` con las claves ordenadas y sin espacios. - Mismas reglas de entrega que los webhooks OrvenPay: 2xx en < 10 s, 8 intentos con backoff, orden por factura, sin seguir redirecciones. También llevan `X-OrvenPay-Event` y `X-OrvenPay-Delivery`. - `comment`: `"overpaid"` en sobrepago, `"underpaid"` en pago parcial expirado, vacío en el resto. **Callback en formulario (por defecto)** ```http POST /callback.php HTTP/1.1 Content-Type: application/x-www-form-urlencoded User-Agent: OrvenPay-Webhooks/1.0 X-OrvenPay-Event: invoice.completed X-OrvenPay-Delivery: cmhx3x9ab0007l70gh8j2k4lm amount=50.91&comment=&confirmations=19¤cy=USDT_TRX&invoice_commission=1&invoice_sum=49.91&invoice_total_sum=50.91&ipn_type=invoice&merchant=Mi+tienda&merchant_id=cmhx2a1bc0001l70g5s6t7u8v&order_name=Plan+Pro+mensual&order_number=10245&pending_amount=0&psys_cid=USDT_TRX&source_amount=49.9&source_currency=USD&source_rate=1.000200&status=completed&txn_id=cmhx3v8qk0004l70gq2b9d7ma&wallet_hash=TQ7m4pVb2Xr9KsN3wJd8YfHc6LtUe1gAzR&verify_hash=8f3a...e21c ``` **Callback JSON (callback_url con json=true)** ```json { "amount": "50.91", "comment": "", "confirmations": "19", "currency": "USDT_TRX", "invoice_commission": "1", "invoice_sum": "49.91", "invoice_total_sum": "50.91", "ipn_type": "invoice", "merchant": "Mi tienda", "merchant_id": "cmhx2a1bc0001l70g5s6t7u8v", "order_name": "Plan Pro mensual", "order_number": "10245", "pending_amount": "0", "psys_cid": "USDT_TRX", "source_amount": "49.9", "source_currency": "USD", "source_rate": "1.000200", "status": "completed", "txn_id": "cmhx3v8qk0004l70gq2b9d7ma", "wallet_hash": "TQ7m4pVb2Xr9KsN3wJd8YfHc6LtUe1gAzR", "verify_hash": "c47e0b2d9f8a1e3c5b7d9f0a2c4e6b8d1f3a5c7e" } ``` **PHP (formulario)** ```php [k, rest[k]])); const expected = crypto.createHmac('sha1', apiKey).update(JSON.stringify(sorted)).digest('hex'); return typeof verify_hash === 'string' && verify_hash.length === 40 && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(verify_hash)); } // Formulario: rawBody = cuerpo application/x-www-form-urlencoded sin parsear export function verifyGatewayForm(rawBody, apiKey) { const params = new URLSearchParams(rawBody); const received = params.get('verify_hash') || ''; const entries = [...params.entries()].filter(([k]) => k !== 'verify_hash') .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); const len = (s) => Buffer.byteLength(s, 'utf8'); const serialized = 'a:' + entries.length + ':{' + entries.map(([k, v]) => 's:' + len(k) + ':"' + k + '";s:' + len(v) + ':"' + v + '";').join('') + '}'; const expected = crypto.createHmac('sha1', apiKey).update(serialized).digest('hex'); return received.length === 40 && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received)); } ``` **Python** ```python import hashlib, hmac, json from urllib.parse import parse_qsl def verify_gateway_json(data: dict, api_key: str) -> bool: received = data.pop("verify_hash", "") msg = json.dumps({k: data[k] for k in sorted(data)}, separators=(",", ":"), ensure_ascii=False) expected = hmac.new(api_key.encode(), msg.encode(), hashlib.sha1).hexdigest() return hmac.compare_digest(expected, received) def php_serialize_str_dict(d: dict) -> str: def s(x: str) -> str: return f's:{len(x.encode())}:"{x}";' return f"a:{len(d)}:{{" + "".join(s(k) + s(v) for k, v in d.items()) + "}" def verify_gateway_form(raw_body: str, api_key: str) -> bool: data = dict(parse_qsl(raw_body, keep_blank_values=True)) received = data.pop("verify_hash", "") ordered = {k: data[k] for k in sorted(data, key=lambda k: k.encode())} expected = hmac.new(api_key.encode(), php_serialize_str_dict(ordered).encode(), hashlib.sha1).hexdigest() return hmac.compare_digest(expected, received) ``` > **Detalles que rompen la verificación.** Las longitudes de `serialize()` son en **bytes UTF-8** (no caracteres). Conserva los campos vacíos (`comment=`). En JSON no uses espacios (`separators=(",", ":")` en Python) ni escapes `/` o Unicode (`JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE` en PHP). Usa la API key con la que creaste la factura. ### Estados en formato Gateway *Correspondencia de estados* | Estado nativo | status Gateway | Tratamiento | | --- | --- | --- | | `NEW` | `new` | Esperando pago. | | `PENDING` | `pending` | Pago detectado, confirmando. | | `COMPLETED` | `completed` | Pagado. | | `COMPLETED` + `overpaid` | `mismatch` | **Sobrepago: tratar como pagado** (`comment: "overpaid"`). | | `MISMATCH` (pago parcial expirado) | `expired` | No pagado completo; `amount` = lo recibido (`comment: "underpaid"`). | | `EXPIRED` | `expired` | Sin pago. | | `CANCELLED` | `cancelled` | Cancelada. | | `ERROR` | `error` | Incidencia: contactar soporte. | ### Operaciones `GET /gateway/api/v1/operations/{id}` · auth: `api_key` Una factura (`type: "invoice"`) o un retiro (`type: "cash_out"`) por id. `404` con código `111` si no existe o no es tuyo. En tiendas estándar las facturas llegan con `wallet_hash: ""` (sin dirección de depósito). Extras útiles en facturas: `txn_id`, `invoice_url`, `invoice_total_sum`, `pending_amount`, `overpaid`; en retiros: `txn_id`, `internal_status`, `error`. **GET operations/{id}** ```json { "status": "success", "data": { "user_id": "cmhx29zz00000l70gxk1m2n3o", "shop_id": "cmhx2a1bc0001l70g5s6t7u8v", "type": "invoice", "status": "completed", "pending_sum": "0", "psys_cid": "USDT_TRX", "currency": "USDT_TRX", "source_currency": "USD", "source_rate": "1.000200", "fee": null, "wallet_hash": "TQ7m4pVb2Xr9KsN3wJd8YfHc6LtUe1gAzR", "sendmany": null, "params": { "order_number": "10245", "order_name": "Plan Pro mensual", "description": "", "email": "", "amount": "49.91", "currency": "USDT_TRX", "psys_cid": "USDT_TRX", "source_amount": "49.9", "source_currency": "USD", "source_rate": "1.000200", "callback_url": "https://mitienda.com/callback.php", "success_callback_url": "", "fail_callback_url": "", "success_invoice_url": "", "fail_invoice_url": "" }, "expire_at_utc": 1794150000, "created_at_utc": 1794146400, "finished_at_utc": 1794146981, "amount": "50.91", "sum": "50.91", "commission": "1", "tx_url": ["https://tronscan.org/#/transaction/5e1c3a9f0b7d24e6c8a1f3b5d7e9c0a2b4d6f8e0a1c3e5b7d9f1a3c5e7b9d1f3"], "tx_id": ["5e1c3a9f0b7d24e6c8a1f3b5d7e9c0a2b4d6f8e0a1c3e5b7d9f1a3c5e7b9d1f3"], "tx": [ { "txid": "5e1c3a9f0b7d24e6c8a1f3b5d7e9c0a2b4d6f8e0a1c3e5b7d9f1a3c5e7b9d1f3", "value": "50.91", "amount": "50.91", "confirmations": 19, "block": 67012345, "processed": true, "status": "confirmed", "url": "https://tronscan.org/#/transaction/5e1c3a9f0b7d24e6c8a1f3b5d7e9c0a2b4d6f8e0a1c3e5b7d9f1a3c5e7b9d1f3" } ], "id": "cmhx3v8qk0004l70gq2b9d7ma", "actual_sum": "50.91", "actual_commission": "0.25455", "actual_fee": "1", "actual_invoice_sum": "49.65545", "confirmations": 19, "expected_confirmations": 19, "parent_id": null, "child_ids": [], "txn_id": "cmhx3v8qk0004l70gq2b9d7ma", "invoice_url": "https://orvenpay.com/invoice/cmhx3v8qk0004l70gq2b9d7ma", "invoice_total_sum": "50.91", "pending_amount": "0", "overpaid": false } } ``` `GET /gateway/api/v1/operations` · auth: `api_key` *Parámetros del listado* | Parámetro | Descripción | | --- | --- | | `page` | 1-1000, por defecto 1. | | `limit` (alias `per-page`, `page_size`) | 1-100, por defecto 20. | | `type` | `invoice` o `cash_out`. | | `status` | `new`, `pending`, `completed`, `mismatch`, `expired`, `cancelled`, `error`. | | `currency` / `psys_cid` | Filtra por moneda. | | `shop_id` | Sólo facturas de esa tienda (la de tu clave; otra tienda devuelve lista vacía). | | `search` | Coincidencia exacta por id, `order_number`, dirección, email o hash de transacción. | Respuesta: `data.operations[]`, `data._links` (`self`, `first`, `last`, `prev`, `next` con `href`) y `data._meta` (`totalCount`, `pageCount`, `currentPage`, `perPage`). Se combinan facturas de la tienda y retiros de la cuenta ordenados por fecha; las páginas más allá de 500 filas por fuente vuelven vacías. ### Saldos, monedas y tienda *Recursos* | Endpoint | Respuesta data | | --- | --- | | `GET /balances/{psys_cid}` | `{ psys_cid, currency, balance }` (saldo disponible). | | `GET /balances?currency=BTC` | Igual que el anterior. Sin `currency`: array con todas las monedas activas. | | `GET /currencies` · `GET /currencies/{fiat}` | Array: `name`, `cid`, `currency` (= psys_cid), `icon` (URL SVG), `rate_usd` (cripto por 1 USD), `price_usd`, `precision`, `output_precision` (strings), `fiat`, `fiat_rate` (cripto por 1 unidad fiat), `min_sum_in` (mínimo de factura en cripto), `invoice_commission_percentage`, `hidden` (0/1: 1 = no disponible para tu tienda), `maintenance` (false). Fiat no soportado: 422, código 105. | | `GET /shops` | `{ id, name, white_label }`. `white_label: true` en tiendas White Label (los plugins muestran el pago en la tienda en lugar de redirigir). | | `GET /operations/fee/{psys_cid}` | `{ fee, psys_cid, currency, plan: "normal" }`: fee de red de retiro (peor caso). | | `GET /operations/fee-plan/{psys_cid}` | `{ normal: { value }, priority: { value } }` (mismo valor: no hay prioridad configurable). | | `GET /operations/commission/{psys_cid}` | `{ commission: "0", fee, plan, useWallet, useWalletBalance, plans }`. | ### Retiros por la API Gateway `GET|POST /gateway/api/v1/operations/withdraw` · auth: `api_key` - **Desactivado por defecto** por tienda (`403`, código `119`); pide a soporte que lo habilite si lo necesitas. La API nativa `POST /v1/withdrawals` no requiere esta habilitación. - Parámetros: `currency` o `psys_cid`, `to` (dirección **activa** de tu libreta), `amount` (decimal), `type=cash_out` (por defecto). `mass_cash_out` y destinos separados por comas no se admiten (103). `feePlan`/`fee_plan`/`feeRate` se aceptan e ignoran. - Idempotencia: la misma petición (tienda, moneda, destino, importe) repetida en 120 s devuelve la misma operación con `200`; un retiro nuevo responde `201`. - Se aplican todas las reglas de retiro (mínimos, fee, límite diario, revisión, maduración, pausa). Respuesta: una operación `type: "cash_out"` con `status` `pending` / `completed` / `error` / `cancelled`. **Saldos, retiros y operaciones** ```bash curl "https://api.orvenpay.com/gateway/api/v1/balances/USDT_TRX?api_key=$ORVENPAY_API_KEY" curl "https://api.orvenpay.com/gateway/api/v1/currencies/EUR?api_key=$ORVENPAY_API_KEY" curl "https://api.orvenpay.com/gateway/api/v1/shops?api_key=$ORVENPAY_API_KEY" curl "https://api.orvenpay.com/gateway/api/v1/operations?api_key=$ORVENPAY_API_KEY&type=invoice&status=completed&page=1&limit=50" curl "https://api.orvenpay.com/gateway/api/v1/operations/cmhx3v8qk0004l70gq2b9d7ma?api_key=$ORVENPAY_API_KEY" curl "https://api.orvenpay.com/gateway/api/v1/operations/withdraw?api_key=$ORVENPAY_API_KEY&psys_cid=USDT_TRX&to=TXk9vQ3mPz7Lr2WbN8cDs5HfYuJ4aEgR6t&amount=250&type=cash_out" ``` ### Identificadores de moneda (psys_cid) *psys_cid* | psys_cid | Código nativo | Moneda | Red | | --- | --- | --- | --- | | `BTC` | `BTC` | Bitcoin (BTC) | Bitcoin | | `LTC` | `LTC` | Litecoin (LTC) | Litecoin | | `TRX` | `TRX` | TRON (TRX) | TRON | | `USDT_TRX` | `USDT_TRC20` | Tether USD (USDT) | TRC20 | | `ETH` | `ETH` | Ethereum (ETH) | ERC20 | | `USDT` | `USDT_ERC20` | Tether USD (USDT) | ERC20 | | `USDC` | `USDC_ERC20` | USD Coin (USDC) | ERC20 | | `BNB` | `BNB` | BNB (BNB) | BEP20 | | `USDT_BSC` | `USDT_BEP20` | Tether USD (USDT) | BEP20 | ### Errores de la API Gateway **Error** ```http HTTP/1.1 400 Bad Request { "status": "error", "data": { "name": "Bad Request", "message": "{\"order_number\":\"Missing required attribute\"}", "code": 103 } } ``` *Códigos numéricos (data.code)* | Error interno | data.code | Significado | | --- | --- | --- | | `VALIDATION_ERROR` | 103 | Parámetro ausente o inválido. `message` es un JSON `{"campo":"motivo"}`. | | `ORDER_NUMBER_REQUIRED` | 103 | Falta `order_number`. | | `AMOUNT_REQUIRED` | 103 | Falta el importe (`source_amount` o `amount`). | | `CURRENCY_REQUIRED` | 103 | `amount` sin `currency`/`psys_cid`. | | `INVALID_AMOUNT` | 103 | Importe no positivo o mal formado. | | `AMOUNT_BELOW_MIN` | 103 | Retiro por debajo del mínimo de la moneda. | | `INVALID_ADDRESS` | 103 | Dirección no válida para la red. | | `INVALID_API_KEY` | 104 | API key ausente, inválida o revocada (HTTP 401). | | `UNAUTHORIZED` | 104 | No autenticado (HTTP 401). | | `INVALID_CURRENCY` | 105 | Moneda desconocida (HTTP 422 o 400). | | `INVALID_SOURCE_CURRENCY` | 105 | Moneda fiat de origen no soportada. | | `CURRENCY_MISMATCH` | 105 | `source_currency` cripto distinta de `currency`. | | `CURRENCY_NOT_AVAILABLE` | 106 | La moneda no está activa para la tienda. | | `NO_CURRENCIES_AVAILABLE` | 106 | La tienda no tiene ninguna moneda disponible. | | `CHAIN_NOT_CONFIGURED` | 106 | La red no está disponible ahora mismo (HTTP 503). | | `DUPLICATE_ORDER` | 107 | `order_number` ya usado en esta tienda (HTTP 409). Usa `return_existing=1`. | | `RATES_UNAVAILABLE` | 108 | Tipo de cambio no disponible temporalmente (HTTP 503). Reintenta. | | `INTERNAL_ERROR` | 108 | Error interno (HTTP 500). Reintenta con backoff. | | `ACCOUNT_SUSPENDED` | 109 | Cuenta suspendida (HTTP 403). | | `STORE_INACTIVE` | 109 | Tienda desactivada (HTTP 403). | | `MAINTENANCE` | 110 | Plataforma en mantenimiento (HTTP 503). | | `NOT_FOUND` | 111 | Recurso inexistente o de otra tienda (HTTP 404). | | `ADDRESS_NOT_IN_BOOK` | 112 | El destino del retiro no está en tu libreta. | | `ADDRESS_NOT_ACTIVE` | 112 | La dirección de la libreta aún está en periodo de activación. | | `ADDRESS_CURRENCY_MISMATCH` | 112 | La dirección de la libreta es de otra moneda. | | `WITHDRAWALS_LOCKED` | 113 | Retiros de la cuenta congelados por seguridad. | | `DAILY_LIMIT_EXCEEDED` | 114 | Supera el límite diario de retiro en USD. | | `AMOUNT_TOO_SMALL` | 115 | Importe por debajo del mínimo de factura. | | `INSUFFICIENT_BALANCE` | 116 | Saldo insuficiente (importe + fee de red). | | `WITHDRAWALS_PAUSED` | 117 | Retiros pausados temporalmente por la plataforma (HTTP 503). | | `FUNDS_MATURING` | 118 | Parte del saldo aún está madurando y no es retirable. | | `GATEWAY_WITHDRAW_DISABLED` | 119 | Retiros por la API Gateway desactivados para esta tienda (HTTP 403). | | `RATE_LIMITED` | 429 | Demasiadas peticiones (HTTP 429). Respeta `Retry-After`. | Cualquier otro error usa el estado HTTP como `data.code` (p. ej. `409` para `INVALID_STATUS`) o `108` si es 5xx. `data.name` es el texto HTTP (`Bad Request`, `Unauthorized`, `Not Found`, `Unprocessable entity`, `Too Many Requests`...). ### Puesta en marcha con un plugin 1. Crea tu cuenta y una tienda, y activa las monedas que aceptas. 2. En el plugin, configura la URL base (API URL / endpoint) `https://api.orvenpay.com/gateway` y la API key de la tienda (campo "API key" / "secret key"). 3. Comprueba que el plugin usa la URL de callback de tu tienda (o fija la URL de webhooks en la tienda). 4. Si haces retiros por API, añade antes los destinos a tu libreta: se activan tras el periodo de espera. 5. Crea un pedido de prueba de importe pequeño y comprueba que el callback valida `verify_hash` y marca el pedido como pagado. --- ## 11. Saldos y retiros *Saldos por moneda, maduración de créditos, libreta de direcciones con activación, mínimos, fees, revisión, límite diario, pausa e idempotencia de retiros.* ### Saldos `GET /v1/balances` · auth: `X-Api-Key` - Saldos de la **cuenta** (todas sus tiendas), una fila por moneda activa o con saldo. - `available`: disponible. `locked`: reservado por retiros en curso (importe + fee). `withdrawable`: retirable ahora (= `available` menos créditos aún en maduración). `maturingUntil`: cuándo madura el último crédito pendiente. `availableUsd`: valor orientativo. - Maduración: cada crédito de factura es retirable 1 h después de acreditarse (protección ante reorganizaciones de la cadena). - `withdrawalsPaused: true` significa que la plataforma ha pausado los retiros temporalmente (los fondos siguen seguros). **GET /v1/balances** ```json { "items": [ { "currency": "USDT_TRC20", "name": "Tether USD", "symbol": "USDT", "network": "TRC20", "icon": "usdt", "networkIcon": "trx", "available": "1520.4471", "locked": "251", "withdrawable": "1469.5371", "maturingUntil": "2026-11-08T15:09:41.000Z", "availableUsd": "1520.14" }, { "currency": "BTC", "name": "Bitcoin", "symbol": "BTC", "network": "Bitcoin", "icon": "btc", "networkIcon": null, "available": "0", "locked": "0", "withdrawable": "0", "maturingUntil": null, "availableUsd": "0.00" } ], "totalUsd": "1520.14", "withdrawalsPaused": false } ``` ### Libreta de direcciones - Los retiros sólo pueden ir a direcciones de tu **libreta** (*Panel → Retiros → Libreta*). Añadir una dirección exige contraseña o 2FA y se valida el formato de la red; no se admiten direcciones de la propia plataforma. - Cada dirección nueva se **activa 24 h después** de añadirla (`activatesAt`). Antes, un retiro hacia ella da `403 ADDRESS_NOT_ACTIVE` con `details.activatesAt`. - La libreta y el auto-retiro por moneda (dirección + importe mínimo, por tienda) se gestionan sólo desde el panel; la API no puede añadir destinos (así una clave filtrada no puede enviar fondos a una dirección nueva). ### Crear un retiro `POST /v1/withdrawals` · auth: `X-Api-Key` · Rate limit: 20/min por clave *Cuerpo* | Campo | Tipo | Oblig. | Descripción | | --- | --- | --- | --- | | `currency` | string | sí | Código nativo (`USDT_TRC20`, `BTC`...). No acepta psysCid. | | `addressId` | string | uno de los dos | Id de la entrada de la libreta. | | `address` | string | uno de los dos | Dirección literal; debe existir en tu libreta para esa moneda. | | `amount` | decimal | sí | Importe que recibirá el destino (el fee de red se descuenta APARTE de tu saldo). Máximo de decimales = decimales on-chain de la moneda. | | `idempotencyKey` | string 1-100 | no | O cabecera `Idempotency-Key`. Repetir con la misma clave y los mismos datos devuelve el mismo retiro (`200`); con datos distintos, `409 IDEMPOTENCY_KEY_REUSED`. | **cURL** ```bash curl -X POST https://api.orvenpay.com/v1/withdrawals \ -H "X-Api-Key: $ORVENPAY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: payout-2026-11-08-001" \ -d '{ "currency": "USDT_TRC20", "address": "TXk9vQ3mPz7Lr2WbN8cDs5HfYuJ4aEgR6t", "amount": "250" }' curl https://api.orvenpay.com/v1/withdrawals/cmhx9k2lm0011l70gr3s5t7uv -H "X-Api-Key: $ORVENPAY_API_KEY" ``` **Respuesta 201** ```json { "withdrawal": { "id": "cmhx9k2lm0011l70gr3s5t7uv", "currency": "USDT_TRC20", "address": "TXk9vQ3mPz7Lr2WbN8cDs5HfYuJ4aEgR6t", "amount": "250", "networkFee": "1", "total": "251", "amountUsd": "249.95", "status": "APPROVED", "auto": false, "txHash": null, "explorerUrl": null, "error": null, "reviewNote": null, "createdAt": "2026-11-08T16:20:00.000Z", "sentAt": null } } ``` *Estados de un retiro* | Estado | Significado | | --- | --- | | `PENDING_REVIEW` | Supera 500 USD (o la aprobación automática está desactivada): espera revisión manual. | | `APPROVED` | En cola para firmarse y enviarse. | | `PROCESSING` | Construyendo y firmando la transacción. | | `SENT` | Emitido; tiene `txHash` y `explorerUrl`. | | `CONFIRMED` | Confirmado en la red (final). | | `FAILED` | No se pudo enviar; importe y fee vuelven a tu saldo (final). | | `REJECTED` | Rechazado en revisión (`reviewNote`); se devuelven los fondos (final). | | `CANCELLED` | Cancelado por ti desde el panel antes de procesarse; se devuelven los fondos (final). | ### Mínimos y fees de retiro La plataforma no cobra % por retirar: sólo el **fee de red** publicado por moneda. Se cobra `max(tabla, coste real estimado × (1 + 10 %))`, así que en congestión extrema puede ser mayor que la tabla (consulta el vigente en `GET /v1/currencies` → `withdrawalFee`). Valores por defecto: *Retiros por moneda (por defecto)* | Código | Moneda | Mínimo | Fee de red (tabla) | | --- | --- | --- | --- | | `BTC` | BTC (Bitcoin) | 0.0002 BTC | 0.00001 BTC | | `LTC` | LTC (Litecoin) | 0.01 LTC | 0.0002 LTC | | `TRX` | TRX (TRON) | 10 TRX | 1 TRX | | `USDT_TRC20` | USDT (TRC20) | 5 USDT | 1 USDT | | `ETH` | ETH (ERC20) | 0.003 ETH | 0.0002 ETH | | `USDT_ERC20` | USDT (ERC20) | 10 USDT | 1.5 USDT | | `USDC_ERC20` | USDC (ERC20) | 10 USDC | 1.5 USDC | | `BNB` | BNB (BEP20) | 0.005 BNB | 0.0001 BNB | | `USDT_BEP20` | USDT (BEP20) | 5 USDT | 0.25 USDT | ### Reglas y errores de retiro *Reglas* | Regla | Error | | --- | --- | | Importe ≥ mínimo de la moneda | `400 AMOUNT_BELOW_MIN` | | Destino en la libreta y de la misma moneda | `404 ADDRESS_NOT_IN_BOOK`, `400 ADDRESS_CURRENCY_MISMATCH` | | Destino activado (24 h) | `403 ADDRESS_NOT_ACTIVE` | | `available ≥ amount + fee` | `400 INSUFFICIENT_BALANCE` | | Saldo madurado suficiente | `400 FUNDS_MATURING` (`details.withdrawable`, `details.maturingUntil`) | | Límite diario: 10.000 USD en 24 h (retiros no fallidos) | `403 DAILY_LIMIT_EXCEEDED` (`details.dailyLimitUsd`, `details.dailyUsedUsd`) | | Más de 500 USD | Sin error: queda `PENDING_REVIEW` | | Retiros de la cuenta congelados por seguridad | `403 WITHDRAWALS_LOCKED` | | Interruptor de emergencia de la plataforma | `503 WITHDRAWALS_PAUSED` (reintenta más tarde) | | Sin tipo de cambio para calcular límites | `503 RATES_UNAVAILABLE` | | Retiro inexistente o de otra cuenta | `404 WITHDRAWAL_NOT_FOUND` | --- ## 12. Monedas y redes *Monedas soportadas con red, contrato, confirmaciones, decimales, mínimos y fee de red; monedas fiat de precio.* Usa el **código** en `currency`/`allowedCurrencies` (la API nativa acepta también el `psysCid`). La disponibilidad real depende de la configuración de la plataforma y de tu tienda: consúltala en `GET /v1/currencies` (`available`). *Monedas (valores por defecto)* | Código | psysCid | Moneda | Red | Tipo / contrato | Confirmaciones | Decimales cobro | Mínimo factura | Suelo fee red factura | | --- | --- | --- | --- | --- | --- | --- | --- | --- | | `BTC` | `BTC` | Bitcoin (BTC) | Bitcoin | nativa | 3 | 8 | 5 USD | 0.00001 BTC | | `LTC` | `LTC` | Litecoin (LTC) | Litecoin | nativa | 6 | 8 | 1 USD | 0.0002 LTC | | `TRX` | `TRX` | TRON (TRX) | TRON | nativa | 19 | 6 | 1 USD | 0.3 TRX | | `USDT_TRC20` | `USDT_TRX` | Tether USD (USDT) | TRC20 | token `TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` | 19 | 4 | 5 USD | 1 USDT | | `ETH` | `ETH` | Ethereum (ETH) | ERC20 | nativa | 12 | 8 | 3 USD | 0.0001 ETH | | `USDT_ERC20` | `USDT` | Tether USD (USDT) | ERC20 | token `0xdAC17F958D2ee523a2206206994597C13D831ec7` | 12 | 4 | 3 USD | 0.5 USDT | | `USDC_ERC20` | `USDC` | USD Coin (USDC) | ERC20 | token `0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48` | 12 | 4 | 3 USD | 0.5 USDC | | `BNB` | `BNB` | BNB (BNB) | BEP20 | nativa | 15 | 8 | 1 USD | 0.0002 BNB | | `USDT_BEP20` | `USDT_BSC` | Tether USD (USDT) | BEP20 | token `0x55d398326f99059fF775485246999027B3197955` | 15 | 4 | 1 USD | 0.1 USDT | - Los tokens (USDT, USDC) sólo se acreditan si llegan por la **red y el contrato indicados**. Un envío por otra red o un token distinto queda como pago huérfano para soporte. - Las direcciones EVM son iguales en Ethereum y BNB Smart Chain: un pago por la cadena equivocada NO se acredita automáticamente. Muestra siempre la red al cliente. - Ningún token requiere memo/tag: cada factura tiene su propia dirección. - Monedas fiat para `sourceCurrency`: USD, EUR, GBP, RUB, UAH, BRL, MXN, ARS, COP, CLP, PEN, VES, TRY, INR, CNY, JPY, KRW, CAD, AUD, CHF, PLN, NGN, KZT, IDR, PHP, VND, THB, AED, SAR. - Monedas activas por defecto: BTC, LTC, TRX, USDT_TRC20, ETH, USDT_ERC20, USDC_ERC20, BNB, USDT_BEP20. `GET /v1/currencies` · auth: `X-Api-Key` **GET /v1/currencies** ```json { "items": [ { "code": "USDT_TRC20", "psysCid": "USDT_TRX", "name": "Tether USD", "symbol": "USDT", "icon": "usdt", "networkIcon": "trx", "network": "TRC20", "decimals": 6, "displayDecimals": 4, "confirmations": 19, "rateUsd": "0.9998", "withdrawalFee": "1", "available": true } ] } ``` `rateUsd` es el precio actual en USD (`null` si no hay tasa fresca). `withdrawalFee` es el fee de retiro publicado. Total de monedas soportadas: 9. Página de pago y monedas activas públicas: `GET https://api.orvenpay.com/public/config`. --- ## 13. Comisiones y mínimos *Comisión por plan (0,5 % estándar, 1 % White Label), quién paga comisión y fee de red, cómo se descuenta del crédito y ejemplos numéricos.* *Conceptos* | Concepto | Valor por defecto | Quién lo paga | | --- | --- | --- | | Comisión (plan estándar) | 0.5 % de lo cobrado | `feePaidBy` de la tienda: `MERCHANT` (por defecto, se descuenta del crédito) o `CUSTOMER` (se suma al importe). | | Comisión (plan White Label) | 1 % de lo cobrado | Igual. | | Fee de red de la factura | max(suelo, coste real × 1.4) | `networkFeePaidBy`: `CUSTOMER` (por defecto, se suma al importe) o `MERCHANT`. | | Fee de retiro | Tabla por moneda (sin %) | Siempre tú, descontado aparte del saldo. | | Crear facturas, webhooks, API | Gratis | - | **Crédito al completarse la factura** ```text recibido = suma de pagos confirmados comision = feePaidBy == MERCHANT ? redondeoARRIBA(recibido × feePercent / 100, decimales on-chain) : amount − baseAmount − (feeRed si lo pagó el cliente) feeRed = networkFee de la factura (SIEMPRE se descuenta; si lo pagó el cliente ya venía dentro de "recibido") crédito neto = recibido − comision − feeRed (nunca negativo: si no alcanza, se cubre primero el fee de red) ``` **Ejemplo: 49,90 USD en USDT_TRC20, comercio paga comisión 0,5 %, cliente paga fee de red 1 USDT** ```text rate = 0.9998 USD/USDT baseAmount = redondeoARRIBA(49.90 / 0.9998, 4) = 49.9100 USDT amount = 49.91 + 1 (fee de red) = 50.91 USDT <- lo que paga el cliente comisión = 50.91 × 0.5 % = 0.25455 USDT crédito = 50.91 − 0.25455 − 1 = 49.65545 USDT <- lo que entra en tu saldo ``` - La comisión de cada factura queda fijada en `invoice.feePercent` al crearla; la cobrada real, en `feeAmount` (y `feeAmount` del webhook). - Mínimos de factura y fees de red por moneda: ver [Monedas y redes](https://orvenpay.com/docs#monedas). Regla: base ≥ mínimo USD de la moneda y ≥ 3 × fee de red. - Comisiones vigentes publicadas: `GET /public/config` → `feePercent`, `whiteLabelFeePercent`. La plataforma puede pactar comisiones personalizadas por cuenta. --- ## 14. Límites y rate limits *Peticiones por minuto por API key e IP, cupo de facturas por cuenta, tamaños máximos y cabeceras de rate limit.* *Límites* | Ámbito | Límite | | --- | --- | | API nativa `/v1/*` | 120 peticiones/min por API key | | `POST /v1/withdrawals` | 20 peticiones/min por API key | | API Gateway `/gateway/api/v1/*` | 120 peticiones/min por API key | | Facturas nuevas | 300/min por CUENTA (sumando todas sus tiendas, claves y orígenes) | | Resto de rutas | 300 peticiones/min por IP | | `GET /public/config` | 60/min por IP | | `GET /public/invoices/{id}` | 120/min por IP | | `POST /public/invoices/{id}/select-currency` | 20/min por IP | | SSE `/public/invoices/{id}/events` | 30 aperturas/min por IP; 3 conexiones simultáneas por IP; 1 por IP y factura; cierre a los 30 min | | Tamaño del cuerpo | 256 KB | | `metadata` | 4 KB serializado | | `orderNumber` | 128 caracteres | | Validez de factura | 1-1440 min | | Importe máximo por factura | 10.000.000 USD | | API keys activas por tienda | 20 | | Página de listado | 100 elementos | - Al superar un límite: `429 RATE_LIMITED` con cabecera `Retry-After` (segundos). Las respuestas incluyen `x-ratelimit-limit`, `x-ratelimit-remaining` y `x-ratelimit-reset`. - Reintenta con backoff exponencial y jitter (p. ej. 1 s, 2 s, 4 s... máximo 60 s). No reintentes 4xx distintos de 429/409 sin corregir la petición. - Si el almacén de rate limit no está disponible, las rutas de API responden `503 SERVICE_UNAVAILABLE` (con `Retry-After: 5`): reintenta. - Timeouts recomendados en tu cliente: 15 s para crear facturas o retiros. Ante un timeout, repite la MISMA petición (idempotente por `orderNumber` / `Idempotency-Key`). --- ## 15. Errores *Formato de error y catálogo completo de códigos de la API nativa y pública.* Todas las respuestas de error de la API nativa y pública tienen este formato (la API Gateway usa el suyo, ver [API Gateway](https://orvenpay.com/docs#api-gateway)). Decide por `code` (estable), no por `message` (texto en español que puede cambiar). `details` aparece en algunos errores. **Formato** ```json { "error": { "code": "AMOUNT_TOO_SMALL", "message": "Importe demasiado pequeño para USDT_TRC20: mínimo 5.0011 USDT (5 USD)", "details": { "currency": "USDT_TRC20", "minAmount": "5.0011", "minUsd": "5" } } } ``` *Catálogo de errores* | HTTP | code | Causa y qué hacer | | --- | --- | --- | | 400 | `VALIDATION_ERROR` | Campo ausente o con formato incorrecto. `message` = `campo: motivo; ...`, `details` = `[{ path, message }]`. Corrige la petición. | | 400 | `AMOUNT_REQUIRED` | Falta `sourceAmount`+`sourceCurrency` o `amount`+`currency`. | | 400 | `CURRENCY_REQUIRED` | `amount` sin `currency`. | | 400 | `INVALID_AMOUNT` | Importe ≤ 0, mal formado o con más decimales de los permitidos. | | 400 | `AMOUNT_TOO_SMALL` | Por debajo del mínimo. `details: { currency, minAmount, minUsd }`. | | 400 | `AMOUNT_TOO_LARGE` | Más de 10.000.000 USD. | | 400 | `INVALID_CURRENCY` | Código de moneda desconocido (en `currency`, `allowedCurrencies` o retiros). | | 400 | `INVALID_SOURCE_CURRENCY` | Moneda de precio no soportada. | | 400 | `CURRENCY_MISMATCH` | `sourceCurrency` cripto distinta de `currency`. | | 400 | `CURRENCY_NOT_AVAILABLE` | Moneda desactivada en la plataforma o en tu tienda. | | 400 | `NO_CURRENCIES_AVAILABLE` | La tienda no tiene monedas disponibles (o `allowedCurrencies` las excluye todas). | | 400 | `ORDER_NUMBER_REQUIRED` | `orderNumber` vacío. | | 400 | `AMOUNT_BELOW_MIN` | Retiro por debajo del mínimo. | | 400 | `INSUFFICIENT_BALANCE` | Saldo disponible < importe + fee de red. | | 400 | `FUNDS_MATURING` | Parte del saldo aún madura. `details: { withdrawable, maturingUntil }`. | | 400 | `ADDRESS_CURRENCY_MISMATCH` | La entrada de la libreta es de otra moneda. | | 400 | `INVALID_ADDRESS` | Dirección inválida para la red. | | 401 | `INVALID_API_KEY` | Clave ausente, inválida o revocada. | | 403 | `ACCOUNT_SUSPENDED` | Cuenta suspendida. Contacta con soporte. | | 403 | `STORE_INACTIVE` | Tienda desactivada (o la factura ya no admite pagos). | | 403 | `ADDRESS_NOT_ACTIVE` | Destino aún en periodo de activación. `details.activatesAt`. | | 403 | `DAILY_LIMIT_EXCEEDED` | Límite diario de retiro. `details: { dailyLimitUsd, dailyUsedUsd }`. | | 403 | `WITHDRAWALS_LOCKED` | Retiros de la cuenta congelados por seguridad. | | 403 | `GATEWAY_WITHDRAW_DISABLED` | Retiros por la API Gateway no habilitados para la tienda. | | 404 | `NOT_FOUND` | Factura inexistente o de otra tienda; también ruta inexistente. | | 404 | `INVOICE_NOT_FOUND` | Factura inexistente (endpoints públicos). | | 404 | `WITHDRAWAL_NOT_FOUND` | Retiro inexistente o de otra cuenta. | | 404 | `ADDRESS_NOT_IN_BOOK` | El destino no está en tu libreta. | | 409 | `DUPLICATE_ORDER` | `orderNumber` ya usado con otros datos. Usa otro `orderNumber`. | | 409 | `INVALID_STATUS` | Operación no válida en el estado actual (p. ej. cancelar una factura `PENDING`). | | 409 | `CURRENCY_ALREADY_SELECTED` | La factura ya tiene otra moneda. | | 409 | `IDEMPOTENCY_KEY_REUSED` | Clave de idempotencia usada para otro retiro. | | 409 | `CONFLICT` | Petición concurrente sobre el mismo recurso. Reintenta. | | 409 | `TRANSACTION_CONFLICT` | Conflicto transaccional. Reintenta. | | 410 | `INVOICE_EXPIRED` | La factura expiró (al elegir moneda). | | 429 | `RATE_LIMITED` | Demasiadas peticiones o facturas. Respeta `Retry-After`. | | 429 | `TOO_MANY_STREAMS` | Demasiadas conexiones SSE. | | 500 | `INTERNAL_ERROR` | Error inesperado. Reintenta con backoff; si persiste, soporte. | | 503 | `RATES_UNAVAILABLE` | Sin tipo de cambio fresco. Reintenta en unos minutos. | | 503 | `CHAIN_NOT_CONFIGURED` | La red de esa moneda no está disponible ahora. | | 503 | `MAINTENANCE` | Plataforma en mantenimiento. | | 503 | `WITHDRAWALS_PAUSED` | Retiros pausados temporalmente. | | 503 | `SERVICE_UNAVAILABLE` | Dependencia temporalmente caída. Reintenta (`Retry-After`). | - Reintentables: `409 CONFLICT`, `409 TRANSACTION_CONFLICT`, `429`, `500`, `503`. Usa siempre la misma clave de idempotencia (`orderNumber`, `Idempotency-Key`). - No reintentables sin cambios: el resto de 4xx. --- ## 16. Seguridad *Buenas prácticas: secretos, verificación de webhooks, no confiar en el navegador, importes, idempotencia y respuesta ante incidentes.* 1. **Secretos en el servidor**: API key y secreto de webhooks sólo en variables de entorno o gestor de secretos; nunca en el cliente, repositorios, logs ni URLs que compartas. En la API Gateway la clave viaja en la query: no registres URLs completas en tus logs. 2. **Una clave por entorno/servicio** y revoca las que no uses. Rota periódicamente (ver [Autenticación](https://orvenpay.com/docs#autenticacion)). 3. **Verifica SIEMPRE la firma** del webhook (HMAC-SHA256 en tiempo constante + ventana de 5 min) o el `verify_hash` del callback Gateway antes de tocar un pedido. 4. **No confíes en la redirección**: llegar a `successUrl` no prueba el pago. Entrega sólo con `invoice.completed` verificado (o tras consultar `GET /v1/invoices/{id}` desde tu servidor). 5. **Comprueba la coherencia**: que `data.orderNumber` existe, que la moneda/importe corresponden a tu pedido y que no lo has procesado ya. 6. **HTTPS** en tus URLs de webhook y retorno. Las URLs de webhook deben ser públicas: se bloquean IPs privadas y se revalida la IP en cada conexión (anti DNS-rebinding). 7. **Responde rápido y procesa en cola**: un endpoint lento provoca reintentos duplicados. 8. **Protege los retiros**: activa 2FA en tu cuenta, mantén una libreta mínima y revisa los avisos. Las direcciones nuevas tardan en activarse precisamente para darte margen si alguien roba tu sesión o tu clave. 9. **Custodia**: los fondos están bajo custodia de OrvenPay hasta que los retiras; usa el auto-retiro por moneda si prefieres no mantener saldo. 10. **Incidente**: si sospechas de una filtración, revoca la clave, rota el secreto de webhooks, cambia la contraseña y contacta con soporte para congelar retiros. --- ## 17. Recetas de integración *Código completo listo para copiar: bots de Telegram (python-telegram-bot, grammY), Next.js, Express, PHP/Laravel y plugins de e-commerce vía API Gateway.* Todas las recetas siguen el mismo patrón: (1) crear la factura en el servidor con `POST /v1/invoices`, (2) enviar al cliente a `invoiceUrl`, (3) verificar el webhook y entregar con `invoice.completed`. Variables de entorno: `ORVENPAY_API_URL=https://api.orvenpay.com`, `ORVENPAY_API_KEY`, `ORVENPAY_WEBHOOK_SECRET`. ### Bots de Telegram - El bot crea la factura al recibir un comando o botón y envía `invoiceUrl` como botón de URL (el cliente paga en el navegador; con White Label puedes enviar dirección e importe en el chat). - Guarda el `chat_id` en `metadata` (`{"chatId": 123}`): vuelve en cada webhook y te permite avisar al usuario sin consultar tu BD. - El webhook de OrvenPay necesita una URL HTTPS pública: corre un pequeño servidor HTTP junto al bot (aunque el bot use polling). - Un `orderNumber` por intento (`tg--`); si quieres reutilizar la factura de un pedido pendiente, usa el mismo `orderNumber` y mismo importe (idempotente). **Python (python-telegram-bot)** ```python # pip install "python-telegram-bot>=21" aiohttp httpx import asyncio, hashlib, hmac, json, os, time import httpx from aiohttp import web from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.ext import Application, CommandHandler, ContextTypes API = os.environ["ORVENPAY_API_URL"] API_KEY = os.environ["ORVENPAY_API_KEY"] SECRET = os.environ["ORVENPAY_WEBHOOK_SECRET"].encode() processed: set[str] = set() # en producción: tabla en tu BD async def comprar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: chat_id = update.effective_chat.id async with httpx.AsyncClient(timeout=15) as http: r = await http.post( f"{API}/v1/invoices", headers={"X-Api-Key": API_KEY}, json={ "orderNumber": f"tg-{chat_id}-{int(time.time())}", "orderName": "Premium 30 días", "sourceCurrency": "USD", "sourceAmount": "9.99", "allowedCurrencies": ["USDT_TRC20", "USDT_BEP20", "TRX", "LTC"], "metadata": {"chatId": chat_id}, }, ) body = r.json() if r.status_code >= 400: await update.message.reply_text("No se pudo crear el pago: " + body["error"]["message"]) return kb = InlineKeyboardMarkup([[InlineKeyboardButton("Pagar con cripto", url=body["invoice"]["invoiceUrl"])]]) await update.message.reply_text("Tu factura está lista (válida 60 min).", reply_markup=kb) def verify(raw: bytes, header: str) -> bool: try: parts = dict(p.strip().split("=", 1) for p in header.split(",")) t = int(parts["t"]) except (KeyError, ValueError): return False if abs(time.time() - t) > 300: return False expected = hmac.new(SECRET, str(t).encode() + b"." + raw, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts.get("v1", "")) async def orvenpay_webhook(request: web.Request) -> web.Response: raw = await request.read() if not verify(raw, request.headers.get("X-OrvenPay-Signature", "")): return web.Response(status=400, text="bad signature") evt = json.loads(raw) data = evt["data"] chat_id = (data.get("metadata") or {}).get("chatId") key = f'{data["id"]}:{evt["event"]}' if chat_id and key not in processed: processed.add(key) bot = request.app["bot"] if evt["event"] == "invoice.pending": await bot.send_message(chat_id, "Pago detectado. Esperando confirmaciones de la red...") elif evt["event"] == "invoice.completed": await bot.send_message(chat_id, "Pago confirmado. Premium activado.") elif evt["event"] == "invoice.expired": await bot.send_message(chat_id, "La factura expiró. Usa /comprar para generar otra.") return web.Response(text="ok") async def main() -> None: tg = Application.builder().token(os.environ["TELEGRAM_BOT_TOKEN"]).build() tg.add_handler(CommandHandler("comprar", comprar)) app = web.Application() app["bot"] = tg.bot app.router.add_post("/orvenpay/webhook", orvenpay_webhook) runner = web.AppRunner(app) await runner.setup() await web.TCPSite(runner, "0.0.0.0", 8080).start() # expón https://tu-bot.com/orvenpay/webhook async with tg: await tg.start() await tg.updater.start_polling() await asyncio.Event().wait() if __name__ == "__main__": asyncio.run(main()) ``` **Node.js (grammY)** ```js // npm i grammy express import crypto from 'node:crypto'; import express from 'express'; import { Bot, InlineKeyboard } from 'grammy'; const API = process.env.ORVENPAY_API_URL; const bot = new Bot(process.env.TELEGRAM_BOT_TOKEN); const processed = new Set(); // en producción: tu BD bot.command('comprar', async (ctx) => { const res = await fetch(API + '/v1/invoices', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Api-Key': process.env.ORVENPAY_API_KEY }, body: JSON.stringify({ orderNumber: 'tg-' + ctx.chat.id + '-' + Date.now(), orderName: 'Premium 30 días', sourceCurrency: 'USD', sourceAmount: '9.99', metadata: { chatId: ctx.chat.id }, }), }); const body = await res.json(); if (!res.ok) return ctx.reply('No se pudo crear el pago: ' + body.error.message); await ctx.reply('Tu factura está lista (válida 60 min).', { reply_markup: new InlineKeyboard().url('Pagar con cripto', body.invoice.invoiceUrl), }); }); function verify(raw, header = '') { const p = Object.fromEntries(header.split(',').map((x) => x.trim().split('='))); const t = Number(p.t); if (!t || Math.abs(Date.now() / 1000 - t) > 300 || !/^[0-9a-f]{64}$/.test(p.v1 || '')) return false; const expected = crypto.createHmac('sha256', process.env.ORVENPAY_WEBHOOK_SECRET).update(t + '.' + raw).digest('hex'); return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(p.v1)); } const app = express(); app.post('/orvenpay/webhook', express.raw({ type: 'application/json' }), async (req, res) => { const raw = req.body.toString('utf8'); if (!verify(raw, req.get('X-OrvenPay-Signature'))) return res.status(400).send('bad signature'); const { event, data } = JSON.parse(raw); const chatId = data.metadata?.chatId; const key = data.id + ':' + event; if (chatId && !processed.has(key)) { processed.add(key); if (event === 'invoice.completed') await bot.api.sendMessage(chatId, 'Pago confirmado. Premium activado.'); if (event === 'invoice.expired') await bot.api.sendMessage(chatId, 'La factura expiró. Usa /comprar.'); } res.sendStatus(200); }); app.listen(8080); bot.start(); ``` ### Next.js (App Router) Cuatro archivos: `lib/orvenpay.ts` (cliente de la API), `app/api/checkout/route.ts` (crea la factura), `app/api/orvenpay/webhook/route.ts` (verifica y procesa webhooks) y `.env.local`. `@/lib/orders` representa tu propia capa de pedidos. **lib/orvenpay.ts** ```ts // Cliente mínimo de la API (sólo servidor) const API_URL = process.env.ORVENPAY_API_URL!; const API_KEY = process.env.ORVENPAY_API_KEY!; export interface Invoice { id: string; status: 'NEW' | 'PENDING' | 'COMPLETED' | 'MISMATCH' | 'EXPIRED' | 'CANCELLED' | 'ERROR'; orderNumber: string; invoiceUrl: string; } export async function createInvoice(order: { id: string; title: string; totalUsd: string }) { const res = await fetch(`${API_URL}/v1/invoices`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Api-Key': API_KEY }, body: JSON.stringify({ orderNumber: order.id, // idempotente: reintentar no duplica orderName: order.title, sourceCurrency: 'USD', sourceAmount: order.totalUsd, }), cache: 'no-store', }); const body = await res.json(); if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`); return body.invoice as Invoice; } ``` **app/api/checkout/route.ts** ```ts import { NextResponse } from 'next/server'; import { createInvoice } from '@/lib/orvenpay'; import { getOrder } from '@/lib/orders'; export async function POST(req: Request) { const { orderId } = await req.json(); const order = await getOrder(orderId); const invoice = await createInvoice({ id: order.id, title: order.title, totalUsd: order.total }); return NextResponse.json({ url: invoice.invoiceUrl }); // el cliente paga aquí } ``` **app/api/orvenpay/webhook/route.ts** ```ts import { createHmac, timingSafeEqual } from 'node:crypto'; import { markOrderPaid } from '@/lib/orders'; export async function POST(req: Request) { const raw = await req.text(); // cuerpo crudo: la firma va sobre estos bytes const sig = req.headers.get('x-orvenpay-signature') ?? ''; const { t = '', v1 = '' } = Object.fromEntries(sig.split(',').map((p) => p.trim().split('='))); const expected = createHmac('sha256', process.env.ORVENPAY_WEBHOOK_SECRET!) .update(`${t}.${raw}`) .digest('hex'); const fresh = Math.abs(Date.now() / 1000 - Number(t)) <= 300; if (!fresh || !/^[0-9a-f]{64}$/.test(v1) || !timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) return new Response('firma inválida', { status: 400 }); const evt = JSON.parse(raw); if (evt.event === 'invoice.completed') await markOrderPaid(evt.data.orderNumber, evt.data.id); return new Response('ok'); // 2xx en < 10 s o se reintenta } ``` **.env.local** ```bash ORVENPAY_API_URL=https://api.orvenpay.com ORVENPAY_API_KEY=op_live_xxxxxxxx # Panel > Tiendas > API keys ORVENPAY_WEBHOOK_SECRET=whsec_xxxxxxxx # Panel > Tiendas > Webhooks ``` > **Next.js.** Los route handlers usan el runtime Node.js por defecto (necesario para `node:crypto`). No uses `req.json()` en el webhook: lee `req.text()` y parsea después de verificar. ### Express **Express** ```js // npm i express import crypto from 'node:crypto'; import express from 'express'; const app = express(); const API = process.env.ORVENPAY_API_URL; // 1) Webhook ANTES de express.json(): necesita el cuerpo crudo app.post('/webhooks/orvenpay', express.raw({ type: 'application/json' }), async (req, res) => { const raw = req.body.toString('utf8'); const p = Object.fromEntries((req.get('X-OrvenPay-Signature') || '').split(',').map((x) => x.trim().split('='))); const t = Number(p.t); const expected = crypto.createHmac('sha256', process.env.ORVENPAY_WEBHOOK_SECRET).update(t + '.' + raw).digest('hex'); const ok = /^[0-9a-f]{64}$/.test(p.v1 || '') && Math.abs(Date.now() / 1000 - t) <= 300 && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(p.v1)); if (!ok) return res.status(400).send('bad signature'); const { event, data } = JSON.parse(raw); if (event === 'invoice.completed') await orders.markPaid(data.orderNumber, data.id); // idempotente if (event === 'invoice.mismatch') await orders.flagPartial(data.orderNumber, data.amountReceived); if (event === 'invoice.expired' || event === 'invoice.cancelled') await orders.release(data.orderNumber); res.sendStatus(200); }); app.use(express.json()); // 2) Checkout: crea la factura y redirige app.post('/checkout/:orderId', async (req, res, next) => { try { const order = await orders.get(req.params.orderId); const r = await fetch(API + '/v1/invoices', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Api-Key': process.env.ORVENPAY_API_KEY }, body: JSON.stringify({ orderNumber: order.id, orderName: order.title, sourceCurrency: order.currency, // "USD", "EUR"... sourceAmount: order.total, // string decimal successUrl: 'https://mitienda.com/pedido/' + order.id, failUrl: 'https://mitienda.com/carrito', }), }); const body = await r.json(); if (!r.ok) return res.status(502).json(body.error); res.redirect(303, body.invoice.invoiceUrl); } catch (e) { next(e); } }); app.listen(3000); ``` ### PHP / Laravel **OrvenPay.php** ```php withHeaders(['X-Api-Key' => config('services.orvenpay.key')]) ->timeout(15) ->retry(2, 500, throw: false) ->post('/v1/invoices', [ 'orderNumber' => $orderNumber, 'orderName' => $title, 'sourceCurrency' => 'USD', 'sourceAmount' => $amountUsd, 'metadata' => $metadata ?: null, ]); if ($res->failed()) { throw new \RuntimeException($res->json('error.code') . ': ' . $res->json('error.message')); } return $res->json('invoice'); } public function validSignature(string $raw, ?string $header): bool { $parts = []; foreach (explode(',', (string) $header) as $pair) { [$k, $v] = array_pad(explode('=', trim($pair), 2), 2, null); $parts[$k] = $v; } $t = (int) ($parts['t'] ?? 0); if ($t === 0 || abs(time() - $t) > 300 || empty($parts['v1'])) { return false; } $expected = hash_hmac('sha256', $t . '.' . $raw, config('services.orvenpay.webhook_secret')); return hash_equals($expected, $parts['v1']); } } // config/services.php // 'orvenpay' => ['url' => env('ORVENPAY_API_URL'), 'key' => env('ORVENPAY_API_KEY'), // 'webhook_secret' => env('ORVENPAY_WEBHOOK_SECRET')], ``` **routes/web.php** ```php createInvoice((string) $order->id, $order->title, (string) $order->total); $order->update(['orvenpay_invoice_id' => $invoice['id']]); return redirect()->away($invoice['invoiceUrl']); }); // Excluye esta ruta de la verificación CSRF (bootstrap/app.php: // $middleware->validateCsrfTokens(except: ['webhooks/orvenpay']);) Route::post('/webhooks/orvenpay', function (Request $request, OrvenPay $orvenpay) { $raw = $request->getContent(); // cuerpo crudo if (!$orvenpay->validSignature($raw, $request->header('X-OrvenPay-Signature'))) { abort(400, 'bad signature'); } $evt = json_decode($raw, true); $order = Order::where('id', $evt['data']['orderNumber'])->first(); if ($order && $evt['event'] === 'invoice.completed' && !$order->paid_at) { $order->update(['paid_at' => now(), 'paid_amount' => $evt['data']['amountReceived'], 'paid_currency' => $evt['data']['currency']]); } return response('ok', 200); }); ``` ### Plugins de e-commerce (WooCommerce y similares) vía API Gateway 1. Instala un plugin de pasarela cripto que permita configurar la **URL de la API** y use el formato `api_key` + `verify_hash` (endpoints `/api/v1/invoices/new`, `/api/v1/operations/{id}`, callbacks firmados con HMAC-SHA1). 2. URL base: `https://api.orvenpay.com/gateway` (el plugin añadirá `/api/v1/...`). Clave: una API key de tu tienda. 3. URL de callback: la que genere el plugin (p. ej. `https://tutienda.com/?wc-api=...`); el plugin la envía como `callback_url` en cada factura. 4. Estados: el plugin marcará el pedido como pagado con `completed` y `mismatch` (sobrepago); `expired` y `cancelled` lo dejan pendiente/cancelado. 5. Con plan White Label, `GET /api/v1/shops` devuelve `white_label: true` y los plugins que lo soportan muestran el pago dentro de la tienda. 6. Si tu plugin no permite cambiar la URL, usa la API nativa con un pequeño módulo propio (receta PHP de arriba). --- ## 18. Pruebas *Checklist antes de producción, webhooks en local con un túnel, firmar payloads de prueba y pago real de bajo importe.* > **No hay entorno sandbox.** Las facturas son reales (mainnet). Prueba con el importe mínimo en una red barata: `USDT_BEP20`, `TRX`, `LTC` o `BNB` (mínimo 1 USD). Lo cobrado se acredita a tu saldo como cualquier venta. ### Webhooks en local con un túnel 1. Arranca tu servidor local (p. ej. `http://localhost:3000`). 2. Abre un túnel HTTPS público: `cloudflared tunnel --url http://localhost:3000` o `ngrok http 3000`. 3. Usa la URL del túnel como `callbackUrl` de la factura de prueba (p. ej. `https://abc123.trycloudflare.com/api/orvenpay/webhook`). `localhost` o IPs privadas se rechazan. 4. Crea una factura pequeña, págala y observa los webhooks `invoice.pending` e `invoice.completed`. 5. En *Panel → Facturas → (factura)* verás cada entrega con su código HTTP y error, y puedes **reenviarla** para depurar tu manejador. ### Probar tu verificación sin pagar Firma tú mismo un payload de ejemplo con tu secreto de webhooks y envíalo a tu endpoint. Si tu código acepta esto y rechaza el mismo cuerpo con un byte cambiado, la verificación es correcta: **Node.js** ```js import crypto from 'node:crypto'; const body = JSON.stringify({ "event": "invoice.completed", "createdAt": "2026-11-08T14:09:41.000Z", "data": { "id": "cmhx3v8qk0004l70gq2b9d7ma", "storeId": "cmhx2a1bc0001l70g5s6t7u8v", "orderNumber": "10245", "orderName": "Plan Pro mensual", "status": "COMPLETED", "overpaid": false, "currency": "USDT_TRC20", "network": "TRC20", "amount": "50.91", "amountReceived": "50.91", "feeAmount": "0.25455", "address": "TQ7m4pVb2Xr9KsN3wJd8YfHc6LtUe1gAzR", "sourceCurrency": "USD", "sourceAmount": "49.9", "rate": "0.9998", "confirmations": 19, "requiredConfirmations": 19, "metadata": { "userId": "u_981", "plan": "pro" }, "paidAt": "2026-11-08T14:09:41.000Z", "expiresAt": "2026-11-08T15:00:00.000Z" } }); const t = Math.floor(Date.now() / 1000); const v1 = crypto.createHmac('sha256', process.env.ORVENPAY_WEBHOOK_SECRET).update(t + '.' + body).digest('hex'); const res = await fetch('http://localhost:3000/api/orvenpay/webhook', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-OrvenPay-Event': 'invoice.completed', 'X-OrvenPay-Delivery': 'test-delivery-1', 'X-OrvenPay-Signature': 't=' + t + ',v1=' + v1, }, body, }); console.log(res.status); // esperado: 200 ``` **Bash (openssl)** ```bash BODY='{"event":"invoice.completed","createdAt":"2026-11-08T14:09:41.000Z","data":{"id":"cmhx3v8qk0004l70gq2b9d7ma","orderNumber":"10245","status":"COMPLETED","currency":"USDT_TRC20","amount":"50.91","amountReceived":"50.91","metadata":null}}' T=$(date +%s) SIG=$(printf '%s' "$T.$BODY" | openssl dgst -sha256 -hmac "$ORVENPAY_WEBHOOK_SECRET" -hex | sed 's/^.* //') curl -i http://localhost:3000/api/orvenpay/webhook \ -H "Content-Type: application/json" \ -H "X-OrvenPay-Event: invoice.completed" \ -H "X-OrvenPay-Signature: t=$T,v1=$SIG" \ --data-raw "$BODY" ``` ### Checklist antes de producción - La API key y el secreto de webhooks están en variables de entorno del servidor, no en el cliente ni en el repositorio. - Las facturas se crean desde el servidor con un `orderNumber` estable por pedido; un reintento tras timeout no duplica facturas. - Manejas `400 AMOUNT_TOO_SMALL` (importe mínimo) y `503 RATES_UNAVAILABLE` (reintento) con mensajes claros. - El webhook verifica `X-OrvenPay-Signature` sobre el cuerpo crudo, con comparación en tiempo constante y ventana de 300 s. - El webhook es idempotente y responde 2xx en menos de 10 s. - El pedido se entrega sólo con `invoice.completed` (no por llegar a `successUrl`). Tienes política para `invoice.mismatch` (pago parcial). - Liberas stock/reservas con `invoice.expired` y `invoice.cancelled`, y aceptas que un `EXPIRED` pase a `COMPLETED` (pago tardío). - La `callbackUrl` es HTTPS, pública y sin redirecciones. - Has hecho un pago real de prueba y has visto el crédito en *Panel → Saldos* (y el retiro a tu libreta, tras la activación de la dirección). - Activaste 2FA en la cuenta y revisaste quién paga comisión y fee de red en la tienda. --- ## 19. Preguntas frecuentes *Respuestas cortas a las dudas más comunes de integración.* - **¿Puedo crear la factura desde el navegador?** No. La API key no debe salir de tu servidor. El navegador sólo recibe `invoiceUrl`. - **¿El cliente puede pagar desde un exchange?** Sí, siempre que envíe el importe exacto por la red correcta antes de `expiresAt`. Algunos exchanges descuentan su comisión del importe: si llega menos de lo esperado (más allá del 0,5 % de tolerancia) la factura acabará en `MISMATCH`. - **¿Qué pasa si paga de menos?** Si lo confirmado alcanza el 99,5 % se completa. Si no, al expirar pasa a `MISMATCH` y se acredita lo recibido; decide tú (entregar, pedir la diferencia con otra factura o reembolsar). - **¿Y si paga de más?** Se completa con `overpaid: true` y se acredita todo lo recibido hasta completar. Si quieres devolver el exceso, hazlo con un retiro. - **¿Y si paga tarde?** Hasta 24 h después de expirar, el pago se procesa y recibes `invoice.completed` (o `invoice.mismatch`). - **¿Puedo cambiar el importe de una factura?** No. Cancela la factura (si está en `NEW`) y crea otra con otro `orderNumber`. - **¿Se puede reutilizar una dirección?** No. Cada factura tiene su propia dirección; un pago a la dirección de una factura antigua cerrada queda para revisión de soporte. - **¿En qué moneda recibo el dinero?** En la misma cripto que pagó el cliente (sin conversión). Retiras cada moneda por separado. - **¿Cuánto tarda en confirmarse?** Depende de la red: TRON ~1 min (19 confirmaciones), BNB Chain ~45 s (15), Ethereum ~3 min (12), Litecoin ~15 min (6), Bitcoin ~30 min (3). - **¿Cuándo puedo retirar lo cobrado?** 1 h después de acreditarse, a direcciones de tu libreta activas (24 h tras añadirlas). - **¿Necesito White Label para usar mi propio checkout?** Sí: sólo las tiendas White Label reciben la dirección e importe por API. Las estándar usan la página de pago alojada. - **¿Puedo usar un plugin existente de pasarela cripto?** Sí, si permite cambiar la URL de la API: usa la API Gateway con base `https://api.orvenpay.com/gateway`. - **¿Por qué no me llegan webhooks?** Revisa que la `callbackUrl` es pública y HTTPS, que no redirige, que responde 2xx en < 10 s y que verificas la firma sobre el cuerpo crudo. El panel muestra el último código HTTP y error de cada entrega. - **¿Hay SDK oficial?** No hace falta: es REST + JSON. Usa la especificación OpenAPI (https://orvenpay.com/openapi.json) para generar un cliente, o las recetas. - **¿Dónde está todo esto para mi agente de IA?** En https://orvenpay.com/llms-full.txt (un único Markdown), https://orvenpay.com/llms.txt (índice) y https://orvenpay.com/openapi.json. - **¿Puedo usar el logotipo para anunciar que aceptáis pagos con OrvenPay?** Sí, sin modificarlo: [logotipo para fondos oscuros](https://orvenpay.com/brand/orvenpay-logo-on-dark.png), [para fondos claros](https://orvenpay.com/brand/orvenpay-logo-on-light.png), [monocromo blanco](https://orvenpay.com/brand/orvenpay-logo-white.png) y [símbolo en SVG](https://orvenpay.com/brand/orvenpay-symbol.svg). --- ## 20. Changelog *Cambios relevantes de la API y la documentación.* *Cambios* | Fecha | Cambio | | --- | --- | | 2026-11-08 | Documentación para agentes de IA: `/llms.txt`, `/llms-full.txt`, `/docs.md` y `/openapi.json` generados desde la misma fuente que esta página. | | 2026-11-08 | Plan **White Label** por tienda (comisión por defecto 1 %): datos de pago completos por API, `whiteLabel` en las facturas, `white_label` en `/gateway/api/v1/shops` y marca propia en el checkout. Las tiendas estándar reciben `address: null` y en `invoices/new` sólo `txn_id`, `invoice_url` e `invoice_total_sum`. `GET /public/config` publica `feePercent` y `whiteLabelFeePercent`. | | 2026-11-08 | La capa de compatibilidad pasa a llamarse **API Gateway**: base `/gateway`, endpoints en `/gateway/api/v1/...` (mismo comportamiento, `api_key` + `verify_hash`). | | 2026-11-07 | Retiros: maduración de créditos (`FUNDS_MATURING`), interruptor de pausa (`WITHDRAWALS_PAUSED`) e idempotencia con `Idempotency-Key`. |