Skip to Content
Integra pagos con terminales POS directamente desde tu app Android 🚀 - Conoce el SDK Android
API EndpointsPagos con 3DSGuía de integración

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.

Menos fraude
La transacción la autoriza el titular real de la tarjeta.
Traslado de responsabilidad
Los contracargos por fraude en pagos autenticados pasan al banco emisor, no a tu comercio.
Sin fricción innecesaria
El banco solo pide challenge cuando su análisis de riesgo lo requiere. Tu integración maneja ambos casos.

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ón

Al 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 header x-idempotency-key es 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.
v1v2
Ruta/api/v1/transactions/api/v2/transactions
x-idempotency-keyOpcional. 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ónSi falta
Comercio con 3DS habilitado por APICobra directamente aunque envíes threeDSParams.
Datos de tarjeta completosNo 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.

HeaderDescripción
X-API-KEYAPI Key generada en Cubo Admin.
x-idempotency-keyUUID v4 único por intento de pago. Se reutiliza en el paso 5.
client-ip-addressIP 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.
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 trae creq y acsURL; 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 status o con cualquier otro valor: la autenticación sigue en curso. Sigue consultando.
  • status: "Y" o "A": autenticación completada. A habilita el cobro igual que Y; tratarlo como fallo deja sin cobrar pagos autenticados. Toma eci, authenticationValue, dsTransId y protocolVersion para 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ámetroTipoDescripción
threeDSEvaluatedbooleanDebe ser true.
ecistringValor de eci del resultado de la autenticación.
xidstringValor de dsTransId del resultado de la autenticación. La respuesta no trae un campo xid propio.
cavvstringValor de authenticationValue del resultado de la autenticación.
dsTransIdstringValor de dsTransId del resultado de la autenticación.
protocolVersionstringValor 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.

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ódigoMensajeQué hacer
409La transaccion esta siendo procesadaHay una solicitud en curso con esa key. Espera y consulta el estado de la key. No reintentes el POST.
422El body del request no coincide con el originalEl cuerpo cambió entre las dos solicitudes. Reenvía el cuerpo exacto del paso 2; solo pueden cambiar los campos de 3DS.
422X-Idempotency-Key header is requiredFalta el header en v2. Inclúyelo en la solicitud.
422X-Idempotency-Key must be a valid UUID v4La key no es un UUID v4. Genérala con crypto.randomUUID().
422client-ip-address header is requiredEl entorno requiere la IP del tarjetahabiente. Envíala en el header client-ip-address.
422threeDSParams should not be emptyTu comercio tiene 3DS habilitado: la solicitud inicial siempre debe incluir threeDSParams.
400Fallo en autenticaciónEl emisor rechazó la autenticación. Detén el flujo.
401API key inválidaRevisa el header X-API-KEY.
429Too Many RequestsSuperaste el límite de consultas del estado de la key. Espera antes de volver a consultar.

Reglas que evitan cobros duplicados

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Usa v2. En v1 el header es opcional y un olvido genera un duplicado silencioso.
  6. Ante un timeout, consulta el estado de la key. Nunca reintentes un POST a 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.