ROKIConnect

Modo 2 - Componentes embebidos

Campos de tarjeta renderizados por ROKI dentro de un iframe seguro en tu propio sitio. El cliente nunca sale de tu página, y el número de tarjeta nunca toca tu código.

22.1 El flujo

1. Browser loads the ROKI SDK and mounts the component with pk_* only
2. Customer fills the card fields (inside ROKI's iframe)
3. Your "pay" button calls payment.submit()
4. The SDK returns a single-use tok_* to your page
5. Your page posts that tok_* to YOUR OWN backend
6. Your backend calls POST /api/connect/embed/confirm with sk_* + tok_* + amount
7. You get approved / declined / pending (3-D Secure)

El monto lo define tu backend en el paso 6, no el navegador. Montar el componente no necesita que exista un pago de antemano.

22.2 Frontend

<script src="https://aura.roki.systems/connect/components/v1/roki.js"></script>

<form id="order-form">
  <div id="roki-payment"></div>
  <input type="hidden" name="roki_payment_token" id="roki_payment_token">
  <button type="submit" id="buy-now" disabled>Buy now</button>
</form>

<script>
  const roki = RokiConnect({ publishableKey: 'pk_test_...', locale: 'en' });

  const payment = roki.createPaymentComponent({
    customer: { name: 'John Doe', email: 'john@example.com' },
    appearance: { theme: 'light', accentColor: '#F97316' }
  });
  payment.mount('#roki-payment');

  // Keep the button disabled until the card fields are complete.
  payment.on('form_complete', (data) => { buyBtn.disabled = !(data && data.complete); });

  form.addEventListener('submit', (e) => {
    e.preventDefault();
    buyBtn.disabled = true;          // also disable while confirming
    payment.submit();
  });

  payment.on('payment_token_ready', async (data) => {
    // Send the token to YOUR backend. Never call ROKI's secret-key endpoints from here.
    const res = await fetch('/your-backend/confirm-payment', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ payment_token: data.payment_token, order_id: 123 })
    });
    showResult(await res.json());
  });

  payment.on('payment_failed', (data) => {
    buyBtn.disabled = false;
    showResult({ status: 'declined', IsoResponseCode: data.IsoResponseCode, Errors: data.Errors });
  });
</script>

Opciones del SDK: paymentId (opcional), customer, description, metadata, locale, saveCard, appearance.theme (light/dark), appearance.accentColor. No podés reemplazar el HTML del iframe.

22.3 Confirmación en el backend

POST https://aura.roki.systems/api/connect/embed/confirm
Authorization: Bearer sk_test_...

{
  "amount": 500.00,
  "currency_code": "HNL",
  "external_reference": "order-123",
  "payment_token": "tok_xxxx",
  "publishable_key": "pk_test_...",
  "success_redirect_url": "https://merchant.example/success",
  "failed_redirect_url": "https://merchant.example/failed"
}

Fijate en la ruta base: /api/connect/embed, no /api/connect/v1.

Las dos URLs de redirección tienen que ser HTTPS. No hay excepción para desarrollo local, a pesar de lo que diga el mensaje de error. Cuando rechaza una devuelve:

422 success_redirect_url_invalid
"success_redirect_url debe ser una URL HTTPS valida (http solo se permite en desarrollo local)."

Ese paréntesis está mal. Verificado el 2026-08-14 contra nueve variantes:

URL Se acepta
http://localhost:4000/ no
http://127.0.0.1/ no
http://anything.test/ok no
https://localhost:4000/
https://your-site.com/gracias?order=1

Así que http se rechaza en todas partes, incluido loopback, y las cadenas de consulta no son problema. Para desarrollar localmente necesitás TLS en localhost, un túnel o - lo más simple - apuntar las dos URLs a cualquier página HTTPS que ya controles. Solo importan cuando un challenge de 3-D Secure se lleva al cliente y lo trae de vuelta; la respuesta común de aprobado/rechazado llega en la respuesta misma de /confirm.

El orden de validación ayuda a la hora de depurar: el cuerpo se valida antes que el token, así que un success_redirect_url_invalid significa que la petición nunca llegó a mirar tu tok_*.

