Guía para integradores

Integra Winora en tu plataforma

Winora es una pasarela de pagos para Colombia: orquesta el cobro con adquirentes autorizados y notifica a tu sistema. Cómo llega el dinero depende del modelo del comercio (ver abajo). Esta guía cubre todo lo que necesitas para cobrar, recibir webhooks y reembolsar desde tu sistema.

# Introducción

La integración tiene tres piezas:

  • API de cobros autenticada con una API key del comercio.
  • Checkout alojado (link de pago) o embebido en tu sitio.
  • Webhooks que te notifican cada cambio de estado (firma HMAC).

Todo lo que pidas con una test key corre en modo sandbox — pagos simulados, sin dinero real. Al cambiar a una live key el flujo es idéntico, ya en producción.

Además de la API, Winora trae canales propios que no requieren código de tu lado y comparten la misma cuenta de comercio: el agente de WhatsApp (clientes que cobran desde un chat), la tienda y el cobro rápido. Toda la actividad llega al mismo dashboard y dispara los mismos webhooks.

Cómo llega el dinero

Con la afiliación propia del comercio, el adquirente le liquida directo y el dinero no pasa por Winora. Con la afiliación de Winora, el pago entra al saldo del comercio (con comisión, IVA y retenciones descontados) y el comercio pide el retiro a su cuenta. La API y los webhooks son iguales en los dos modelos. Las credenciales sensibles se cifran con AES-256-GCM.

# Empezar en 5 minutos

  • Crea tu cuenta y registra tu comercio (organización).
  • Solicita los métodos de pago que necesitas (tarjeta, PSE, Nequi…) desde tu panel — el equipo de Winora los habilita por ti.
  • Genera una API key en Organización → API keys. La secret key se muestra una sola vez; guárdala en tus variables de entorno.
  • Configura un endpoint de webhook (opcional pero recomendado).
  • Llama al endpoint de cobros y comparte el link con tu cliente.

SDK de Node.js

npm install @winorapay/node trae los tipos de toda la API, la idempotencia y los reintentos seguros, y la verificación de webhooks. Los ejemplos de esta guía lo muestran en la pestaña Node.js (SDK).

# Autenticación

Cada organización tiene dos tipos de credenciales:

  • wk_test_… / wk_live_… — secret key, sólo en el servidor del integrador. Se devuelve hasheada (SHA-256) en la base de datos.
  • wp_test_… / wp_live_… — publishable key, segura para usar en el navegador (sólo permite mintar form tokens).

Las llamadas al servidor llevan la secret key en el header estándar:

http
Authorization: Bearer wk_test_xxxxxxxxxxxxxxxxxxxx

La URL base es https://api.winorapay.com/api/v1. Todas las respuestas siguen un envelope consistente:

json
// Éxito
{ "ok": true, "data": { ... } }

// Error
{ "ok": false, "error": { "code": "...", "message": "..." } }

# Crear un cobro

POST/api/v1/charges

El monto va en pesos enteros — el peso colombiano no usa decimales: 50000 = $50.000 COP.

bash
curl -X POST https://api.winorapay.com/api/v1/charges \
  -H "Authorization: Bearer wk_test_xxxxxxxx" \
  -H "Idempotency-Key: order-1001" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 50000,
    "currency": "COP",
    "reference": "Factura 1001",
    "description": "Pedido #1001",
    "customerEmail": "cliente@correo.com",
    "customerFirstName": "Ana",
    "customerLastName": "Pérez",
    "customerDocType": "CC",
    "customerDocNumber": "1020304050",
    "customerPhone": "+573001112233",
    "returnUrl": "https://tu-tienda.com/pago/resultado"
  }'

Respuesta

json
{
  "ok": true,
  "data": {
    "transaction": {
      "id": "9b4a…",
      "status": "PENDING",
      "amount": 50000,
      "currency": "COP",
      "reference": "Factura 1001",
      "createdAt": "2026-05-20T15:30:00.000Z"
    },
    "checkoutUrl": "https://app.winora/checkout/9b4a…"
  }
}
La forma más rápida de cobrar es redirigir o compartir el checkoutUrl. Winora mantiene la página de pago, y los datos de la tarjeta se escriben en el formulario del adquirente: no pasan por tu servidor.

Volver a tu sitio (returnUrl)

Pasa returnUrl al crear el cobro y, cuando el pago quede en un estado final, el checkout devuelve al comprador a esa URL con dos parámetros en el query string: winora_tx (id de la transacción) y winora_status (ej. APPROVED).

text
https://tu-tienda.com/pago/resultado?winora_tx=9b4a…&winora_status=APPROVED

Úsalo solo para la experiencia de usuario. La confirmación de la que debes fiarte para entregar el producto es el webhook — el comprador podría cerrar el navegador antes de volver.

