Skip to main content

Descripción General

Esta guía te muestra cómo integrar el flujo de verificación KYC de Gu1 en tus aplicaciones móviles (Android, iOS, React Native, Flutter) y aplicaciones web (React, Vue, vanilla JS). La verificación se ejecuta en un WebView/iframe que apunta a una URL segura generada por tu backend.
Esta guía asume que ya has configurado la integración de tu backend con la API de Gu1. Si no es así, comienza con la Descripción General de la API KYC.

Descripción General de la Arquitectura

Puntos Clave:
  • Tu backend genera la URL de verificación (nunca expongas claves de API en el cliente)
  • Tu frontend abre la URL en WebView/iframe
  • Gu1 envía webhooks a tu backend cuando la verificación se completa
  • Tu frontend hace polling a tu backend para detectar la finalización

Mejores Prácticas de Seguridad

CRÍTICO: Nunca expongas las credenciales de la API de Gu1 o los IDs de workflow en tu código del lado del cliente. Siempre genera las URLs de verificación desde tu backend seguro.

✅ HACER

// ✅ CORRECTO: Frontend solicita URL desde TU backend
const response = await fetch('https://yourapi.com/kyc/start', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${userToken}`, // Tu autenticación
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    userId: '123'
  })
});

const { verificationUrl } = await response.json();
// Abrir verificationUrl en WebView/iframe
Genera URLs de verificación que expiren rápidamente (15-30 minutos). Si el usuario no inicia dentro de ese tiempo, genera una nueva.
// En tu backend
const validation = await gu1.createValidation({
  // ... datos de entidad
});

// Almacenar con expiración
await redis.setex(
  `kyc:${userId}`,
  1800, // TTL de 30 minutos
  validation.validation_url
);
Al recibir webhooks de Gu1:
  1. Extraer solo los datos necesarios
  2. Actualizar el estado del usuario en tu BD
  3. No almacenar datos sensibles de verificación a largo plazo
// En tu manejador de webhook
app.post('/webhooks/kyc', async (req, res) => {
  const { event, payload } = req.body;

  if (event === 'kyc.validation_approved') {
    // Extraer solo lo que necesitas
    await db.users.update({
      where: { externalId: payload.entity.externalId },
      data: {
        kycStatus: 'approved',
        kycVerifiedAt: new Date(),
        // No almacenar extractedData a menos que sea necesario
      }
    });
  }

  res.status(200).send('OK');
});
Siempre verifica que los webhooks provienen de Gu1 usando firmas HMAC.Aprende sobre seguridad de webhooks →

❌ NO HACER

// ❌ INCORRECTO: Exponiendo credenciales en código del cliente
const response = await fetch('https://api.gu1.ai/kyc/validations', {
  headers: {
    'x-api-key': 'gsk_live_abc123...', // NUNCA HAGAS ESTO
    'x-workflow-id': 'workflow-id' // NUNCA HAGAS ESTO
  }
});
Por qué es peligroso:
  • Las claves de API expuestas en bundles de app pueden ser extraídas
  • Cualquiera puede crear validaciones en tu nombre
  • Imposible rotar claves comprometidas sin actualizar la app
// ❌ INCORRECTO: Almacenar URL permanentemente
localStorage.setItem('kycUrl', verificationUrl);

// Más tarde...
window.open(localStorage.getItem('kycUrl')); // La URL podría haber expirado
En su lugar: Siempre solicita una URL nueva desde tu backend cuando sea necesario.
// ❌ INCORRECTO: Confiar en que el cliente reporte la finalización
function onVerificationComplete() {
  // El usuario podría falsificar esto
  updateUserStatus('approved');
}
En su lugar: Solo confía en webhooks recibidos por tu backend.

Integración Móvil

Android (Kotlin)

import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.app.AppCompatActivity

class KYCActivity : AppCompatActivity() {
    private lateinit var webView: WebView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        webView = WebView(this).apply {
            settings.apply {
                javaScriptEnabled = true
                domStorageEnabled = true
                mediaPlaybackRequiresUserGesture = false // Para acceso a cámara
            }

            webViewClient = object : WebViewClient() {
                override fun onPageFinished(view: WebView?, url: String?) {
                    // Página cargada
                }
            }
        }

        setContentView(webView)

        // Solicitar URL de verificación desde TU backend
        loadVerificationUrl()
    }

    private fun loadVerificationUrl() {
        lifecycleScope.launch {
            try {
                val url = apiClient.requestKYCUrl(userId)
                webView.loadUrl(url)

                // Iniciar polling para finalización
                startStatusPolling()
            } catch (e: Exception) {
                // Manejar error
                showError("Failed to load verification")
            }
        }
    }

    private fun startStatusPolling() {
        lifecycleScope.launch {
            while (true) {
                delay(3000) // Polling cada 3 segundos

                val status = apiClient.getKYCStatus(userId)
                when (status) {
                    "approved" -> {
                        showSuccess()
                        finish()
                        return@launch
                    }
                    "rejected" -> {
                        showRejected()
                        finish()
                        return@launch
                    }
                    "pending", "in_progress" -> {
                        // Continuar polling
                    }
                }
            }
        }
    }

    override fun onDestroy() {
        webView.destroy()
        super.onDestroy()
    }
}
Permisos (AndroidManifest.xml):
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />

iOS (Swift)

import UIKit
import WebKit

class KYCViewController: UIViewController {
    private var webView: WKWebView!
    private let userId: String
    private var pollingTimer: Timer?

    init(userId: String) {
        self.userId = userId
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        // Configurar WebView
        let configuration = WKWebViewConfiguration()
        configuration.allowsInlineMediaPlayback = true
        configuration.mediaTypesRequiringUserActionForPlayback = []

        webView = WKWebView(frame: view.bounds, configuration: configuration)
        webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        view.addSubview(webView)

        // Cargar URL de verificación
        loadVerificationURL()
    }

    private func loadVerificationURL() {
        Task {
            do {
                let urlString = try await apiClient.requestKYCURL(userId: userId)
                guard let url = URL(string: urlString) else { return }

                let request = URLRequest(url: url)
                webView.load(request)

                // Iniciar polling
                startStatusPolling()
            } catch {
                showError("Failed to load verification")
            }
        }
    }

    private func startStatusPolling() {
        pollingTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
            guard let self = self else { return }

            Task {
                do {
                    let status = try await self.apiClient.getKYCStatus(userId: self.userId)

                    switch status {
                    case "approved":
                        self.handleApproved()
                    case "rejected":
                        self.handleRejected()
                    default:
                        break // Continuar polling
                    }
                } catch {
                    // Manejar error
                }
            }
        }
    }

    private func handleApproved() {
        pollingTimer?.invalidate()
        // Mostrar éxito y cerrar
        dismiss(animated: true)
    }

    private func handleRejected() {
        pollingTimer?.invalidate()
        // Mostrar mensaje de rechazo
    }

    deinit {
        pollingTimer?.invalidate()
    }
}
Permisos (Info.plist):
<key>NSCameraUsageDescription</key>
<string>We need camera access for identity verification</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>We need photo library access for document upload</string>

Kotlin Multiplatform (KMP)

Perfecto para compartir lógica entre Android e iOS:
// commonMain/KYCManager.kt
expect class KYCManager {
    fun openVerification(url: String)
    fun startStatusPolling(userId: String, onComplete: (String) -> Unit)
}

// Lógica de negocio compartida
class KYCCoordinator(
    private val apiClient: ApiClient,
    private val kycManager: KYCManager
) {
    suspend fun startVerification(userId: String) {
        try {
            val url = apiClient.requestKYCUrl(userId)
            kycManager.openVerification(url)

            kycManager.startStatusPolling(userId) { status ->
                when (status) {
                    "approved" -> handleApproved()
                    "rejected" -> handleRejected()
                }
            }
        } catch (e: Exception) {
            // Manejar error
        }
    }

    private fun handleApproved() {
        // Lógica de aprobación compartida
    }

    private fun handleRejected() {
        // Lógica de rechazo compartida
    }
}

React Native

import React, { useEffect, useState } from 'react';
import { View, ActivityIndicator, StyleSheet } from 'react-native';
import { WebView } from 'react-native-webview';

interface KYCVerificationProps {
  userId: string;
  onComplete: (status: string) => void;
}

export const KYCVerification: React.FC<KYCVerificationProps> = ({
  userId,
  onComplete
}) => {
  const [verificationUrl, setVerificationUrl] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    loadVerificationUrl();
    const pollInterval = startStatusPolling();

    return () => clearInterval(pollInterval);
  }, [userId]);

  const loadVerificationUrl = async () => {
    try {
      const response = await fetch(`https://yourapi.com/kyc/start`, {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${userToken}`,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ userId })
      });

      const { verificationUrl } = await response.json();
      setVerificationUrl(verificationUrl);
      setIsLoading(false);
    } catch (error) {
      console.error('Failed to load KYC URL:', error);
      setIsLoading(false);
    }
  };

  const startStatusPolling = () => {
    return setInterval(async () => {
      try {
        const response = await fetch(`https://yourapi.com/kyc/status/${userId}`, {
          headers: {
            'Authorization': `Bearer ${userToken}`
          }
        });

        const { status } = await response.json();

        if (status === 'approved' || status === 'rejected') {
          onComplete(status);
        }
      } catch (error) {
        console.error('Polling error:', error);
      }
    }, 3000); // Polling cada 3 segundos
  };

  if (isLoading) {
    return (
      <View style={styles.loading}>
        <ActivityIndicator size="large" />
      </View>
    );
  }

  if (!verificationUrl) {
    return (
      <View style={styles.error}>
        <Text>Failed to load verification</Text>
      </View>
    );
  }

  return (
    <WebView
      source={{ uri: verificationUrl }}
      style={styles.webview}
      javaScriptEnabled={true}
      domStorageEnabled={true}
      mediaPlaybackRequiresUserAction={false} // Para cámara
      allowsInlineMediaPlayback={true}
    />
  );
};

