Pagos con 3DS
Esta sección explica cómo integrar el flujo de 3D Secure (3DS) al procesar pagos y suscripciones desde tu aplicación.
¿Qué es 3DS?
3D Secure es un protocolo de autenticación para pagos con tarjeta en línea: antes del cobro, el banco emisor puede pedirle al tarjetahabiente que confirme su identidad (el challenge), por ejemplo con un código en su teléfono o desde la app de su banco.
Cómo funciona
Un pago con 3DS puede requerir hasta dos solicitudes al mismo endpoint. El banco emisor decide, según su análisis de riesgo, si el tarjetahabiente debe confirmar su identidad:
- Sin challenge (flujo frictionless). La solicitud inicial autentica y cobra en un solo paso. La respuesta ya trae la transacción y el flujo termina ahí.
- Con challenge. La solicitud inicial devuelve los datos del challenge, el tarjetahabiente lo resuelve con su banco y una segunda solicitud efectúa el cobro.
Tu integración debe manejar ambos casos. El flujo completo, cuando hay challenge, es:
1. Tu frontend recolecta los datos del navegador (threeDSParams)
2. POST /api/v2/transactions → devuelve el challenge
3. El cliente resuelve el challenge con el banco
4. GET /api/v1/transactions/3ds-results/:id → devuelve el resultado de la autenticación
5. POST /api/v2/transactions → efectúa el cobro y devuelve la transacciónAl primer POST lo llamamos solicitud inicial y al segundo solicitud de confirmación.
Ambos POST deben llevar la misma x-idempotency-key. Es lo que le permite a la API saber que
la segunda solicitud continúa la primera y no es un pago nuevo. Si envías una key distinta, o ninguna,
generarás dos transacciones y dos cobros.
La API compara el cuerpo de las dos solicitudes para asegurarse de que se trata del mismo pago. En esa comparación ignora únicamente los campos de 3DS (threeDSParams, threeDSEvaluated, eci, xid, cavv, dsTransId y protocolVersion), para que ambas solicitudes coincidan aunque la segunda traiga el resultado de la autenticación. El resto del cuerpo debe ser idéntico.
Antes de empezar
- Todas las solicitudes deben incluir el header
X-API-KEY. Si aún no has generado tu API Key, revisa nuestra sección de autenticación. - Tu comercio debe tener habilitado 3DS por API. Si no lo está, el endpoint cobra directamente y nunca devuelve un challenge. Para habilitarlo, comunícate con nuestro contact center.
- El flujo utiliza la versión v2 del endpoint de transacciones (
/api/v2/transactions), donde el headerx-idempotency-keyes obligatorio. Si ya integras v1, solo cambia la versión en la ruta y agrega el header. Los endpoints de consulta (3ds-results,status) se mantienen en v1. - El flujo requiere los datos de la tarjeta (
cardNumber,cvv,month,year). No está disponible con tarjeta tokenizada (cardIdentifier). - Las solicitudes a la API de Cubo se envían desde tu backend. Tu frontend solo recolecta los datos del navegador y muestra el challenge del banco; nunca debe conocer tu API Key ni el
authenticationToken. Revisa la guía de seguridad de la integración antes de salir a producción.
| v1 | v2 | |
|---|---|---|
| Ruta | /api/v1/transactions | /api/v2/transactions |
x-idempotency-key | Opcional. Si la omites, la API no avisa y puedes generar duplicados. | Obligatorio. Si falta, recibes un 422. |
Cuándo 3DS no se dispara
Si falta cualquiera de estas condiciones, la API cobra directamente y el flujo termina sin challenge, con un cobro real y no con un error:
| Condición | Si falta |
|---|---|
| Comercio con 3DS habilitado por API | Cobra directamente aunque envíes threeDSParams. |
| Datos de tarjeta completos | No funciona con cardIdentifier. |
Una condición de activación apagada y una solicitud mal armada se ven exactamente igual desde afuera: ambas terminan en un cobro sin challenge. Antes de depurar tu solicitud, confirma que el comercio tenga 3DS habilitado.
Una forma rápida de verificarlo: envía un pago sin threeDSParams. Si el comercio tiene 3DS activo, la
API responde 422 con threeDSParams should not be empty y no procesa nada. Si no lo tiene, el pago se
intenta de verdad, así que usa un monto que no pueda aprobarse.
Flujo de integración
Recolecta los datos del navegador
La solicitud inicial necesita el objeto threeDSParams con información del navegador del tarjetahabiente. Recolecta estos datos en tu frontend y envíalos a tu backend junto con el pago. Todos los campos son obligatorios y todos son string salvo los dos booleanos:
const threeDSParams = {
browserAcceptHeader: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
browserJavaScriptEnabled: true,
browserJavaEnabled: navigator.javaEnabled(),
browserLanguage: navigator.language,
browserColorDepth: String(screen.colorDepth),
browserTZ: String(new Date().getTimezoneOffset()),
browserScreenWidth: String(screen.width),
browserScreenHeight: String(screen.height),
browserUserAgent: navigator.userAgent,
};browserAcceptHeader describe al navegador del tarjetahabiente, no a tu cliente HTTP. No se puede
leer desde JavaScript, así que envía la constante del ejemplo: es el Accept de una navegación real.
Un valor como application/json no corresponde a un navegador y puede hacer que la autenticación
sea rechazada.
Envía la solicitud inicial
Genera una x-idempotency-key (UUID v4) y envía el pago a POST /api/v2/transactions con los mismos campos de procesar pagos más el objeto threeDSParams.
Usa datos de cliente reales y consistentes: el correo debe ser de un dominio existente (evita @example.com y similares) y clientName debería coincidir con cardHolder. Los datos de prueba inconsistentes pueden producir rechazos por reglas de riesgo que no tienen relación con tu integración.
| Header | Descripción |
|---|---|
X-API-KEY | API Key generada en Cubo Admin. |
x-idempotency-key | UUID v4 único por intento de pago. Se reutiliza en el paso 5. |
client-ip-address | IP pública del tarjetahabiente, tomada de la conexión con tu frontend. Puede ser obligatorio según la configuración del entorno; si lo es, omitirlo devuelve un 422. |
JavaScript
const idempotencyKey = crypto.randomUUID(); // Guárdala: se reutiliza en la solicitud de confirmación
const body = {
clientName: 'John Doe',
clientEmail: '[email protected]',
clientPhone: '+50322577777',
description: 'Test API',
amount: 100, // Monto en centavos (100 = $1.00)
cardHolder: 'John Doe',
cardNumber: '4000000000000416',
cvv: '123',
month: '12',
year: '26',
threeDSParams, // Objeto del paso 1
};
fetch('https://<CUBO_API_URL>/api/v2/transactions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-KEY': 'TU_API_KEY_AQUI',
'x-idempotency-key': idempotencyKey,
'client-ip-address': '190.0.0.1', // IP pública del tarjetahabiente
},
body: JSON.stringify(body),
})
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error('Error:', error));Guarda el cuerpo completo de esta solicitud en memoria o en la sesión de tu backend mientras dure
el flujo: deberás reenviarlo tal cual en el paso 5, agregando solo el resultado de la
autenticación. No escribas el cvv ni el número de tarjeta en base de datos ni en logs.
Posibles respuestas
A) Hay challenge. La respuesta incluye authenticationToken. Continúa con el paso 3 y guarda authenticationToken y authenticationResponse.transactionId, ya que los necesitarás para consultar el resultado.
{
"authenticationToken": "eyJhbGciOiJIUzI1NiIs...",
"authenticationResponse": {
"methodURL": "https://acs.banco.com/3ds-method",
"protocolVersion": "2.2.0",
"correlationId": "c-8891",
"transactionId": "a3f9c2b1-...",
"threeDSMethodData": "eyJ0aHJlZURTU2VydmVyVHJhb...",
"scaIndicator": true
}
}En este punto todavía no existe ninguna transacción ni ningún cobro: esta solicitud no genera nada cobrable. Si la repites con la misma key y el mismo cuerpo, recibirás este mismo challenge.
B) No hubo challenge. La respuesta incluye paymentIntentToken. El emisor no exigió challenge y el pago ya se procesó; revisa status para saber si fue SUCCEEDED o REJECTED. El flujo termina aquí: no envíes la segunda solicitud.
{
"paymentIntentToken": "pi_9f8a7b6c",
"status": "SUCCEEDED",
"referenceId": "REF123456",
"authorizationCode": "A1B2C3",
"processedAt": "2026-09-17T14:21:05.113Z"
}Esta respuesta es idéntica a la de un comercio sin 3DS habilitado. Si no esperabas un flujo frictionless, verifica la configuración del comercio antes de buscar el problema en tu integración.
C) La autenticación falló. Recibes un 400 con el mensaje Fallo en autenticación. Detén el flujo y no reintentes con la misma key.
Resuelve el challenge con el banco
Con methodURL y threeDSMethodData de la respuesta anterior, ejecuta el paso de 3DS Method en tu frontend: un iframe oculto que hace POST del campo threeDSMethodData a methodURL. Si la respuesta no trae methodURL, omite este paso.
Luego consulta el resultado de la autenticación (paso 4). Si el emisor pide challenge, muestra al tarjetahabiente un iframe visible que haga POST del campo creq a acsURL.
El challenge ocurre entre el navegador del tarjetahabiente y el banco; la API de Cubo no participa.
Ambos iframes se alimentan igual: un <form> con método POST cuyo target es el iframe, que se envía automáticamente al montarse.
// Monta un iframe y le envía un formulario por POST. Para el 3DS Method el iframe va oculto
// (threeDSMethodData → methodURL); para el challenge va visible (creq → acsURL).
// Devuelve el iframe para que puedas retirarlo después.
const postToIframe = (url, fields, { visible }) => {
const name = visible ? 'threeDSChallenge' : 'threeDSMethod';
// Si ya existe un iframe con este nombre (por ejemplo, al reabrir el modal), lo retira
// antes de montar uno nuevo; de lo contrario se apilan.
document.getElementsByName(name).forEach(existing => existing.remove());
const iframe = document.createElement('iframe');
iframe.name = name;
iframe.hidden = !visible;
iframe.allow = 'payment *; publickey-credentials-get *';
if (visible) Object.assign(iframe.style, { width: '90%', height: '90%', border: '0' });
document.body.appendChild(iframe);
const form = document.createElement('form');
form.method = 'POST';
form.action = url;
form.target = iframe.name;
Object.entries(fields).forEach(([name, value]) => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = name;
input.value = value;
form.appendChild(input);
});
document.body.appendChild(form);
// Espera ~100 ms antes de enviar: el iframe necesita montar su documento para recibir el POST.
setTimeout(() => {
form.submit();
form.remove();
}, 100);
return iframe;
};
// Paso 3a: 3DS Method (oculto). Retíralo a los 15 segundos.
const methodFrame = postToIframe(methodURL, { threeDSMethodData }, { visible: false });
setTimeout(() => methodFrame.remove(), 15000);
// Paso 3b: challenge (visible), cuando el paso 4 devuelve status "C".
// Retíralo cuando el resultado deje de ser "C".
const challengeFrame = postToIframe(acsURL, { creq }, { visible: true });El ejemplo contempla dos detalles que fallan en silencio si se omiten: esperar unos 100 ms antes del submit() para que el iframe termine de montarse, y limpiar el contenedor antes de montar el challenge, porque reabrir el modal apila iframes.
El banco redirige dentro del iframe cuando el tarjetahabiente termina. No necesitas escuchar ese evento: el fin del challenge lo detectas cuando el resultado del paso 4 deja de ser C. Muestra el iframe del challenge centrado y sobre el resto de la página, como un modal.
La ventana para completar el flujo es de 10 minutos. Pasado ese plazo la x-idempotency-key
expira y la solicitud de confirmación se procesa como un pago nuevo. Inicia un flujo nuevo con una
key nueva.
Consulta el resultado de la autenticación
Consulta el endpoint de resultado 3DS enviando el transactionId del paso 2 en la ruta y el authenticationToken en el header 3ds-token. Repite la consulta cada 2 o 3 segundos hasta obtener un estado final:
status: "C": el emisor pide challenge. La respuesta traecreqyacsURL; muestra el iframe del challenge (paso 3) y sigue consultando.status: "D": autenticación fuera del navegador en curso (por ejemplo, en la app del banco). Es un estado transitorio: no detengas el flujo aquí, sigue consultando.- Sin
statuso con cualquier otro valor: la autenticación sigue en curso. Sigue consultando. status: "Y"o"A": autenticación completada.Ahabilita el cobro igual queY; tratarlo como fallo deja sin cobrar pagos autenticados. Tomaeci,authenticationValue,dsTransIdyprotocolVersionpara el paso 5.status: "N","U"o"R": la autenticación falló. Detén el flujo.
dsTransId cambia entre la respuesta C y la final. Toma los valores de la respuesta terminal
(Y o A), no los del challenge. Usar el dsTransId equivocado invalida la solicitud de confirmación.
Envía la solicitud de confirmación
Envía el mismo endpoint, el mismo cuerpo del paso 2 y la misma x-idempotency-key, agregando los resultados de la autenticación.
| Parámetro | Tipo | Descripción |
|---|---|---|
threeDSEvaluated | boolean | Debe ser true. |
eci | string | Valor de eci del resultado de la autenticación. |
xid | string | Valor de dsTransId del resultado de la autenticación. La respuesta no trae un campo xid propio. |
cavv | string | Valor de authenticationValue del resultado de la autenticación. |
dsTransId | string | Valor de dsTransId del resultado de la autenticación. |
protocolVersion | string | Valor de protocolVersion del resultado de la autenticación. |
No hace falta reenviar threeDSParams en esta solicitud: la comparación de cuerpos lo ignora, así que omitirlo no rompe la igualdad con el paso 2.
JavaScript
fetch('https://<CUBO_API_URL>/api/v2/transactions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-KEY': 'TU_API_KEY_AQUI',
'x-idempotency-key': idempotencyKey, // La misma del paso 2
'client-ip-address': '190.0.0.1',
},
body: JSON.stringify({
...body, // El mismo cuerpo del paso 2
threeDSEvaluated: true,
eci: '05',
xid: '1d9a2b6e-0d6b-4f0e-9f11-01',
cavv: 'AAABBJg0VhI0VniQEjRWAAAAAAA=',
dsTransId: '1d9a2b6e-0d6b-4f0e-9f11-01',
protocolVersion: '2.2.0',
}),
})
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error('Error:', error));threeDSEvaluated: true es lo que identifica esta solicitud como la de confirmación. Sin ese campo la
API inicia una autenticación nueva y te devuelve otro challenge.
Respuesta
{
"paymentIntentToken": "pi_9f8a7b6c",
"status": "SUCCEEDED",
"referenceId": "REF123456",
"authorizationCode": "A1B2C3",
"processedAt": "2026-09-17T14:23:40.882Z"
}status puede ser SUCCEEDED o REJECTED. Guarda el paymentIntentToken: es el valor con el que podrás consultar la transacción más adelante. Si repites esta solicitud con la misma key y el mismo cuerpo dentro de los 3 minutos siguientes, recibirás esta misma respuesta sin que se genere un nuevo cobro.
Un REJECTED aquí no es un fallo de 3DS: la autenticación ya se completó y lo que falló es el cobro (fondos insuficientes, límites o restricciones del emisor). Esto también te permite ejercitar el flujo completo sin mover dinero, usando un monto que no pueda aprobarse.
Envía la solicitud de confirmación una sola vez. La API responde 409 mientras hay una solicitud
en curso con la misma key, pero dos solicitudes que lleguen en el mismo instante pueden colarse. El
callback del navegador tras el challenge se dispara más de una vez con facilidad, así que agrega una
protección en tu servidor, no solo en el frontend: una protección en el navegador no sobrevive a un
refresh ni a dos pestañas abiertas.
Suscripciones con 3DS
El flujo es idéntico contra POST /api/v2/transactions/subscription, agregando el campo installments al cuerpo como en pago de suscripción. La solicitud inicial devuelve el mismo objeto con authenticationToken, y la de confirmación devuelve además los datos de la suscripción:
{
"paymentIntentToken": "pi_5d4c3b2a",
"status": "SUCCEEDED",
"referenceId": "REF998877",
"authorizationCode": "Z9Y8X7",
"processedAt": "2026-09-17T14:23:40.882Z",
"nextDueDate": "2026-10-17",
"totalInstallments": 12
}Errores
| Código | Mensaje | Qué hacer |
|---|---|---|
409 | La transaccion esta siendo procesada | Hay una solicitud en curso con esa key. Espera y consulta el estado de la key. No reintentes el POST. |
422 | El body del request no coincide con el original | El cuerpo cambió entre las dos solicitudes. Reenvía el cuerpo exacto del paso 2; solo pueden cambiar los campos de 3DS. |
422 | X-Idempotency-Key header is required | Falta el header en v2. Inclúyelo en la solicitud. |
422 | X-Idempotency-Key must be a valid UUID v4 | La key no es un UUID v4. Genérala con crypto.randomUUID(). |
422 | client-ip-address header is required | El entorno requiere la IP del tarjetahabiente. Envíala en el header client-ip-address. |
422 | threeDSParams should not be empty | Tu comercio tiene 3DS habilitado: la solicitud inicial siempre debe incluir threeDSParams. |
400 | Fallo en autenticación | El emisor rechazó la autenticación. Detén el flujo. |
401 | API key inválida | Revisa el header X-API-KEY. |
429 | Too Many Requests | Superaste el límite de consultas del estado de la key. Espera antes de volver a consultar. |
Reglas que evitan cobros duplicados
- Una key por intento de pago, la misma en las dos solicitudes. Genérala antes del paso 2 y guárdala hasta cerrar el flujo.
- La key la genera tu servidor, nunca el navegador. Si la provee el cliente, el remount de un componente o un reintento automático pueden producir dos keys para el mismo pago, y para la API serían dos pagos.
- Nunca generes una key nueva para la solicitud de confirmación. Es el error que produce el cobro doble, y la API no puede detectarlo.
- No envíes la solicitud de confirmación dos veces. El callback del navegador tras el challenge puede dispararse más de una vez; la protección va en tu servidor, no solo en el frontend.
- Usa v2. En v1 el header es opcional y un olvido genera un duplicado silencioso.
- Ante un timeout, consulta el estado de la key. Nunca reintentes un
POSTa ciegas.
Antes de salir a producción, repasa la guía de seguridad de la integración: qué vive en tu servidor y qué en el navegador, qué no debe aparecer en tus logs y qué revisar antes de producción.