Para cobros que se reusan — pagos por WhatsApp, QR en mostrador, datáfono virtual donde el cliente escribe el monto — crea un payment link. Tienes dos tipos:

  • type: "fixed" — monto fijo. Equivalente a un link de pago clásico, pero gestionado por tu cuenta de Winora.
  • type: "open" — monto abierto. El comprador decide cuánto pagar al aterrizar en https://app.winora/pay/<linkId>. Útil para propinas, servicios variables o un "datáfono virtual".

Los links se crean desde el panel

Hoy los links de cobro se crean, editan y desactivan desde el panel del comercio (Links de pago o Cobro rápido), no con la llave de API. Para cobrar desde tu sistema usa POST /api/v1/charges: devuelve el checkoutUrl para redirigir a tu cliente.

Desde el panel puedes mostrar el link como QR, mandarlo por WhatsApp o correo, o ponerlo en tu sitio.

Los pagos hechos a través de un link quedan vinculados a él (campo paymentLinkId en la transacción) para reportes separados.

PSE en links abiertos

Para links de tipo open, recuerda que el comprador puede elegir PSE como método de pago — y los pagos PSE no son reembolsables vía API (es un débito ACH). Si quieres permitir solo tarjeta, configura allowedMethods: ["card"].

# Checkout

Tienes dos formas de cobrar después de crear la transacción — las dos usan la página de pago alojada por Winora:

1) Checkout alojado (recomendado)

Redirige al cliente al checkoutUrl que Winora devolvió. Es responsivo, soporta tarjeta y PSE, y se actualiza al confirmarse el pago. Lo más simple — no necesitas JavaScript adicional.

js
// En tu app, después de recibir la respuesta:
window.location.href = data.checkoutUrl;

2) Sin salir de tu sitio

Si prefieres no sacar al comprador de tu flujo, abre el checkoutUrl en una ventana modal o una pestaña nueva. Al terminar, el comprador vuelve a tu sitio por el returnUrl que definiste al crear el cobro.

js
// Abre el checkout sin abandonar tu página
window.open(data.checkoutUrl, 'winora-checkout', 'width=480,height=720');
La sesión de checkout vence ~15 minutos. Si el comprador abandona, puedes reabrir el checkoutUrl mientras el pago siga en PENDING — Winora reactiva la sesión automáticamente.

# Agente WhatsApp

Tus clientes pueden cobrar dentro de WhatsApp — sin llamarte, sin que tú generes el link, sin salir del chat. Winora corre un agente conversacional sobre un número oficial de WhatsApp Business; el cliente escribe el monto, llena un formulario nativo (Flow) con sus datos y paga con tarjeta o PSE.

Activar el agente para tu comercio

  • Entra a tu dashboard en Agente WhatsApp. Vas a ver un código de comercio tipo WIN-AB12CD y un link wa.me ya pre-rellenado con ese código.
  • Comparte ese link (o el QR que genera la página) con tus clientes — por tu sitio, redes, factura, lo que sea.
  • Cuando un cliente abre el link y manda el primer mensaje, el agente lo asocia automáticamente a tu comercio gracias al código. No hace falta configurar números por tu parte.
Es un solo número Winora para todos los comercios. El agente identifica al comercio destino por el código WIN-XXXXXX que viaja en el mensaje inicial, y vincula la conversación a tu organización hasta que el cliente envíe el código de otro comercio.

Cómo es el flujo para el cliente

  • El cliente abre WhatsApp con tu link y manda el saludo (mensaje pre-rellenado con tu código).
  • El agente le responde con el nombre de tu comercio y un botón Pagar ahora. Al tocarlo se abre un Flow nativo de WhatsApp: monto, concepto, datos.
  • Al confirmar, el agente le envía un botón Abrir checkout que lleva a la página de pago alojada por Winora (la misma que describimos arriba).
  • Cuando el pago queda APPROVED, el agente notifica al cliente en el chat — y a tu sistema por webhook (evento payment.approved, idéntico a cualquier otro canal).

Qué ves tú

  • Métricas del agente: conversaciones, pagos creados desde WhatsApp, tasa de conversión, monto cobrado por este canal.
  • Feed de conversaciones (con teléfono enmascarado para preservar la privacidad del cliente) y trazabilidad de cada link generado.
  • Los pagos creados por el agente aparecen en /transacciones como cualquier otro cobro, con su paymentLinkId y el reference que tu cliente escribió en el Flow.

Multi-tenant en V2

En esta fase Winora opera con un único número WhatsApp (de marca Winora) compartido entre todos los comercios. Próximamente: asignar un número WhatsApp Business propio por comercio.

# Winora Tap (Android)

En desarrollo

Winora Tap, la app Android que convierte el celular del comercio en un datáfono (el cliente acerca su tarjeta o su celular), está en desarrollo y todavía no está disponible para los comercios.

Cuando salga, sus cobros quedarán en el dashboard como cualquier otra transacción, con las mismas reglas de antifraude, webhooks y reembolsos. No tendrá API pública propia: si integras por API, puedes ignorar esta sección.