const styles = StyleSheet.create({
  webview: {
    flex: 1
  },
  loading: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center'
  },
  error: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center'
  }
});
Permisos:
  • Agrega permisos de cámara a AndroidManifest.xml e Info.plist
  • Instalar: npm install react-native-webview

Flutter

import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'dart:async';

class KYCVerificationScreen extends StatefulWidget {
  final String userId;
  final Function(String) onComplete;

  const KYCVerificationScreen({
    Key? key,
    required this.userId,
    required this.onComplete,
  }) : super(key: key);

  @override
  _KYCVerificationScreenState createState() => _KYCVerificationScreenState();
}

class _KYCVerificationScreenState extends State<KYCVerificationScreen> {
  String? verificationUrl;
  bool isLoading = true;
  Timer? pollingTimer;
  late WebViewController controller;

  @override
  void initState() {
    super.initState();
    _loadVerificationUrl();
    _startStatusPolling();
  }

  @override
  void dispose() {
    pollingTimer?.cancel();
    super.dispose();
  }

  Future<void> _loadVerificationUrl() async {
    try {
      final response = await http.post(
        Uri.parse('https://yourapi.com/kyc/start'),
        headers: {
          'Authorization': 'Bearer $userToken',
          'Content-Type': 'application/json',
        },
        body: jsonEncode({'userId': widget.userId}),
      );

      if (response.statusCode == 200) {
        final data = jsonDecode(response.body);
        setState(() {
          verificationUrl = data['verificationUrl'];
          isLoading = false;
        });

        // Inicializar WebView controller
        controller = WebViewController()
          ..setJavaScriptMode(JavaScriptMode.unrestricted)
          ..setBackgroundColor(const Color(0x00000000))
          ..loadRequest(Uri.parse(verificationUrl!));
      }
    } catch (e) {
      print('Error loading KYC URL: $e');
      setState(() => isLoading = false);
    }
  }

