Integración en apps móviles
Esta guía cubre cómo integrar catastro (POST /v1/cards) y checkout (POST /v1/payments) desde una app móvil — React Native, iOS nativo, Android nativo — o desde una PWA. Para todos estos entornos el patrón cambia respecto de la integración web pura: en vez de embeber un <iframe> y escuchar postMessage, abrís el checkout_url en un browser tab del sistema y recibís el resultado como un deep link vía return_url.
Por qué no un <iframe> en un WebView
Puede parecer natural embeber el checkout_url como <iframe> dentro del WebView de tu app. No funciona confiablemente en mobile. El 3D Secure del emisor exige que las cookies de la sesión de challenge persistan entre las páginas del flow (form → OTP → validación). Los WebView modernos (WKWebView en iOS, WebView en Android desde SDK 33+) bloquean por default las cookies third-party dentro de iframes cross-origin, así que el OTP puede aparecer y aceptar el código pero el submit final falla con “cancelado por el cliente” — sin importar que el código haya sido correcto.
El browser tab del sistema (SFSafariViewController en iOS, Custom Tabs en Android) usa el motor y las cookies del navegador real. Ahí el 3DS funciona sin fricción.
El patrón return_url + deep link
Tu app:
- Llama
POST /v1/cards(o/v1/payments) desde su backend o directamente, incluyendo en el body unreturn_urlcon un deep link a tu app (fixapp://checkout-callbackpor ejemplo, ocom.fix.app://return). - Recibe la respuesta con
registration.checkout_url(odata.checkout.checkout_urlen pagos). - Abre esa URL en el browser tab del sistema.
- El usuario completa el 3DS ahí.
- Al terminar, nuestro proxy redirige el browser tab a tu
return_urlcon query params (?status=completed&card_id=card_xyz). - El OS captura el deep link, cierra el browser tab automáticamente, y abre tu app en el listener del deep link.
- Tu app parsea la URL, actualiza el estado, y sigue el flow.
En paralelo, el webhook card.registered (o payment.confirmed) llega server-to-server como siempre — es la fuente autoritativa para la persistencia en tu backend.
return_url — formato y query params
En el request de POST /v1/cards o POST /v1/payments mandás:
{ "return_url": "fixapp://checkout-callback"}Aceptamos:
- URLs http/https regulares (
https://tuapp.com/checkout-callback) — útil para PWA o para universal/app links. - Custom URL schemes (
fixapp://,com.fix.app://) — la manera estándar de deep-linkear en React Native, iOS y Android.
En el redirect al terminar el 3DS, sumamos:
| Query param | Valores | Notas |
|---|---|---|
status | completed | failed | cancelled | Estado normalizado del intento visual. |
card_id | card_xxxxxxxx | Solo en el flow de catastro. |
payment_id | pay_xxxxxxxx | Solo en el flow de checkout de pago. |
reason | string opcional | Motivo del rechazo cuando status=failed (viene del proveedor si lo emite). |
Ejemplo del redirect final si el catastro salió bien:
fixapp://checkout-callback?status=completed&card_id=card_xyz789React Native
Recomendamos react-native-inappbrowser-reborn. Envuelve SFSafariViewController en iOS y Custom Tabs en Android con una API unificada.
import { Linking } from 'react-native';import { InAppBrowser } from 'react-native-inappbrowser-reborn';
async function catastrarTarjeta(customerId) { // 1. Backend/Fix llama al gateway para crear la sesión de catastro const res = await fetch('https://api.pay.ingalca.com/v1/cards', { method: 'POST', headers: { 'Authorization': 'Bearer sk_test_...', 'Content-Type': 'application/json', }, body: JSON.stringify({ provider: 'dinelco', customer_id: customerId, target_origin: ['https://fix.example.com'], return_url: 'fixapp://checkout-callback', customer: { name: 'Juan', lastname: 'Pérez', email: 'juan@example.com', phone: '+595981234567', }, }), }); const { data } = await res.json(); const checkoutUrl = data.registration.checkout_url;
// 2. Abrir el checkout en el browser tab del sistema await InAppBrowser.open(checkoutUrl, { // iOS preferredBarTintColor: '#ffffff', preferredControlTintColor: '#0F172A', // Android toolbarColor: '#0F172A', showTitle: true, }); // El await resuelve cuando el usuario cierra manualmente el browser // tab, o cuando el deep link lo cierra automáticamente. En ambos // casos el listener de Linking (abajo) recibe el resultado.}
// 3. Registrar el listener de deep link una sola vez en tu app// (típicamente en App.tsx o donde inicialices la navegación).Linking.addEventListener('url', (event) => { const url = new URL(event.url); if (url.protocol === 'fixapp:' && url.hostname === 'checkout-callback') { const status = url.searchParams.get('status'); const cardId = url.searchParams.get('card_id'); const reason = url.searchParams.get('reason');
// Reaccioná en tu UI: cerrar modal, mostrar toast, esperar webhook. onCheckoutResult({ status, cardId, reason }); }});En Android además hay que declarar el intent filter en android/app/src/main/AndroidManifest.xml:
<intent-filter android:autoVerify="false"> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE" /> <data android:scheme="fixapp" android:host="checkout-callback" /></intent-filter>Y en iOS, en Info.plist:
<key>CFBundleURLTypes</key><array> <dict> <key>CFBundleURLSchemes</key> <array><string>fixapp</string></array> </dict></array>iOS nativo (Swift)
import SafariServices
func catastrarTarjeta(customerId: String) async throws { // 1. Crear sesión de catastro contra el gateway var request = URLRequest(url: URL(string: "https://api.pay.ingalca.com/v1/cards")!) request.httpMethod = "POST" request.setValue("Bearer sk_test_...", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: [ "provider": "dinelco", "customer_id": customerId, "target_origin": ["https://fix.example.com"], "return_url": "fixapp://checkout-callback", "customer": [ "name": "Juan", "lastname": "Pérez", "email": "juan@example.com", "phone": "+595981234567" ] ]) let (data, _) = try await URLSession.shared.data(for: request) let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] let dataObj = json["data"] as! [String: Any] let registration = dataObj["registration"] as! [String: Any] let checkoutUrl = URL(string: registration["checkout_url"] as! String)!
// 2. Abrir el SFSafariViewController let safariVC = SFSafariViewController(url: checkoutUrl) await MainActor.run { rootViewController.present(safariVC, animated: true) }}
// 3. En AppDelegate/SceneDelegate captura el deep linkfunc application(_ app: UIApplication, open url: URL, options: ...) -> Bool { guard url.scheme == "fixapp", url.host == "checkout-callback" else { return false }
let params = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] let status = params.first { $0.name == "status" }?.value let cardId = params.first { $0.name == "card_id" }?.value
// El SFSafariViewController presentado antes se cierra solo cuando se // dispara el deep link. Reaccioná acá con tu lógica de app. onCheckoutResult(status: status, cardId: cardId) return true}Registrar el scheme en Info.plist igual que en el ejemplo de React Native.
Android nativo (Kotlin)
import androidx.browser.customtabs.CustomTabsIntent
suspend fun catastrarTarjeta(customerId: String, context: Context) { // 1. Crear sesión (ejemplo con OkHttp) val body = """ { "provider": "dinelco", "customer_id": "$customerId", "target_origin": ["https://fix.example.com"], "return_url": "fixapp://checkout-callback", "customer": { "name": "Juan", "lastname": "Pérez", "email": "juan@example.com", "phone": "+595981234567" } } """.trimIndent()
val response = okHttpClient.newCall( Request.Builder() .url("https://api.pay.ingalca.com/v1/cards") .header("Authorization", "Bearer sk_test_...") .post(body.toRequestBody("application/json".toMediaType())) .build() ).execute() val json = JSONObject(response.body!!.string()) val checkoutUrl = json.getJSONObject("data") .getJSONObject("registration") .getString("checkout_url")
// 2. Abrir Custom Tab CustomTabsIntent.Builder() .setDefaultColorSchemeParams( CustomTabColorSchemeParams.Builder() .setToolbarColor(Color.parseColor("#0F172A")) .build() ) .build() .launchUrl(context, Uri.parse(checkoutUrl))}
// 3. En tu Activity registrada con el intent filter, capturá el deep linkoverride fun onNewIntent(intent: Intent) { super.onNewIntent(intent) val data = intent.data ?: return if (data.scheme != "fixapp" || data.host != "checkout-callback") return
val status = data.getQueryParameter("status") val cardId = data.getQueryParameter("card_id") onCheckoutResult(status, cardId)}Y el intent filter en el AndroidManifest.xml igual que en el ejemplo de React Native.
PWA / mobile web
Si tu app corre en un browser mobile (Chrome mobile, Safari mobile, PWA instalada), el iframe embed también puede tener limitaciones — Safari mobile es más estricto con cookies third-party que Safari desktop. En este caso el patrón cambia levemente:
return_urles una URL http/https tuya (https://tuapp.com/checkout-callback).- En vez de
<iframe>, hacéswindow.location.href = checkoutUrl. El browser navega directo al proxy nuestro. - Al terminar el 3DS, redirigimos el browser a tu
return_urlcon los query params. - Tu página
/checkout-callback(o el ruteador de tu SPA) procesa los params.
Si publicaste Universal Links / App Links para esa URL, el OS puede además abrir tu app instalada en vez de quedarse en el browser. Con eso conseguís UX híbrida: PWA para quienes no tienen la app instalada, native para quienes sí.
Fallback y compatibilidad
- No mandes
return_urlsi estás integrando con el patrón clásico de<iframe>+postMessage(ej. un dashboard web tradicional). Ese flow sigue funcionando como antes — es el mismo endpoint. - Podés mandar
return_urlhttp/https y también embeber el iframe. En ese caso, cuando el 3DS termina el proxy hace redirect al top-level del iframe, no al parent. Útil solo si estás en un mismo dominio y querés ambas cosas — para casi todos, elegí uno de los dos flows. return_urlcon esquema custom (fixapp://) en un contexto de iframe web no funciona: el browser va a mostrar un error de “protocolo desconocido” en vez de disparar el deep link. Está pensado para SFSafariViewController / Custom Tabs.
Checklist de integración
- Elegir el esquema de deep link (
fixapp://,com.fix.app://, universal link). - Registrar el scheme en
Info.plist(iOS) y en el intent filter deAndroidManifest.xml(Android). - Instalar la librería del browser tab del sistema (
react-native-inappbrowser-reborn, oSafariServices/androidx.browser). - Agregar
return_urlal body dePOST /v1/cardsyPOST /v1/payments. - Abrir
checkout_urlen el browser tab del sistema, no en un WebView de tu app. - Registrar el listener de deep link para capturar el redirect final.
- Configurar el webhook receiver para
card.registered/payment.confirmed— es la fuente autoritativa. - Testear en dispositivo real (los simuladores tienen comportamientos raros con deep links).