# Consultar y listar

GET/api/v1/charges/:id
GET/api/v1/charges?status=APPROVED&q=Factura&page=1&pageSize=20

Filtros soportados: status (PENDING, APPROVED, DECLINED, VOIDED, ERROR, REFUNDED, EXPIRED, ABANDONED) y q (búsqueda parcial en reference). Paginación con page y pageSize (máx 100).

bash
# Un cobro
curl https://api.winorapay.com/api/v1/charges/9b4a… \
  -H "Authorization: Bearer wk_test_xxxxxxxx"

# Listado con filtros
curl "https://api.winorapay.com/api/v1/charges?status=APPROVED&q=Factura&page=1&pageSize=20" \
  -H "Authorization: Bearer wk_test_xxxxxxxx"

# Webhooks salientes

Configura uno o más endpoints en Organización → Webhooks. Winora hace POST a tu URL cuando una transacción cambia de estado. Cada endpoint tiene un secret propio (whsec_…) que firma cada entrega.

Eventos disponibles

Cada endpoint recibe todos los eventos de tu cuenta. Filtra por type y responde 2xx a todo, también a los que no uses: cualquier otra respuesta se reintenta. Los de cartera y suscripciones solo salen con el módulo activo.

EventoCuándodata
payment.approvedEl cobro se aprobó.{ transaction }
payment.declinedEl banco o la red rechazaron el cobro.{ transaction }
payment.voidedEl cobro se anuló.{ transaction }
payment.errorFalla terminal al procesar el cobro.{ transaction }
payment.refundedSe aprobó un reembolso (total o parcial).{ transaction }
payment.expiredEl cobro se cerró sin pago: hubo un intento que nunca se completó. Si el banco aprueba después, llega payment.approved.{ transaction }
payment.abandonedEl cliente abrió el pago y se fue sin intentar pagar (en PSE, segundos después). Si aun así se aprueba, llega payment.approved.{ transaction }
receivable.createdSe creó una deuda.{ receivable, eventId }
receivable.partially_paidEntró un abono.{ receivable, eventId }
receivable.paidLa deuda quedó saldada.{ receivable, eventId }
receivable.overdueLa deuda se venció.{ receivable, eventId }
receivable.voidedLa deuda se anuló.{ receivable, eventId }
receivable.reopenedEl saldo volvió a subir (reembolso o pago quitado).{ receivable, eventId }
subscription.createdSe creó una suscripción.{ subscription, plan, invoice? }
subscription.chargedSe cobró un período.{ subscription, plan, invoice }
subscription.payment_failedFalló el cobro de un período (se reintenta).{ subscription, plan, invoice }
subscription.payment_method_requiredEl cobro ya falló varias veces: pide al cliente otro medio de pago.{ subscription, plan }
subscription.canceledSe canceló la suscripción.{ subscription, plan? }

Cuerpo y headers

json
{
  "id": "<id de la entrega>",
  "type": "payment.approved",
  "createdAt": "2026-05-20T21:40:00.000Z",
  "data": {
    "transaction": {
      "id": "9b4a…",
      "status": "APPROVED",
      "amount": 50000,
      "reference": "Factura 1001",
      "cardLast4": "0002",
      "cardBrand": "VISA"
    }
  }
}
http
Winora-Signature: t=1715890800,v1=<hmac_sha256_hex>
Winora-Event: payment.approved
Content-Type: application/json

Verificar la firma

Recalcula HMAC_SHA256(secret, "<t>." + rawBody) y compara con v1 en tiempo constante. Usa el cuerpo crudo (no el JSON parseado), o la firma no coincidirá. t es la hora del envío: rechaza las firmas de más de 5 minutos para que nadie te repita una entrega vieja.

javascript
import express from 'express';
import { webhooks, WinoraSignatureError } from '@winorapay/node';

const app = express();
const SECRET = process.env.WINORA_WEBHOOK_SECRET; // whsec_…

// La firma va sobre el cuerpo TAL CUAL llegó: express.raw, no express.json.
app.post('/winora/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
  let event;
  try {
    // Verifica la firma y la ventana de 5 minutos, y devuelve el evento tipado.
    event = webhooks.constructEvent(req.body, req.get('Winora-Signature'), SECRET);
  } catch (e) {
    if (e instanceof WinoraSignatureError) return res.status(400).end();
    throw e;
  }
  // Responde 2xx de una vez y procesa aparte: tienes 10 segundos.
  res.status(200).end();

  if (await yaProcesado(event.id)) return; // el mismo evento puede llegar más de una vez
  if (event.type === 'payment.approved' && event.data.transaction.mode === 'live') {
    await entregarPedido(event.data.transaction);
  }
  await marcarProcesado(event.id);
});

Reintentos