  void _startStatusPolling() {
    pollingTimer = Timer.periodic(Duration(seconds: 3), (timer) async {
      try {
        final response = await http.get(
          Uri.parse('https://yourapi.com/kyc/status/${widget.userId}'),
          headers: {'Authorization': 'Bearer $userToken'},
        );

        if (response.statusCode == 200) {
          final data = jsonDecode(response.body);
          final status = data['status'];

          if (status == 'approved' || status == 'rejected') {
            timer.cancel();
            widget.onComplete(status);
          }
        }
      } catch (e) {
        print('Polling error: $e');
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    if (isLoading) {
      return Scaffold(
        body: Center(
          child: CircularProgressIndicator(),
        ),
      );
    }

    if (verificationUrl == null) {
      return Scaffold(
        body: Center(
          child: Text('Failed to load verification'),
        ),
      );
    }

    return Scaffold(
      appBar: AppBar(
        title: Text('Identity Verification'),
      ),
      body: WebViewWidget(controller: controller),
    );
  }
}
Dependencias (pubspec.yaml):
dependencies:
  webview_flutter: ^4.4.0
  http: ^1.1.0
Permisos:
  • Android: Agrega permiso de cámara a AndroidManifest.xml
  • iOS: Agrega descripción de uso de cámara a Info.plist

Integración Web

React

import React, { useEffect, useState, useRef } from 'react';

interface KYCVerificationProps {
  userId: string;
  onComplete: (status: string) => void;
}

export const KYCVerification: React.FC<KYCVerificationProps> = ({
  userId,
  onComplete
}) => {
  const [verificationUrl, setVerificationUrl] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const iframeRef = useRef<HTMLIFrameElement>(null);
  const pollingInterval = useRef<NodeJS.Timeout | null>(null);

  useEffect(() => {
    loadVerificationUrl();
    startStatusPolling();

    return () => {
      if (pollingInterval.current) {
        clearInterval(pollingInterval.current);
      }
    };
  }, [userId]);

  const loadVerificationUrl = async () => {
    try {
      const response = await fetch('/api/kyc/start', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${getAuthToken()}`
        },
        body: JSON.stringify({ userId })
      });

      const { verificationUrl } = await response.json();
      setVerificationUrl(verificationUrl);
      setIsLoading(false);
    } catch (error) {
      console.error('Failed to load KYC URL:', error);
      setIsLoading(false);
    }
  };

  const startStatusPolling = () => {
    pollingInterval.current = setInterval(async () => {
      try {
        const response = await fetch(`/api/kyc/status/${userId}`, {
          headers: {
            'Authorization': `Bearer ${getAuthToken()}`
          }
        });

        const { status } = await response.json();

        if (status === 'approved' || status === 'rejected') {
          if (pollingInterval.current) {
            clearInterval(pollingInterval.current);
          }
          onComplete(status);
        }
      } catch (error) {
        console.error('Polling error:', error);
      }
    }, 3000); // Polling cada 3 segundos
  };

  if (isLoading) {
    return (
      <div className="flex items-center justify-center h-screen">
        <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
      </div>
    );
  }

  if (!verificationUrl) {
    return (
      <div className="flex items-center justify-center h-screen">
        <p className="text-red-500">Failed to load verification</p>
      </div>
    );
  }

  return (
    <div className="w-full h-screen">
      <iframe
        ref={iframeRef}
        src={verificationUrl}
        className="w-full h-full border-0"
        allow="camera; microphone"
        title="KYC Verification"
      />
    </div>
  );
};

Vue 3

<template>
  <div class="kyc-verification">
    <div v-if="isLoading" class="loading">
      <div class="spinner"></div>
      <p>Loading verification...</p>
    </div>

    <div v-else-if="error" class="error">
      <p>{{ error }}</p>
    </div>

    <iframe
      v-else-if="verificationUrl"
      :src="verificationUrl"
      class="verification-iframe"
      allow="camera; microphone"
      title="KYC Verification"
    ></iframe>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';

interface Props {
  userId: string;
}

const props = defineProps<Props>();
const emit = defineEmits<{
  complete: [status: string];
}>();

const verificationUrl = ref<string | null>(null);
const isLoading = ref(true);
const error = ref<string | null>(null);
let pollingInterval: number | null = null;

onMounted(async () => {
  await loadVerificationUrl();
  startStatusPolling();
});

onUnmounted(() => {
  if (pollingInterval) {
    clearInterval(pollingInterval);
  }
});

async function loadVerificationUrl() {
  try {
    const response = await fetch('/api/kyc/start', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${getAuthToken()}`
      },
      body: JSON.stringify({ userId: props.userId })
    });

    if (!response.ok) throw new Error('Failed to load verification URL');

    const data = await response.json();
    verificationUrl.value = data.verificationUrl;
  } catch (err) {
    error.value = 'Failed to load verification';
    console.error(err);
  } finally {
    isLoading.value = false;
  }
}

function startStatusPolling() {
  pollingInterval = setInterval(async () => {
    try {
      const response = await fetch(`/api/kyc/status/${props.userId}`, {
        headers: {
          'Authorization': `Bearer ${getAuthToken()}`
        }
      });

      const { status } = await response.json();

      if (status === 'approved' || status === 'rejected') {
        if (pollingInterval) clearInterval(pollingInterval);
        emit('complete', status);
      }
    } catch (err) {
      console.error('Polling error:', err);
    }
  }, 3000);
}

function getAuthToken(): string {
  // Tu lógica de token de autenticación
  return localStorage.getItem('authToken') || '';
}
</script>

<style scoped>
.kyc-verification {
  width: 100%;
  height: 100vh;
}

.loading,
.error {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100%;
}

.spinner {
  width: 50px;
  height: 50px;
  border: 4px solid #f3f3f3;
  border-top: 4px solid #3498db;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

.verification-iframe {
  width: 100%;
  height: 100%;
  border: none;
}

.error {
  color: #e74c3c;
}
</style>

Plain HTML/JavaScript (Vanilla)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>KYC Verification</title>
    <style>
        body, html {
            margin: 0;
            padding: 0;
            height: 100%;
            font-family: Arial, sans-serif;
        }

        #loading {
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            height: 100vh;
        }

        .spinner {
            width: 50px;
            height: 50px;
            border: 4px solid #f3f3f3;
            border-top: 4px solid #3498db;
            border-radius: 50%;
            animation: spin 1s linear infinite;
        }

        @keyframes spin {
            0% { transform: rotate(0deg); }
            100% { transform: rotate(360deg); }
        }

        #verification-iframe {
            width: 100%;
            height: 100vh;
            border: none;
            display: none;
        }

