Saltearse al contenido

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.

Tu app:

  1. Llama POST /v1/cards (o /v1/payments) desde su backend o directamente, incluyendo en el body un return_url con un deep link a tu app (fixapp://checkout-callback por ejemplo, o com.fix.app://return).
  2. Recibe la respuesta con registration.checkout_url (o data.checkout.checkout_url en pagos).
  3. Abre esa URL en el browser tab del sistema.
  4. El usuario completa el 3DS ahí.
  5. Al terminar, nuestro proxy redirige el browser tab a tu return_url con query params (?status=completed&card_id=card_xyz).
  6. El OS captura el deep link, cierra el browser tab automáticamente, y abre tu app en el listener del deep link.
  7. 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 paramValoresNotas
statuscompleted | failed | cancelledEstado normalizado del intento visual.
card_idcard_xxxxxxxxSolo en el flow de catastro.
payment_idpay_xxxxxxxxSolo en el flow de checkout de pago.
reasonstring opcionalMotivo 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_xyz789

React 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 link
func 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 link
override 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_url es una URL http/https tuya (https://tuapp.com/checkout-callback).
  • En vez de <iframe>, hacés window.location.href = checkoutUrl. El browser navega directo al proxy nuestro.
  • Al terminar el 3DS, redirigimos el browser a tu return_url con 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_url si 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_url http/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_url con 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 de AndroidManifest.xml (Android).
  • Instalar la librería del browser tab del sistema (react-native-inappbrowser-reborn, o SafariServices / androidx.browser).
  • Agregar return_url al body de POST /v1/cards y POST /v1/payments.
  • Abrir checkout_url en 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).