Si tu endpoint no responde 2xx en 10 segundos, Winora reintenta con backoff 1m → 5m → 30m → 2h → 6h (hasta 6 intentos) y, si se agotan, avisa por correo a los dueños de la cuenta. Winora no sigue redirecciones: la URL tiene que responder directamente. Puedes ver el log y reenviar manualmente desde el panel.

# Reembolsos

POST/api/v1/charges/:id/refunds
GET/api/v1/charges/:id/refunds

Reembolsos totales o parciales sobre un cobro APPROVED. Cuando la suma de reembolsos aprobados llega al total, la transacción pasa a REFUNDED.

bash
curl -X POST https://api.winorapay.com/api/v1/charges/9b4a.../refunds \
  -H "Authorization: Bearer wk_test_xxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "amount": 2500000, "reason": "Cliente solicitó devolución" }'
  • amount en pesos enteros. Si lo omites, reembolsa el saldo disponible.
  • No puede exceder transaction.amount - transaction.refundedAmount.
  • Cuando se aprueba dispara el evento payment.refunded.

PSE no es reembolsable por API

En Colombia, PSE es un débito ACH y la devolución se hace típicamente manualmente (transferencia del comercio o gestión bancaria). El adquirente responde transaction can't be refunded y Winora registra el intento como failed en el ledger. Los reembolsos automáticos aplican a tarjetas (Visa/MC/Amex).

# Cartera · API

Para que tu sistema cargue y siga la cartera sin entrar al panel. Necesita el módulo de Cartera activo. Si eres el comercio y no el integrador, empieza por la guía de Cartera.

Montos en pesos enteros

En Cartera los montos van en pesos: 150000 son $150.000 (no en centavos como en /charges). El modo lo decide la llave: con wk_test_ solo ves y creas deudas de prueba; con wk_live_, las reales. Una deuda de otro comercio o del otro modo responde 404, igual que una que no existe.
Método y rutaQué hace
POST /api/v1/receivablesCrea una deuda (y su deudor, si no existe)
POST /api/v1/receivables/batchCrea hasta 200 deudas; cada una se valida por su lado
GET /api/v1/receivablesLista, con filtros y paginación
GET /api/v1/receivables/:idUna deuda
PATCH /api/v1/receivables/:idCambia concepto, referencia, monto, vencimiento…
POST /api/v1/receivables/:id/voidAnula la deuda (y cancela su cobro en línea abierto)
POST /api/v1/receivables/:id/paymentsRegistra un pago recibido por fuera (caja, consignación)
POST /api/v1/debtorsCrea o actualiza un deudor y da su estado de cuenta

Crear una deuda

http
POST /api/v1/receivables
Authorization: Bearer wk_test_xxxxxxxx
Idempotency-Key: deuda-JUR-1001
Content-Type: application/json

{
  "debtor": { "fullName": "Carlos Pérez", "docType": "CC", "docNumber": "7001002003",
              "email": "carlos@correo.com", "phone": "3001234567" },
  "concept": "Cuota crédito octubre",
  "externalRef": "JUR-1001",
  "amount": 150000,
  "dueDate": "2026-11-30",
  "projectCode": "sede-norte"
}

Respuesta (dentro de data):

json
{
  "id": "…", "externalRef": "JUR-1001", "concept": "Cuota crédito octubre",
  "amount": 150000, "chargesTotal": 0, "balance": 150000, "currency": "COP",
  "dueDate": "2026-11-30", "status": "open", "overdue": false, "mode": "test",
  "debtor": { "id": "…", "fullName": "Carlos Pérez", "docType": "CC", "docNumber": "7001002003",
              "email": "carlos@correo.com", "phone": "3001234567" },
  "project": "sede-norte",
  "form": null,
  "payUrl": "https://pagos.winorapay.com/cartera/…",
  "paidAt": null, "voidedAt": null, "createdAt": "…"
}
  • payUrl es el link de pago para mandarle al deudor; viene null cuando la deuda ya no se puede pagar (pagada o anulada).
  • status: open, partially_paid, paid o voided.
  • externalRef es única por comercio y modo entre las deudas no anuladas: repetirla da 409 collections/external-ref-taken. Distingue mayúsculas y guiones (JUR-1001 ≠ jur1001).
  • projectCode (opcional): el código de uno de tus proyectos; sin él, o con "principal", va al principal. Uno que no existe responde 400 collections/project-not-found.
  • El deudor se reconoce por su documento. Con una llave de prueba no se puede usar el documento de alguien con deudas reales (409 collections/debtor-has-live-debts): usa documentos de prueba.
  • form viene cuando la deuda salió de un formulario de recaudo: { id, name, answers: [{ label, value }] }, con lo que escribió quien pagó. Esas deudas se anulan solas si no se pagan en 24 horas, y reviven si el pago se aprueba tarde: no trates voided como final en las que traen form.