        #error {
            display: none;
            text-align: center;
            padding: 20px;
            color: #e74c3c;
        }
    </style>
</head>
<body>
    <div id="loading">
        <div class="spinner"></div>
        <p>Loading verification...</p>
    </div>

    <div id="error">
        <p>Failed to load verification. Please try again.</p>
    </div>

    <iframe
        id="verification-iframe"
        allow="camera; microphone"
        title="KYC Verification"
    ></iframe>

    <script>
        const userId = 'USER_ID_HERE'; // Reemplazar con ID de usuario real
        let pollingInterval;

        async function loadVerificationUrl() {
            try {
                const response = await fetch('/api/kyc/start', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                        'Authorization': `Bearer ${getAuthToken()}`
                    },
                    body: JSON.stringify({ userId })
                });

                if (!response.ok) throw new Error('Failed to load');

                const { verificationUrl } = await response.json();

                // Mostrar iframe y ocultar loading
                document.getElementById('loading').style.display = 'none';
                const iframe = document.getElementById('verification-iframe');
                iframe.src = verificationUrl;
                iframe.style.display = 'block';

                // Iniciar polling para estado
                startStatusPolling();
            } catch (error) {
                console.error('Error:', error);
                document.getElementById('loading').style.display = 'none';
                document.getElementById('error').style.display = 'block';
            }
        }

        function startStatusPolling() {
            pollingInterval = setInterval(async () => {
                try {
                    const response = await fetch(`/api/kyc/status/${userId}`, {
                        headers: {
                            'Authorization': `Bearer ${getAuthToken()}`
                        }
                    });

                    const { status } = await response.json();

                    if (status === 'approved') {
                        clearInterval(pollingInterval);
                        handleApproved();
                    } else if (status === 'rejected') {
                        clearInterval(pollingInterval);
                        handleRejected();
                    }
                } catch (error) {
                    console.error('Polling error:', error);
                }
            }, 3000); // Polling cada 3 segundos
        }

        function handleApproved() {
            alert('Verification approved!');
            window.location.href = '/dashboard'; // Redirigir
        }

        function handleRejected() {
            alert('Verification rejected. Please try again.');
            window.location.href = '/kyc-retry'; // Redirigir
        }

        function getAuthToken() {
            // Obtener token de autenticación de localStorage, cookie, etc.
            return localStorage.getItem('authToken');
        }

        // Iniciar carga cuando la página carga
        window.addEventListener('DOMContentLoaded', loadVerificationUrl);

        // Limpiar al descargar la página
        window.addEventListener('beforeunload', () => {
            if (pollingInterval) {
                clearInterval(pollingInterval);
            }
        });
    </script>
