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/ |
sí |
https://your-site.com/gracias?order=1 |
sí |
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..."
}
- El código de estado es
202, no200. Código escrito comoif (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. - Trae
error_codeyerror_messageaunque no falló nada.if (body.error_code)es la comprobación natural de escribir y acá está mal. Ramificá segúnstatusen su lugar:approved,declined,pending. - La
authentication_urlva firmada y vence. La ventana observada fue de 15 minutos (expireses un timestamp Unix, con unsigque 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
- incluidos
roki_commission,isvyexpected_settlement. Esos datos son comercialmente sensibles: registralos si hace falta, pero nunca se los muestres al tarjetahabiente.
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:
- Redirección de página completa a
authentication_url, volviendo a tusuccess_redirect_url. Este es el flujo que ROKI probó de punta a punta y el valor por defecto más seguro. - Un iframe modal visible con tamaño suficiente para el challenge (unos 400x600), si querés mantener al cliente en tu página. Contemplá el caso en que lo cierre sin terminar.
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.