Lote, listar, pagos por fuera y deudores

  • Lote ({ "items": [ … hasta 200 … ] }, hasta 512 KB): responde 200 aunque algunas fallen, con created, failed y un resultado por cada una en el mismo orden.
  • Listar: ?externalRef=&status=&debtorDoc=&dueFrom=&dueTo=&page=1&pageSize=25 (pageSize hasta 100; fechas AAAA-MM-DD).
  • Pago por fuera: { "amount": 50000, "method": "cash", "paidOn": "2026-11-01", "note": "…" }, con method entre cash, transfer, deposit, card_terminal u other. No puede pasar del saldo ni tener fecha futura.
  • Deudor: el mismo cuerpo que debtor. Si ya existe, se actualiza su contacto; la respuesta trae statementUrl, su estado de cuenta.

# Cartera · Webhooks

Vienen apagados. Se encienden en Cartera → Configuración → Integraciones; lo que pasó mientras estaban apagados no se envía. Llegan a los mismos endpoints y con la misma firma que los de pagos (ver Webhooks salientes).

EventoCuándo
receivable.createdSe creó la deuda (panel, CSV, recurrente, formulario o API)
receivable.partially_paidEntró un abono (en línea o registrado por fuera)
receivable.paidLa deuda quedó pagada
receivable.overdueVenció sin pagarse (una vez, a las 00:30 de Bogotá)
receivable.voidedSe anuló
receivable.reopenedEl saldo volvió a subir (un reembolso, o se quitó un pago registrado)
  • data.receivable tiene la forma de la respuesta de la API y trae el estado al momento de enviar; data.eventId identifica el cambio (el mismo en cada reintento): úsalo para descartar repetidos.
  • Salen en hasta un minuto; con muchos cambios se reparten en los minutos siguientes. El orden no está garantizado: guíate por data.receivable.
  • Responde 2xx también a los eventos que no uses: Winora puede sumar tipos nuevos.

# Cartera · Consulta en línea

Si tu cartera vive en tu sistema, el portal de pagos le puede preguntar a él qué debe cada persona, en el momento, en vez de que la cargues. Tú expones una URL; Winora la llama cuando un deudor busca en tu portal. Se configura en Cartera → Configuración → Integraciones: el portal busca solo en lo cargado, solo en tu sistema, o en las dos (primero lo cargado). Ahí mismo está Probar consulta, que llama a tu sistema sin guardar nada.

Lo que Winora te manda

http
POST https://tu-sistema.com/winora/cartera
Content-Type: application/json
Authorization: Bearer <tu token>          (o Basic, si lo configuraste)
Winora-Signature: t=<unix_ts>,v1=<hmac_sha256_hex>
Winora-Event: collections.lookup

{ "requestId": "…", "query": "1020304050", "lookupBy": "doc", "mode": "live" }
  • query llega sin espacios, puntos ni guiones, en mayúsculas. lookupBy es lo que pide tu portal: doc, ref o both. mode es live o test, según el modo del comercio.
  • Verifica la firma: HMAC_SHA256(secreto, "<t>." + cuerpo) sobre el cuerpo tal cual llegó, comparado en tiempo constante con v1, y rechaza un t de más de 5 minutos. El secreto (olsec_…) se muestra una sola vez al guardar la configuración o al rotarlo.

Lo que respondes

json
{
  "debtor": { "fullName": "Ana Gómez", "docType": "CC", "docNumber": "1020304050",
              "phone": "3001234567", "email": "ana@correo.com" },
  "debts": [
    { "externalRef": "CR-1001", "concept": "Cuota noviembre", "balance": 120000, "dueDate": "2026-11-30" }
  ]
}
  • 200, JSON, máximo 256 KB y 50 deudas. Sin deudas con ese dato: 404, o 200 con "debtor": null explícito. Una respuesta sin la llave debtor (por ejemplo envuelta en data) es un error de formato y no se toca nada.
  • debts es obligatorio cuando hay deudor (manda [] si no debe nada). balance es lo que debe hoy, en pesos enteros (de $1.000 a $50.000.000); con 0 la línea se ignora. Una línea mal escrita se descarta y la ves como aviso en Configuración.
  • Buscando por documento, devuelve todas las deudas pendientes de esa persona: lo que no vuelva se da por pagado o anulado (nunca si hubo alguna línea descartada). Por referencia, basta la de esa referencia.
  • El celular o el correo sirven para verificar a la persona: el portal le pide los últimos 4 del celular o su correo y los compara con lo que devolviste. Sin ese dato, no puede entrar.
  • Responde dentro del tiempo que configuraste (1 a 10 s). Ante caída, tiempo agotado, 5xx o 429, Winora reintenta lo configurado (0 a 2). No sigue redirecciones.
  • Antes de cobrar, si la última consulta tiene más de 2 minutos, Winora te vuelve a preguntar. Cuando el deudor paga, te llega receivable.paid: marca el pago en tu sistema. Winora no manda recordatorios de estas deudas.

Un servidor de ejemplo