</body>
</html>

Detección de Finalización del Flujo

El WebView/iframe no puede notificar directamente a tu app cuando la verificación se completa. La UI de verificación de Gu1 se ejecuta en aislamiento por razones de seguridad.

Patrón Recomendado: Polling al Backend

Tu frontend hace polling a tu backend, que recibe webhooks de Gu1: Ejemplo de endpoint del backend:
// Ejemplo Node.js/Express
app.get('/api/kyc/status/:userId', authenticate, async (req, res) => {
  const { userId } = req.params;

  const user = await db.users.findOne({ id: userId });

  res.json({
    status: user.kycStatus, // 'pending', 'in_progress', 'approved', 'rejected'
    verifiedAt: user.kycVerifiedAt,
    lastUpdated: user.kycLastUpdated
  });
});
Polling del frontend (todas las plataformas):
async function pollStatus(userId) {
  const interval = setInterval(async () => {
    const response = await fetch(`/api/kyc/status/${userId}`);
    const { status } = await response.json();

    if (status === 'approved' || status === 'rejected') {
      clearInterval(interval);
      handleComplete(status);
    }
  }, 3000); // 3 segundos
}

Alternativa: WebSockets (Avanzado)

Para mejor UX, usa WebSockets para enviar actualizaciones en lugar de polling:
// Backend (ejemplo Socket.IO)
io.on('connection', (socket) => {
  socket.on('subscribe-kyc', (userId) => {
    socket.join(`kyc:${userId}`);
  });
});

