Pagos con Wompi
Integración completa con Wompi para procesar pagos con tarjetas de crédito, débito y PSE en Colombia. Cobros recurrentes automáticos incluidos.
Métodos de Pago Soportados
| Método | Descripción | Recurrente |
|---|---|---|
| 💳 Tarjeta Crédito | Visa, Mastercard, Amex, Diners | ✅ Sí |
| 💳 Tarjeta Débito | Visa Débito, Mastercard Débito | ✅ Sí |
| 🏦 PSE | Transferencia bancaria | ❌ No |
| 💵 Nequi | Billetera digital | ❌ No |
Flujo de Pago
Flujo de Datos de la Tarjeta (Alcance PCI)
El número de tarjeta y el CVV nunca tocan los servidores de PayWL ni los del cliente. Viajan directo del browser del lector a Wompi. Esto es lo que realmente cruza cada frontera del sistema:
| # | De → A | Qué viaja | Con qué credencial |
|---|---|---|---|
| 1 | Browser del lector → Wompi (POST /tokens/cards) |
Número, CVV, expiración, titular | Public Key (pub_...) |
| 2 | Wompi → Browser | card_token (tok_...), de un solo uso |
— |
| 3 | Browser → Engine PayWL (/edge/wompi-subscribe) |
Solo el card_token. Nunca el número de tarjeta. |
Ninguna del lado de pagos — el tenant se resuelve por dominio y el usuario por su user_token de login |
| 4 | Engine PayWL → Wompi (POST /payment_sources) |
El card_token del paso 2 |
Private Key (prv_...), server-to-server |
| 5 | Wompi → Engine PayWL | payment_source_id (referencia numérica opaca) + last_four/brand (metadata no sensible, solo para mostrar "VISA •••• 4242") |
— |
| 6+ | Engine PayWL → Wompi (cobros recurrentes, POST /transactions) |
Solo el payment_source_id guardado. La tarjeta no se vuelve a pedir ni a tocar. |
Private Key, server-to-server |
subscriptions.payment_method_id (el payment_source_id de Wompi) y, en subscription_payments, el wompi_transaction_id/wompi_reference/monto/estado de cada cobro. last_four y brand se devuelven una vez en la respuesta de activación para mostrarlos en pantalla, pero no se guardan en ninguna tabla. Nunca se almacena el número completo ni el CVV — ni en PayWL ni en el cliente.
https://sandbox.wompi.co/v1/tokens/cards (o production.wompi.co en prod), autenticada con la Public Key — es segura de usar en JS público, para eso está diseñada. La Private Key nunca debe salir del backend de PayWL.
Configuración
En el Dashboard, ve a Configuración → Integraciones → Wompi:
- Ingresa tu Public Key (comienza con
pub_) - Ingresa tu Private Key (comienza con
prv_) - Configura el Events Secret para validar webhooks
- Selecciona el ambiente:
sandboxoproduction - Haz clic en "Probar Conexión" para verificar
Cobros Recurrentes
PayWL maneja automáticamente los cobros recurrentes mensuales:
- Tokenización: La tarjeta se tokeniza en el primer pago
- Renovación: 1 día antes del vencimiento se intenta el cobro
- Reintentos: Si falla, se reintenta 3 veces con backoff exponencial
- Notificación: Email al suscriptor si falla el cobro
- Cancelación: Después de 3 fallos se pausa la suscripción
Webhooks de Wompi
PayWL escucha los siguientes eventos de Wompi:
| Evento | Acción en PayWL |
|---|---|
transaction.updated | Actualiza estado del pago |
nequi_token.updated | Guarda token de Nequi |
Configurar Webhook en Wompi
URL: https://engine.paywl.io/webhooks/wompi
Eventos: transaction.updated
Estados de Transacción
| Estado Wompi | Estado en subscription_payments | Descripción |
|---|---|---|
APPROVED | approved | Pago exitoso, suscripción activa |
DECLINED | declined | Pago rechazado |
PENDING | pending | Esperando confirmación (PSE) — se resuelve con un segundo webhook |
Cualquier otro (VOIDED, ERROR, etc.) | error | No procesado / fallido |
Integración del Checkout (para equipos de frontend de cliente)
Flujo de widget hosteado, no de API directa: Wompi tokeniza la tarjeta en un widget que el cliente mismo hostea (PayWL nunca la ve), y al terminar hace un POST real de formulario (no un callback de JS) directo al edge worker de PayWL en el propio dominio del cliente, que activa la suscripción del lado del servidor y redirige de vuelta con el resultado.
CORS_ORIGINS del engine antes de que el equipo de frontend empiece a probar — si no, el fetch del Paso 1 falla por CORS en el browser aunque el endpoint funcione bien por curl.
Paso 1: Obtener el public_key
Endpoint público, sin autenticación:
GET https://engine.paywl.io/edge/config?domain=<su-dominio>
{
"wompi": { "public_key": "pub_...", "sandbox": false }
}
Si wompi viene null, el tenant todavía no tiene Wompi configurado (se hace desde Configuración → Integraciones → Wompi en el Dashboard).
Paso 1.5: Verificar identidad antes de tokenizar (Regla 4, evitar suscripciones duplicadas)
Opcional pero recomendado: antes de montar el widget de Wompi (Paso 3), llamar a este endpoint con el email (y documento de identidad, si ya se capturó en el formulario) para avisarle al usuario si ya existe una suscripción activa asociada a esos datos — antes de que cobre la tarjeta, no después.
POST https://engine.paywl.io/edge/check-identity
Content-Type: application/json
{
"domain": "<su-dominio>",
"email": "[email protected]",
"document_number": "1234567890"
}
Respuesta — una de tres formas, según lo que encuentre:
// Sin ninguna coincidencia — proceder normal
{ "match": "none" }
// Ya existe una suscripción activa con ESTE MISMO email (caso normal de
// re-suscripción/encadenamiento, no es una advertencia — informativo)
{ "match": "same_email", "current_period_end": "2027-03-15T00:00:00.000Z" }
// Existe una suscripción activa con el MISMO documento de identidad pero
// bajo OTRO email — probable duplicado sin querer, mostrar advertencia
{ "match": "different_email", "masked_email": "j•••@gmail.com" }
different_email: mostrarle al usuario algo como "Encontramos una suscripción activa asociada a otro correo (j•••@gmail.com) con el mismo documento de identidad. ¿Es tuya? Si continúas, se creará una suscripción nueva." Si el usuario confirma que quiere continuar de todas formas, seguir al checkout normal pero agregando confirmed_duplicate=true (ver Paso 2 y Paso 4) — sin ese flag, el checkout va a rechazar la suscripción con el mismo error IDENTITY_COLLISION aunque este pre-chequeo ya se haya mostrado.
document_number es opcional en la llamada — si no se manda (por ejemplo, el formulario todavía no lo pidió en ese punto del flujo), solo se resuelve el caso same_email/none. Este endpoint es de solo lectura y no requiere user_token: solo confirma si alguna suscripción coincide, más un email enmascarado en el caso de colisión — no expone a quién pertenece la cuenta más allá de eso.
Paso 2: El formulario
<form id="checkout-form" action="https://<su-dominio>/__paywl/wompi-token-callback" method="POST">
<input type="hidden" name="plan_id" value="<uuid del plan elegido>" />
<input type="hidden" name="payer_email" value="<email del usuario logueado>" />
<input type="hidden" name="payer_name" value="<opcional, ver nota abajo>" />
<input type="hidden" name="document_type" value="<CC | CE | NIT | TI | PASSPORT | OTHER>" />
<input type="hidden" name="document_number" value="<opcional si el tenant no tiene Siigo activo>" />
<input type="hidden" name="trial_policy_id" value="<opcional, si aplica trial>" />
<input type="hidden" name="return_url" value="<ruta relativa a dónde volver, ver nota abajo>" />
<input type="hidden" name="confirmed_duplicate" value="<'true' solo si el usuario ya confirmó continuar tras la advertencia del Paso 1.5>" />
<input type="hidden" name="discount_code" value="<opcional, código de cupón ya validado>" />
<!-- el script de Wompi va ACÁ, ver Paso 3 -->
</form>
user_token no va como campo del form — se resuelve del lado del servidor leyendo la cookie de sesión del usuario en su dominio. No hace falta hacer nada especial más allá del login normal del sitio. confirmed_duplicate se omite (o se manda distinto de "true") en el caso normal — solo se setea a "true" cuando el usuario ya vio la advertencia de identidad duplicada del Paso 1.5 y decidió seguir de todas formas. discount_code es opcional — ver Suscripciones y Trials → Códigos de Descuento para el flujo completo de previsualización y validación.
document_number (cédula/NIT) del suscriptor — sin él, el checkout se rechaza con DOCUMENT_REQUIRED (ver Paso 4) antes de tokenizar la tarjeta, sin cobrar nada. Si el sitio ya conoce el documento del usuario logueado (su propio sistema de perfil), mándenlo directo; si no, pídanlo antes de mostrar el botón de pago. Una vez guardado en el perfil, no hace falta volver a pedirlo en compras futuras del mismo usuario.
payer_name: se guarda en el perfil del usuario la primera vez que se recibe (no se sobrescribe un nombre ya guardado) — PayWL lo usa para armar el contacto/factura en HubSpot y Siigo. Si no se manda nunca, esas integraciones quedan con el nombre en blanco.
return_url: si no se manda (o va vacío), el navegador vuelve a la raíz del dominio. Para volver a una página específica, manden una ruta relativa que empiece con / (ej. /suscribirse/resultado) — se resuelve siempre sobre el mismo dominio del checkout, sin configuración adicional del lado de PayWL. Una URL completa con otro dominio solo se acepta si está en una lista blanca (protección anti-redirect abierto).
Paso 3: Montar el widget de Wompi
<script
src="https://checkout.wompi.co/widget.js"
data-render="button"
data-widget-operation="tokenize"
data-public-key="<public_key del Paso 1>"
></script>
<script> tiene que ser hijo directo del <form>, nunca envuelto en un <div> ni otro contenedor. El widget busca el form con document.currentScript.parentNode — si hay algo en el medio no lo encuentra, y falla sin tirar un error claro. Si se monta dinámicamente por JS, agregarlo con form.appendChild(script) directo sobre el form.
Paso 4: Después del pago
Wompi tokeniza, agrega un campo oculto payment_source_token al form, y lo submitea solo (navegación real de browser, no un fetch). El edge worker recibe ese POST, activa la suscripción, y redirige de vuelta a return_url con query params:
?paywl_status=success&paywl_subscription_id=<uuid>
# o
?paywl_status=error&paywl_message=<motivo>
# o, específicamente cuando el checkout se bloqueó por Regla 4 sin
# confirmed_duplicate=true (ver Paso 1.5 y Paso 2):
?paywl_status=error&paywl_message=<motivo>&paywl_error_code=IDENTITY_COLLISION&paywl_masked_email=j%E2%80%A2%E2%80%A2%E2%80%A2%40gmail.com
# o, si falta el documento y el tenant tiene Siigo activo (ver nota en Paso 2):
?paywl_status=error&paywl_message=<motivo>&paywl_error_code=DOCUMENT_REQUIRED
# o, si el usuario ya tiene una suscripción activa (ver "Ya tiene un plan activo" abajo):
?paywl_status=error&paywl_message=<motivo>&paywl_error_code=ALREADY_SUBSCRIBED&paywl_already_subscribed_subscription_id=<uuid>
paywl_error_code y los campos que lo acompañan (paywl_masked_email, paywl_already_subscribed_subscription_id) solo vienen presentes en su caso puntual — permiten que la página de retorno distinga cada motivo específico de cualquier otro error (tarjeta rechazada, datos faltantes, etc.) sin tener que parsear paywl_message.
Ya tiene un plan activo (ALREADY_SUBSCRIBED)
Si el usuario logueado ya tiene una suscripción activa, el checkout de compra nueva se bloquea antes de cobrar la tarjeta — no cancela la suscripción vieja ni cobra el plan nuevo por encima. En vez de eso, hay que redirigirlo al flujo de cambio de plan (con prorrateo real), que ya existe y no requiere el widget de Wompi de nuevo:
/__paywl/* en su propio dominio. A diferencia del checkout (que hace un POST de formulario real a /__paywl/wompi-token-callback, interceptado por el edge worker), change-plan-preview/change-plan son fetch normales desde el browser a https://engine.paywl.io — mismo patrón que GET /edge/config y POST /edge/check-identity ya documentados arriba. El edge worker no proxya rutas /edge/* en el dominio del cliente, solo /__paywl/* — un POST a https://su-dominio/edge/change-plan-preview nunca le llega a PayWL. Requiere que su dominio esté en CORS_ORIGINS del engine (mismo prerrequisito de la nota al inicio de esta sección).
# 1. Previsualizar el prorrateo (solo lectura)
POST https://engine.paywl.io/edge/change-plan-preview
Content-Type: application/json
{ "domain": "<su-dominio>", "user_token": "<misma cookie de sesión>",
"subscription_id": "<paywl_already_subscribed_subscription_id>",
"new_plan_id": "<el ID (UUID) del plan, NO el slug>" }
new_plan_id debe ser el id (UUID) del plan, nunca su slug. Es un error común mandar el slug porque suele ser lo que se usa en la URL de la pantalla de cambio de plan (ej. ?plan=nombre-del-plan) — hay que resolverlo al UUID real (que GET /edge/plans ya devuelve) antes de llamar a este endpoint. Si se manda el slug, el backend responde 404 y la pantalla solo puede mostrar un error genérico.
{
"type": "upgrade",
"current_plan": { "id": "...", "name": "...", "price_cents": 880000, "currency": "COP", "billing_interval": "monthly" },
"new_plan": { "id": "...", "name": "...", "price_cents": 1290000, "currency": "COP", "billing_interval": "monthly" },
"credit_cents": 848000,
"charge_cents": 442000,
"effective_at": "now"
}
type es "upgrade" (plan más caro), "downgrade" (más barato) o "lateral" (mismo precio — cambio de plan sin ningún efecto financiero). En upgrade, effective_at es "now" — cambia de inmediato y charge_cents se cobra ya, con credit_cents ya descontado por el tiempo no usado del plan actual. En downgrade, effective_at es "next_renewal" — no se cobra nada ahora, el cambio de precio aplica recién en la siguiente renovación. En lateral, effective_at es "now" y credit_cents/charge_cents siempre son 0 — el cambio de plan aplica de inmediato sin cobrar ni acreditar nada, porque el precio no cambia.
Después de mostrarle esto al usuario, si confirma:
# 2. Confirmar — mismo body, endpoint distinto
POST https://engine.paywl.io/edge/change-plan
Content-Type: application/json
{ "domain": "<su-dominio>", "user_token": "<misma cookie de sesión>",
"subscription_id": "<el mismo id>", "new_plan_id": "<el mismo UUID usado en el preview>" }
user_token.
Cómo probar
Tarjetas de test en modo sandbox:
| Tarjeta | Número | Resultado |
|---|---|---|
| Visa Aprobada | 4242 4242 4242 4242 | Aprobado (síncrono) |
| Visa Rechazada | 4111 1111 1111 1111 | Rechazado |
| Pendiente (simula PSE/async) | 4012 8888 8888 1881 | Pendiente → confirma por webhook |
Fecha de expiración y CVC: cualquier valor futuro / 3 dígitos sirve en sandbox.
Checklist de prueba completa:
- El botón de Wompi se renderiza (si no aparece, revisar que el script esté como hijo directo del form — Paso 3)
- Tarjeta Aprobada: la suscripción queda
activede una - Tarjeta Pendiente: queda en estado intermedio hasta que llega el webhook de Wompi, después pasa a
activesola - Tarjeta Rechazada: vuelve a
return_urlconpaywl_status=error - Confirmar en el Dashboard (Suscriptores) que la suscripción nueva aparece con el plan y el email correctos
- Probar estando no logueado: debería fallar (el callback necesita
user_tokende la sesión) — confirmar que el mensaje de error tiene sentido para el usuario final - Probar el caso de identidad duplicada (Paso 1.5): con dos cuentas de prueba que compartan
document_numberpero tengan emails distintos, confirmar quecheck-identitydevuelvedifferent_emailpara la segunda, que el checkout sinconfirmed_duplicatese bloquea conpaywl_error_code=IDENTITY_COLLISION, y que reintentando conconfirmed_duplicate=truesí se crea la suscripción - Si el tenant tiene Siigo activo: probar sin
document_number— confirma que se bloquea conpaywl_error_code=DOCUMENT_REQUIREDsin cobrar la tarjeta, y que reintentando con el documento sí se completa - Confirmar que
return_urlcomo ruta relativa (ej./suscribirse/resultado) efectivamente vuelve ahí, no a la raíz del dominio - Con un usuario que ya tenga una suscripción activa: intentar comprar un plan nuevo — confirma que se bloquea con
paywl_error_code=ALREADY_SUBSCRIBED(ypaywl_already_subscribed_subscription_id) sin cobrar ni cancelar nada, y que/edge/change-plan-previewcon ese ID sí calcula un prorrateo real