Verifica la firma, busca en tu sistema y responde el formato de arriba. buscarCliente es tu consulta a tu base de datos.

javascript
import express from 'express';
import crypto from 'node:crypto';

const app = express();
const SECRET = process.env.WINORA_LOOKUP_SECRET; // olsec_…

// La firma va sobre el cuerpo TAL CUAL llegó: léelo crudo, sin reformatear el JSON.
app.post('/winora/cartera', express.raw({ type: 'application/json' }), async (req, res) => {
  const raw = req.body.toString('utf8');
  const parts = Object.fromEntries(
    (req.get('Winora-Signature') ?? '').split(',').map((p) => p.split('=')),
  );
  const t = Number(parts.t);
  const expected = crypto.createHmac('sha256', SECRET).update(`${t}.${raw}`).digest('hex');
  // v1 tiene que ser hex de 64: así timingSafeEqual nunca recibe largos distintos.
  const valid =
    Number.isInteger(t) &&
    /^[0-9a-f]{64}$/.test(parts.v1 ?? '') &&
    crypto.timingSafeEqual(Buffer.from(parts.v1, 'hex'), Buffer.from(expected, 'hex'));
  if (!valid || Math.abs(Date.now() / 1000 - t) > 300) return res.status(401).end();

  const { query, lookupBy, mode } = JSON.parse(raw);
  const cliente = await buscarCliente(query, lookupBy, mode); // tu sistema
  if (!cliente) return res.json({ debtor: null });

  res.json({
    debtor: {
      fullName: cliente.nombre,
      docType: 'CC',
      docNumber: cliente.documento,
      phone: cliente.celular,
      email: cliente.correo,
    },
    debts: cliente.pendientes.map((d) => ({
      externalRef: d.referencia, // única por deuda
      concept: d.concepto,
      balance: d.saldo, // pesos enteros, lo que debe HOY
      dueDate: d.vence, // AAAA-MM-DD
    })),
  });
});

app.listen(3000);

# Suscripciones · API

Cobros recurrentes con tarjeta: defines planes y Winora cobra cada período a la tarjeta que el cliente guarda al suscribirse. Necesita el módulo de Suscripciones activo en tu cuenta; sin él, crear un plan o una suscripción responde subscriptions/module-not-active.

Cómo se arma

  • Crea el plan con POST /subscription-plans.
  • El cliente se suscribe en la página alojada por Winora: https://secure.winorapay.com/subscribe/<planId>. Ahí paga el primer período y guarda su tarjeta; tú no tocas datos de tarjeta.
  • Sigue cada cobro con los webhooks subscription.*.
  • Gestiona desde tu sistema: pausar, reanudar, cambiar de plan o cancelar.

POST /subscriptions crea una suscripción directo solo si ya tienes un paymentTokenId (una tarjeta guardada del cliente en Winora). Lo normal es la página alojada.

Endpoints

POST/api/v1/subscription-plans
GET/api/v1/subscription-plans?includeInactive=true
GET/api/v1/subscription-plans/:planId
POST/api/v1/subscriptions
GET/api/v1/subscriptions?status=active&planId=…
GET/api/v1/subscriptions/:subId
GET/api/v1/subscriptions/:subId/invoices
POST/api/v1/subscriptions/:subId/pause
POST/api/v1/subscriptions/:subId/resume
POST/api/v1/subscriptions/:subId/cancel
POST/api/v1/subscriptions/:subId/change-plan

Crear un plan

bash
curl -X POST https://api.winorapay.com/api/v1/subscription-plans \
  -H "Authorization: Bearer wk_test_xxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Plan mensual",
    "amountMinor": 49900,
    "interval": "month",
    "intervalCount": 1
  }'

amountMinor va en pesos enteros

A pesar del nombre, amountMinor son pesos colombianos enteros: 49900 son $49.900. El período es interval (day, week, month, year) por intervalCount (1 a 12).

Estados

EstadoQué significa
activeAl día. Winora cobra en nextBillingDate.
past_dueFalló el último cobro. Se reintenta a los 3, 7 y 14 días (luego cada 7); a los 30 días del primer fallo se cancela.
pausedPausada: no se cobra hasta que la reanudes.
canceledTerminada.
  • cancel recibe { "atPeriodEnd": true } por defecto: el período pagado se respeta y luego termina. Con false termina de una vez.
  • change-plan aplica desde el próximo período, sin prorrateo.
  • Winora le manda al cliente, por correo, el enlace a su portal: ahí ve su plan y puede pausar o cancelar. Tú no tienes que gestionarlo.

Manda Idempotency-Key al crear

Una suscripción duplicada se cobra dos veces cada período. Manda una Idempotency-Key al crear planes y suscripciones (y en pausar, reanudar, cancelar y cambiar de plan): si la red se corta y reintentas con la misma llave, Winora devuelve la que ya creó. Ante un 5xx, consulta con GET /subscriptions?planId=… antes de reintentar con una llave nueva.