// Cuando llega el webhook
app.post('/webhooks/kyc', async (req, res) => {
  const { event, payload } = req.body;

  // Actualizar base de datos
  await updateUserStatus(payload);

  // Notificar clientes conectados
  io.to(`kyc:${payload.entity.externalId}`).emit('kyc-update', {
    status: payload.status
  });

  res.status(200).send('OK');
});

// Frontend
const socket = io();
socket.emit('subscribe-kyc', userId);
socket.on('kyc-update', ({ status }) => {
  handleComplete(status);
});

Manejo de Webhooks

Tu backend recibe webhooks de Gu1 cuando la verificación se completa.

Guía de Integración de Webhooks

Ver guía completa de integración de webhooks con ejemplos de código, seguridad y estructuras de payload

Eventos Clave de Webhook

EventoCuándo se DisparaAcción
kyc.validation_approvedUsuario verificado exitosamenteActualizar estado del usuario a verified, habilitar funciones
kyc.validation_rejectedVerificación fallidaActualizar estado a rejected, opcionalmente permitir reintento
kyc.validation_abandonedUsuario salió sin completarMarcar como incomplete, enviar email de recordatorio
kyc.validation_expiredSesión expiradaPermitir al usuario solicitar nueva URL
Manejador mínimo de webhook:
app.post('/webhooks/gu1/kyc', async (req, res) => {
  // Verificar firma (ver guía de webhooks)
  const { event, payload } = req.body;

  const userId = payload.entity.externalId;

  switch (event) {
    case 'kyc.validation_approved':
      await db.users.update({
        where: { id: userId },
        data: {
          kycStatus: 'approved',
          kycVerifiedAt: new Date(),
          isVerified: true
        }
      });
      break;

    case 'kyc.validation_rejected':
      await db.users.update({
        where: { id: userId },
        data: {
          kycStatus: 'rejected',
          isVerified: false
        }
      });
      break;
  }

  res.status(200).send('OK');
});

Ejemplo de Flujo Completo

Veamos un ejemplo completo de extremo a extremo:

1. Backend: Generar URL

// POST /api/kyc/start
app.post('/api/kyc/start', authenticate, async (req, res) => {
  const { userId } = req.body;
  const user = await db.users.findOne({ id: userId });

  // Llamar a API de Gu1
  const validation = await gu1.post('/kyc/validations', {
    entity: {
      externalId: user.id,
      name: user.name,
      taxId: user.taxId,
      type: 'person'
    },
    workflowId: process.env.GU1_WORKFLOW_ID
  });

  // Almacenar ID de validación
  await db.users.update({
    where: { id: userId },
    data: {
      kycValidationId: validation.id,
      kycStatus: 'pending'
    }
  });

  res.json({
    verificationUrl: validation.validation_url
  });
});

