Mode 2 - Embedded components
Card fields rendered by ROKI inside a secure iframe on your own site. The customer never leaves your page, and the card number never touches your code.
22.1 The flow
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)
The amount is set by your backend at step 6, not by the browser. Mounting needs no payment to exist beforehand.
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>
SDK options: paymentId (optional), customer, description, metadata, locale, saveCard,
appearance.theme (light/dark), appearance.accentColor. You cannot replace the iframe's HTML.
22.3 Backend confirm
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"
}
Note the base path: /api/connect/embed, not /api/connect/v1.
Both redirect URLs must be HTTPS. There is no local-development exception, despite what the error message claims. Rejecting one returns:
422 success_redirect_url_invalid
"success_redirect_url debe ser una URL HTTPS valida (http solo se permite en desarrollo local)."
There is no local-development exception. What is accepted:
| URL | Accepted |
|---|---|
http://localhost:4000/ |
no |
http://127.0.0.1/ |
no |
http://anything.test/ok |
no |
https://localhost:4000/ |
yes |
https://your-site.com/gracias?order=1 |
yes |
So http is refused everywhere, including loopback, and query strings are fine. To develop
locally you need TLS on localhost, a tunnel, or - simplest - point the two URLs at any HTTPS page
you already control. They only matter when a 3-D Secure challenge sends the customer away and back;
the ordinary approved/declined answer arrives in the /confirm response itself.
The validation order helps when debugging: the body is validated before the token, so a
success_redirect_url_invalid means the request never got as far as looking at your tok_*.
22.3.1 A pending 3-D Secure answer, transcribed from a real one
When 3-D Secure needs to step in, the answer is not an error even though it reads like one. Three things in this single response will mislead an integrator:
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..."
}
- The status code is
202, not200. Code written asif (res.status !== 200) fail()rejects a payment that is merely awaiting authentication - and the customer may still complete it, leaving the order failed while the money moves. - It carries
error_codeanderror_messagealthough nothing failed.if (body.error_code)is the natural check to write and it is wrong here. Branch onstatusinstead:approved,declined,pending. - The
authentication_urlis signed and expires. The observed window was 15 minutes (expiresis a Unix timestamp, with asigthat seals it). Do not store it, email it, or render it later - send the customer there immediately.
Load that URL as a full-page redirect or a real popup. The official documentation's example puts it in a 1x1 invisible iframe, where the challenge cannot be completed and the payment simply never advances.
The transaction_id in this response is the one you will see again in the webhook and the one void
and refund take. Persist it here, before the customer disappears into the challenge.
22.4 The three outcomes
approved carries transaction_id plus transaction_details with the full financial breakdown
- including
roki_commission,isvandexpected_settlement. Those are commercially sensitive: log them if you must, but never show them to the cardholder.
declined carries the processor's own fields, capitalized as the processor sends them:
{ "status": "declined", "IsoResponseCode": "05", "Errors": [{ "Code": "201", "Message": "..." }] }
Branch on Errors[0].Code, not on the message text.
pending means 3-D Secure is required:
{ "status": "pending", "authentication_url": "https://...", "transaction_id": "9e2b..." }
The final outcome arrives through the usual payment.approved / payment.failed webhooks - do not
treat pending as a failure.
How you present authentication_url decides whether high-value payments work. ROKI's tested
flow redirects the customer to that URL. Some code samples instead append it as a 1x1 invisible
iframe:
// DO NOT ship this as your only 3DS path.
frame.style.cssText = 'position:absolute;width:1px;height:1px;opacity:0;border:0;';
That is fine only for frictionless authentication, where the issuer approves silently. The moment the issuer requires a challenge - a code by SMS, the bank's app, a password - the customer sees nothing at all and the payment hangs forever. Challenges are most common on exactly the high-value transactions you least want to lose.
Use one of these instead:
- Full-page redirect to
authentication_url, returning to yoursuccess_redirect_url. This is the flow ROKI tested end to end and the safest default. - A visible modal iframe sized for the challenge (roughly 400x600), if you want to keep the customer on your page. Handle the case where they close it without finishing.
Whichever you pick, the webhook remains the source of truth: a customer who completes the challenge
and then closes the tab still produces payment.approved, and your order must be fulfilled from
that event, not from the redirect.
22.5 What to get right
Keep the pay button disabled until form_complete reports the fields are valid, and disable it again
while confirming - otherwise a double click produces two tokens. Treat tok_* as single-use: on a
decline, either create a new payment or remount the component (a reusable: true payment makes the
remount cleaner). And never put sk_* in the page: the token goes to your server, your server calls
ROKI.