# Antifraude

Cada cobro se evalúa contra las reglas que configuras en Organización → Riesgo: límites por monto/día, velocity por email e IP, listas de bloqueo y permitidos, y bandera para forzar 3DS. Si una regla bloquea, el endpoint responde:

json
// HTTP 422 Unprocessable Entity
{
  "ok": false,
  "error": {
    "code": "risk_blocked",
    "message": "Pago rechazado por reglas de riesgo"
  }
}

La respuesta no dice qué regla bloqueó (eso solo le sirve a quien intenta saltársela). Lo que puede bloquear un cobro:

  • Correo, IP o documento en la lista de bloqueo del comercio.
  • Un monto mayor que el tope por transacción.
  • Pasar el acumulado o el número máximo de pagos del día.
  • Demasiados intentos seguidos desde el mismo correo o la misma IP.

El motor también puede decidir review (forzar 3DS); en ese caso el cobro no se bloquea, sólo queda marcado para revisión interna del comercio.

# Idempotencia

Para evitar cobros duplicados ante un reintento de red, envía un Idempotency-Key único por intento (o usa el campo idempotencyKey del body). Si Winora ya tiene una transacción con esa clave para tu organización, devuelve la existente en lugar de crear otra.

http
POST /api/v1/charges
Authorization: Bearer wk_test_xxxxxxxx
Idempotency-Key: order-1001
Content-Type: application/json

# Errores

Todos los errores siguen el mismo sobre:

json
{ "ok": false, "error": { "code": "payment/invalid-customer-email", "message": "El correo del comprador no es válido: el dominio «gmail.con» no existe. ¿Quisiste decir gmail.com?" } }

Decide en tu código por code, que no cambia. message está en español. Los 4xx son datos o estados que hay que corregir: reintentar igual da el mismo error.

Un 5xx deja el intento registrado como fallido: repetirlo con la misma Idempotency-Key te devuelve ese fallo, no un intento nuevo. Consulta el estado (GET /charges/:id, GET /charges/:id/refunds, GET /receivables, GET /subscriptions?planId=…) y, si no quedó hecho, reintenta con una llave nueva. La misma llave es para cuando no recibiste ninguna respuesta (red caída o timeout). En un reembolso, revisa su status: un ok: true puede traer un reembolso failed.

Ojo: la misma llave devuelve la respuesta original, no el estado de ahora. Si pausaste una suscripción con una llave, la reanudaste y repites esa pausa, la respuesta dice "pausada" sin volver a pausarla. Para el estado actual, consulta con GET.

Códigos

HTTPCódigoQué pasóQué hacer
401api/missing-keyFalta la cabecera Authorization: Bearer <llave>.Corregir y reenviar
401api/invalid-keyLa llave no existe o fue revocada; o la cuenta está suspendida o cerrada; o es una llave wk_live_ y la verificación del comercio no está aprobada.Corregir y reenviar
429api/too-many-failed-attemptsDemasiados intentos con llaves inválidas desde tu IP.Esperar y reintentar
429api/rate-limitedSuperaste el límite de peticiones de tu llave.Esperar y reintentar
400validation/invalid-inputUn campo no cumple el formato. El mensaje dice cuál.Corregir y reenviar
400request/invalid-idempotency-keyLa Idempotency-Key no tiene un formato válido (8 a 100 caracteres).Corregir y reenviar
409request/idempotency-mismatchReusaste una Idempotency-Key con datos distintos.Corregir y reenviar
409request/in-progressUna petición con la misma Idempotency-Key todavía se está procesando.Esperar y reintentar
409request/indeterminateUn intento anterior con esa Idempotency-Key terminó en error y no se sabe si quedó hecho (cartera).Consultar el estado; si no quedó hecho, reintentar con una llave nueva
400payment/invalid-customer-emailEl correo del comprador no es válido (p. ej. gmail.con). El mensaje sugiere la corrección: muéstraselo al comprador.Corregir y reenviar
409payment/idempotency-mismatchYa existe un cobro con esa llave de idempotencia y otros datos o modo.Corregir y reenviar
409payment/already-processedYa existe un cobro con esa llave y ya no está pendiente. Búscalo con GET /charges?q=<referencia>: si quedó en ERROR, crea otro con una llave nueva.Consultar el estado; si no quedó hecho, reintentar con una llave nueva
422risk_blockedEl antifraude bloqueó el cobro.No reintentar
404payment/not-foundEl cobro no existe o no es de tu cuenta.No reintentar
502payment/provider-errorLa pasarela no respondió o falló. El cobro queda en ERROR.Consultar el estado; si no quedó hecho, reintentar con una llave nueva
400refund/invalid-amountEl monto es mayor que lo que queda por reembolsar.Corregir y reenviar
409refund/not-refundableEl cobro no se puede reembolsar (no está aprobado).No reintentar
409refund/concurrent-conflictHay otro reembolso del mismo cobro en curso.Esperar y reintentar
502refund/provider-errorLa pasarela no respondió al reembolso. Revisa su status en GET /charges/:id/refunds: no basta con ok.Consultar el estado; si no quedó hecho, reintentar con una llave nueva
403collections/module-not-activeEl módulo de Cartera no está activo en la cuenta.No reintentar
404collections/not-foundLa deuda no existe o no es de tu cuenta.No reintentar
409collections/external-ref-takenYa hay una deuda con esa referencia externa.No reintentar
409collections/not-editableLa deuda ya no se puede editar (pagada, anulada o con pagos).No reintentar
409collections/not-voidableLa deuda ya no se puede anular.No reintentar
409collections/amount-exceeds-balanceEl pago registrado es mayor que el saldo.Corregir y reenviar
403subscriptions/module-not-activeCrear un plan o una suscripción sin el módulo de Suscripciones activo.No reintentar
404plan/not-foundEl plan no existe o no es de tu cuenta.No reintentar
409plan/inactiveEl plan está desactivado.No reintentar
404subscription/not-foundLa suscripción no existe o no es de tu cuenta.No reintentar
400subscription/invalid-statusEl filtro status no es uno de active, past_due, paused o canceled.Corregir y reenviar
409subscription/already-canceledPausar una suscripción que ya está cancelada.No reintentar
409subscription/canceledCambiar de plan una suscripción cancelada.No reintentar
409subscription/not-pausedSolo se puede reanudar una suscripción pausada.No reintentar