22.3.1 Una respuesta pendiente de 3-D Secure, transcrita de una real

Lo que vimos fue autenticación sin fricción, no un challenge: llegó el 202 de abajo, y el pago ya estaba paid en el mismo segundo, con payment.approved unos tres segundos después - la authentication_url nunca se cargó. Así que la forma de la respuesta es real y las trampas que trae son reales; un challenge que de verdad detenga al cliente todavía no se ha observado acá.

Tres cosas de esta única respuesta van a confundir a un integrador:

HTTP 202
{
  "status": "pending",
  "code": "authentication_required",
  "error_code": "authentication_required",
  "error_message": "La autenticacion del pago aun esta en progreso. Consulte GET /payments/{id} o espere el webhook.",
  "transaction_id": "21e41243-4214-46b5-a29b-ff7966adb629",
  "amount": 25,
  "currency": "340",
  "authentication_url": "https://aura.roki.systems/connect/components/v1/confirm-challenge/21e41243-...?expires=1786751541&sig=a0d14de9..."
}
  1. El código de estado es 202, no 200. Código escrito como if (res.status !== 200) fail() rechaza un pago que apenas está esperando autenticación - y el cliente todavía puede completarlo, dejando el pedido como fallido mientras el dinero se mueve.
  2. Trae error_code y error_message aunque no falló nada. if (body.error_code) es la comprobación natural de escribir y acá está mal. Ramificá según status en su lugar: approved, declined, pending.
  3. La authentication_url va firmada y vence. La ventana observada fue de 15 minutos (expires es un timestamp Unix, con un sig que lo sella). No la guardes, no la mandes por correo ni la renderices después - mandá al cliente ahí de inmediato.

Cargá esa URL como redirección de página completa o un popup de verdad. El ejemplo de la documentación oficial la mete en un iframe invisible de 1x1, donde el challenge no se puede completar y el pago simplemente nunca avanza.

El transaction_id de esta respuesta es el que vas a volver a ver en el webhook y el que reciben la anulación y el reembolso. Guardalo acá, antes de que el cliente desaparezca dentro del challenge.

22.4 Los tres resultados

approved trae transaction_id más transaction_details con el desglose financiero completo

declined trae los campos propios del procesador, con las mayúsculas tal como las manda el procesador:

{ "status": "declined", "IsoResponseCode": "05", "Errors": [{ "Code": "201", "Message": "..." }] }

Ramificá según Errors[0].Code, no según el texto del mensaje.

pending significa que se requiere 3-D Secure:

{ "status": "pending", "authentication_url": "https://...", "transaction_id": "9e2b..." }

El resultado final llega por los webhooks de siempre, payment.approved / payment.failed - no trates pending como una falla.

Cómo presentes la authentication_url decide si funcionan los pagos de monto alto. El flujo que ROKI probó redirige al cliente a esa URL. Algunos ejemplos de código en cambio la agregan como un iframe invisible de 1x1:

// DO NOT ship this as your only 3DS path.
frame.style.cssText = 'position:absolute;width:1px;height:1px;opacity:0;border:0;';

Eso solo sirve para autenticación sin fricción, donde el emisor aprueba en silencio. En el momento en que el emisor exige un challenge - un código por SMS, la app del banco, una clave - el cliente no ve absolutamente nada y el pago se queda colgado para siempre. Los challenges son más comunes justo en las transacciones de monto alto que menos querés perder.

Usá una de estas opciones en su lugar:

Elijas la que elijas, el webhook sigue siendo la fuente de verdad: un cliente que completa el challenge y después cierra la pestaña igual produce payment.approved, y tu pedido se tiene que cumplir a partir de ese evento, no de la redirección.

22.5 Qué hay que hacer bien

Dejá el botón de pago deshabilitado hasta que form_complete reporte que los campos son válidos, y deshabilitalo de nuevo mientras confirmás - si no, un doble clic produce dos tokens. Tratá tok_* como de un solo uso: ante un rechazo, o creás un pago nuevo o volvés a montar el componente (un pago con reusable: true hace más limpio el remontaje). Y nunca pongas sk_* en la página: el token va a tu servidor, y tu servidor llama a ROKI.