2. Frontend: Abrir Verificación

// Usuario hace clic en "Verify Identity"
async function startVerification() {
  const { verificationUrl } = await fetch('/api/kyc/start', {
    method: 'POST',
    body: JSON.stringify({ userId: currentUser.id })
  }).then(r => r.json());

  // Abrir en WebView/iframe
  openVerificationUI(verificationUrl);

  // Iniciar polling
  pollForCompletion();
}

3. Backend: Recibir Webhook

// POST /webhooks/gu1/kyc
app.post('/webhooks/gu1/kyc', async (req, res) => {
  const { event, payload } = req.body;

  if (event === 'kyc.validation_approved') {
    await db.users.update({
      where: { externalId: payload.entity.externalId },
      data: {
        kycStatus: 'approved',
        isVerified: true
      }
    });
  }

  res.status(200).send('OK');
});

4. Frontend: Detectar Finalización

async function pollForCompletion() {
  const interval = setInterval(async () => {
    const { status } = await fetch(`/api/kyc/status/${userId}`)
      .then(r => r.json());

    if (status === 'approved') {
      clearInterval(interval);
      showSuccess('Identity verified!');
    } else if (status === 'rejected') {
      clearInterval(interval);
      showError('Verification failed');
    }
  }, 3000);
}

Pruebas

Entorno Sandbox

Usa el modo sandbox para pruebas:
// Backend
const gu1 = new Gu1Client({
  apiKey: process.env.GU1_SANDBOX_API_KEY,
  environment: 'sandbox' // Usar sandbox para pruebas
});
En modo sandbox:
  • No se requieren documentos reales
  • Puedes simular diferentes resultados
  • Los webhooks se disparan normalmente

Probando Diferentes Resultados

Para probar escenarios de rechazo/expiración, usa diferentes datos de prueba en modo sandbox.

Solución de Problemas

Posibles causas:
  • URL expirada (generar una nueva)
  • JavaScript deshabilitado en WebView
  • Problemas de conectividad de red
Soluciones:
  • Habilitar JavaScript: settings.javaScriptEnabled = true
  • Verificar que la URL es válida y no ha expirado
  • Probar URL en navegador normal primero
Posibles causas:
  • Faltan permisos de cámara
  • La reproducción de medios requiere gesto del usuario
Soluciones:Android:
<uses-permission android:name="android.permission.CAMERA" />
iOS:
configuration.mediaTypesRequiringUserActionForPlayback = []
Web:
<iframe allow="camera; microphone" />
Posibles causas:
  • Webhook no recibido por el backend
  • Verificación de firma de webhook fallando
  • Base de datos no se está actualizando
Soluciones:
  • Verificar logs de webhook en el dashboard de Gu1
  • Verificar implementación de firma de webhook
  • Agregar logging al manejador de webhook
  • Probar endpoint de webhook manualmente
Posibles causas:
  • Iluminación deficiente para fotos de documentos
  • Tipo de documento no soportado
  • Problemas técnicos
Soluciones:
  • Proporcionar instrucciones claras antes de comenzar
  • Mostrar ejemplos de fotos buenas vs malas
  • Implementar timeout (15-20 minutos)
  • Permitir al usuario salir y reintentar

Resumen de Mejores Prácticas

Seguridad Primero

  • Nunca exponer claves de API en el cliente
  • Siempre generar URLs desde el backend
  • Verificar firmas de webhook
  • Usar HTTPS en todas partes

Experiencia de Usuario

  • Mostrar estados de carga
  • Proporcionar instrucciones claras
  • Manejar errores con gracia
  • Permitir reintentos en caso de fallo

Confiabilidad

  • Implementar polling con intervalos razonables
  • Manejar fallos de red
  • Establecer timeouts apropiados
  • Probar todas las plataformas exhaustivamente

Cumplimiento

  • No almacenar datos sensibles de verificación
  • Usar patrón Process-and-Purge
  • Respetar políticas de retención de datos
  • Documentar tu integración

Próximos Pasos