# Buenas prácticas

Lo que más problemas evita en producción:

  • No reintentes un 4xx. Un correo mal escrito o un monto fuera de rango falla igual las veces que lo mandes. En los errores de datos de quien paga (como payment/invalid-customer-email o validation/invalid-input) puedes mostrarle el message para que corrija; los demás, mejor tradúcelos a tu propio mensaje.
  • Manda Idempotency-Key en cada escritura (cobros, reembolsos, cartera y suscripciones). Si la red se corta sin respuesta y reintentas con la misma llave, Winora devuelve lo que pasó en vez de crear otro. Ante un 5xx, consulta y usa una llave nueva (ver Errores).
  • Responde 2xx a todo webhook, también a los eventos que no uses (por ejemplo payment.declined). Un 500 por un evento que no esperabas se reintenta seis veces y termina en un aviso de "webhook fallando".
  • Responde rápido y procesa aparte. Tienes 10 segundos; si tu proceso es largo, guarda el evento, responde y procésalo después.
  • Procesa cada cambio una sola vez. Un evento puede llegar repetido: los reintentos traen el mismo id de entrega, pero cada endpoint recibe su propia entrega con otro id. Descarta repetidos por lo que cambió:
    • Cobros: type + transaction.id; en payment.refunded, súmale refundedAmount, porque cada reembolso parcial del mismo cobro trae su propio evento.
    • Suscripciones: type + invoice.id en los eventos que traen factura (cada cobro trae la suya); type + subscription.id solo en created, canceled y payment_method_required.
    • Cartera: eventId.
    Más simple todavía: guarda siempre el estado más reciente del objeto que llega, o usa un solo endpoint por sistema.
  • Separa pruebas de producción. Los eventos de las llaves wk_test_… llegan a los mismos endpoints y bien firmados. En producción ignora los que traen mode: "test" (en transaction, subscription o receivable).
  • Confía en el webhook, no en el regreso del comprador. El comprador puede cerrar el navegador antes de volver a tu sitio; el webhook llega igual. Para confirmar un estado en cualquier momento, consulta GET /charges/:id.
  • Verifica la firma siempre, con el cuerpo crudo, y rechaza las de más de 5 minutos.
  • Prueba con llaves de prueba. wk_test_… no mueve plata y dispara los mismos webhooks (con mode: "test"); pasa a wk_live_… cuando todo funcione.

# Referencia OpenAPI

Todos los endpoints con llave de API, sus campos, respuestas y los eventos de webhook, en formato OpenAPI 3.1: para importarlo en Postman, Insomnia o generar un cliente en tu lenguaje. Sale del mismo código que valida cada petición, así que no se desactualiza.

Probar en Postman

Una colección con todos los endpoints, la llave por variable y ejemplos que pasan la validación, más un entorno de pruebas:

  • Colección de Winora y entorno de pruebas.
  • En Postman: Import → Link y pega https://merchant.winorapay.com/docs/winora.postman_collection.json (y el entorno con su enlace, o el archivo descargado).
  • Elige el entorno Winora · pruebas, pon tu llave wk_test_… en apiKey y envía Crear un cobro: la respuesta trae el checkoutUrl para pagar con datos de prueba.
¿Te falta algo en la documentación? Escríbenos — soporte@winorapay.com.

© 2026 Winora · Pasarela de pagos · Hecho en Colombia.