Overview
This guide shows you how to integrate Gu1’s KYC verification flow into your mobile apps (Android, iOS, React Native, Flutter) and web applications (React, Vue, vanilla JS). The verification runs in a WebView/iframe pointing to a secure URL generated by your backend.This guide assumes you’ve already set up your backend integration with Gu1’s API. If not, start with the KYC API Overview.
Architecture Overview
Key Points:- 🔒 Your backend generates the verification URL (never expose API keys in client)
- 📱 Your frontend opens the URL in WebView/iframe
- 🔔 Gu1 sends webhooks to your backend when verification completes
- 🔄 Your frontend polls your backend to detect completion
Security Best Practices
CRITICAL: Never expose Gu1 API credentials or workflow IDs in your client-side code. Always generate verification URLs from your secure backend.
✅ DO
Generate URLs on-demand from your backend
Generate URLs on-demand from your backend
Copy
Ask AI
// ✅ CORRECT: Frontend requests URL from YOUR backend
const response = await fetch('https://yourapi.com/kyc/start', {
method: 'POST',
headers: {
'Authorization': `Bearer ${userToken}`, // Your auth
'Content-Type': 'application/json'
},
body: JSON.stringify({
userId: '123'
})
});
const { verificationUrl } = await response.json();
// Open verificationUrl in WebView/iframe
Implement short TTL for URLs
Implement short TTL for URLs
Generate verification URLs that expire quickly (15-30 minutes). If user doesn’t start within that time, generate a new one.
Copy
Ask AI
// In your backend
const validation = await gu1.createValidation({
// ... entity data
});
// Store with expiration
await redis.setex(
`kyc:${userId}`,
1800, // 30 minutes TTL
validation.validation_url
);
Use Process-and-Purge pattern
Use Process-and-Purge pattern
When receiving webhooks from Gu1:
- Extract only necessary data
- Update user status in your DB
- Don’t store sensitive verification data long-term
Copy
Ask AI
// In your webhook handler
app.post('/webhooks/kyc', async (req, res) => {
const { event, payload } = req.body;
if (event === 'kyc.validation_approved') {
// Extract only what you need
await db.users.update({
where: { externalId: payload.entity.externalId },
data: {
kycStatus: 'approved',
kycVerifiedAt: new Date(),
// Don't store extractedData unless required
}
});
}
res.status(200).send('OK');
});
Validate webhook signatures
Validate webhook signatures
Always verify webhooks are from Gu1 using HMAC signatures.Learn about webhook security →
❌ DON’T
Never hardcode API keys or workflow IDs
Never hardcode API keys or workflow IDs
Copy
Ask AI
// ❌ WRONG: Exposing credentials in client code
const response = await fetch('https://api.gu1.ai/kyc/validations', {
headers: {
'x-api-key': 'gsk_live_abc123...', // NEVER DO THIS
'x-workflow-id': 'workflow-id' // NEVER DO THIS
}
});
- API keys exposed in app bundles can be extracted
- Anyone can create validations on your behalf
- Impossible to rotate compromised keys without app updates
Don't reuse verification URLs
Don't reuse verification URLs
Copy
Ask AI
// ❌ WRONG: Storing URL permanently
localStorage.setItem('kycUrl', verificationUrl);
// Later...
window.open(localStorage.getItem('kycUrl')); // URL might be expired
Don't rely on client-side validation
Don't rely on client-side validation
Copy
Ask AI
// ❌ WRONG: Trusting client to report completion
function onVerificationComplete() {
// User could fake this
updateUserStatus('approved');
}
Mobile Integration
Android (Kotlin)
- Standard Android
- Jetpack Compose
Copy
Ask AI
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 // For camera access
}
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
// Page loaded
}
}
}
setContentView(webView)
// Request verification URL from YOUR backend
loadVerificationUrl()
}
private fun loadVerificationUrl() {
lifecycleScope.launch {
try {
val url = apiClient.requestKYCUrl(userId)
webView.loadUrl(url)
// Start polling for completion
startStatusPolling()
} catch (e: Exception) {
// Handle error
showError("Failed to load verification")
}
}
}
private fun startStatusPolling() {
lifecycleScope.launch {
while (true) {
delay(3000) // Poll every 3 seconds
val status = apiClient.getKYCStatus(userId)
when (status) {
"approved" -> {
showSuccess()
finish()
return@launch
}
"rejected" -> {
showRejected()
finish()
return@launch
}
"pending", "in_progress" -> {
// Continue polling
}
}
}
}
}
override fun onDestroy() {
webView.destroy()
super.onDestroy()
}
}
Copy
Ask AI
import androidx.compose.runtime.*
import androidx.compose.ui.viewinterop.AndroidView
import android.webkit.WebView
@Composable
fun KYCVerificationScreen(
userId: String,
onComplete: (status: String) -> Unit
) {
var verificationUrl by remember { mutableStateOf<String?>(null) }
var isLoading by remember { mutableStateOf(true) }
// Load verification URL
LaunchedEffect(userId) {
try {
val url = apiClient.requestKYCUrl(userId)
verificationUrl = url
isLoading = false
} catch (e: Exception) {
// Handle error
}
}
// Poll for status
LaunchedEffect(userId) {
while (true) {
delay(3000) // Poll every 3 seconds
val status = apiClient.getKYCStatus(userId)
when (status) {
"approved", "rejected" -> {
onComplete(status)
return@LaunchedEffect
}
}
}
}
if (isLoading) {
CircularProgressIndicator()
} else {
verificationUrl?.let { url ->
AndroidView(
factory = { context ->
WebView(context).apply {
settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
mediaPlaybackRequiresUserGesture = false
}
loadUrl(url)
}
}
)
}
}
}
Copy
Ask AI
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
iOS (Swift)
- UIKit
- SwiftUI
Copy
Ask AI
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()
// Configure WebView
let configuration = WKWebViewConfiguration()
configuration.allowsInlineMediaPlayback = true
configuration.mediaTypesRequiringUserActionForPlayback = []
webView = WKWebView(frame: view.bounds, configuration: configuration)
webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(webView)
// Load verification URL
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)
// Start 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 // Continue polling
}
} catch {
// Handle error
}
}
}
}
private func handleApproved() {
pollingTimer?.invalidate()
// Show success and dismiss
dismiss(animated: true)
}
private func handleRejected() {
pollingTimer?.invalidate()
// Show rejection message
}
deinit {
pollingTimer?.invalidate()
}
}
Copy
Ask AI
import SwiftUI
import WebKit
struct KYCVerificationView: View {
let userId: String
@State private var verificationURL: URL?
@State private var isLoading = true
@State private var status: String = "pending"
@Environment(\.dismiss) var dismiss
var body: some View {
ZStack {
if isLoading {
ProgressView("Loading verification...")
} else if let url = verificationURL {
WebView(url: url)
}
}
.onAppear {
loadVerificationURL()
startStatusPolling()
}
}
private func loadVerificationURL() {
Task {
do {
let urlString = try await apiClient.requestKYCURL(userId: userId)
verificationURL = URL(string: urlString)
isLoading = false
} catch {
// Handle error
}
}
}
private func startStatusPolling() {
Task {
while status == "pending" || status == "in_progress" {
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
do {
status = try await apiClient.getKYCStatus(userId: userId)
if status == "approved" || status == "rejected" {
dismiss()
return
}
} catch {
// Handle error
}
}
}
}
}
struct WebView: UIViewRepresentable {
let url: URL
func makeUIView(context: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
configuration.allowsInlineMediaPlayback = true
configuration.mediaTypesRequiringUserActionForPlayback = []
return WKWebView(frame: .zero, configuration: configuration)
}
func updateUIView(_ webView: WKWebView, context: Context) {
let request = URLRequest(url: url)
webView.load(request)
}
}
Copy
Ask AI
<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)
Perfect for sharing logic between Android and iOS:- Common Code
- Android Implementation
- iOS Implementation
Copy
Ask AI
// commonMain/KYCManager.kt
expect class KYCManager {
fun openVerification(url: String)
fun startStatusPolling(userId: String, onComplete: (String) -> Unit)
}
// Shared business logic
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) {
// Handle error
}
}
private fun handleApproved() {
// Shared approval logic
}
private fun handleRejected() {
// Shared rejection logic
}
}
Copy
Ask AI
// androidMain/KYCManager.kt
actual class KYCManager(private val activity: Activity) {
actual fun openVerification(url: String) {
val intent = Intent(activity, KYCWebViewActivity::class.java).apply {
putExtra("url", url)
}
activity.startActivity(intent)
}
actual fun startStatusPolling(userId: String, onComplete: (String) -> Unit) {
// Use coroutines for polling
CoroutineScope(Dispatchers.Main).launch {
while (true) {
delay(3000)
val status = apiClient.getKYCStatus(userId)
if (status in listOf("approved", "rejected")) {
onComplete(status)
break
}
}
}
}
}
Copy
Ask AI
// iosMain/KYCManager.kt
actual class KYCManager {
actual fun openVerification(url: String) {
let vc = KYCViewController(urlString: url)
// Present view controller
UIApplication.shared.windows.first?.rootViewController?
.present(vc, animated: true)
}
actual fun startStatusPolling(userId: String, onComplete: (String) -> Unit) {
Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { timer in
Task {
let status = try await apiClient.getKYCStatus(userId: userId)
if status == "approved" || status == "rejected" {
onComplete(status)
timer.invalidate()
}
}
}
}
}
React Native
Copy
Ask AI
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); // Poll every 3 seconds
};
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} // For camera
allowsInlineMediaPlayback={true}
/>
);
};
const styles = StyleSheet.create({
webview: {
flex: 1
},
loading: {
flex: 1,
justifyContent: 'center',
alignItems: 'center'
},
error: {
flex: 1,
justifyContent: 'center',
alignItems: 'center'
}
});
- Add camera permissions to
AndroidManifest.xmlandInfo.plist - Install:
npm install react-native-webview
Flutter
Copy
Ask AI
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;
});
// Initialize 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),
);
}
}
Copy
Ask AI
dependencies:
webview_flutter: ^4.4.0
http: ^1.1.0
- Android: Add camera permission to
AndroidManifest.xml - iOS: Add camera usage description to
Info.plist
Web Integration
React
Copy
Ask AI
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); // Poll every 3 seconds
};
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
Copy
Ask AI
<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 {
// Your auth token logic
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)
Copy
Ask AI
<!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'; // Replace with actual user ID
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();
// Show iframe and hide loading
document.getElementById('loading').style.display = 'none';
const iframe = document.getElementById('verification-iframe');
iframe.src = verificationUrl;
iframe.style.display = 'block';
// Start polling for status
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); // Poll every 3 seconds
}
function handleApproved() {
alert('Verification approved!');
window.location.href = '/dashboard'; // Redirect
}
function handleRejected() {
alert('Verification rejected. Please try again.');
window.location.href = '/kyc-retry'; // Redirect
}
function getAuthToken() {
// Get auth token from localStorage, cookie, etc.
return localStorage.getItem('authToken');
}
// Start loading when page loads
window.addEventListener('DOMContentLoaded', loadVerificationUrl);
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
if (pollingInterval) {
clearInterval(pollingInterval);
}
});
</script>
</body>
</html>
Detecting Flow Completion
The WebView/iframe cannot directly notify your app when verification completes. The Gu1 verification UI runs in isolation for security reasons.
Recommended Pattern: Backend Polling
Your frontend polls your backend, which receives webhooks from Gu1: Backend endpoint example:Copy
Ask AI
// Node.js/Express example
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
});
});
- JavaScript
- Kotlin
- Swift
Copy
Ask AI
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 seconds
}
Copy
Ask AI
fun pollStatus(userId: String) {
lifecycleScope.launch {
while (true) {
delay(3000)
val status = apiClient.getKYCStatus(userId)
if (status in listOf("approved", "rejected")) {
handleComplete(status)
break
}
}
}
}
Copy
Ask AI
func pollStatus(userId: String) {
Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { timer in
Task {
let status = try await apiClient.getKYCStatus(userId: userId)
if status == "approved" || status == "rejected" {
timer.invalidate()
handleComplete(status)
}
}
}
}
Alternative: WebSockets (Advanced)
For better UX, use WebSockets to push updates instead of polling:Copy
Ask AI
// Backend (Socket.IO example)
io.on('connection', (socket) => {
socket.on('subscribe-kyc', (userId) => {
socket.join(`kyc:${userId}`);
});
});
// When webhook arrives
app.post('/webhooks/kyc', async (req, res) => {
const { event, payload } = req.body;
// Update database
await updateUserStatus(payload);
// Notify connected clients
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);
});
Webhook Handling
Your backend receives webhooks from Gu1 when verification completes.Webhook Integration Guide
See complete webhook integration guide with code examples, security, and payload structures
Key Webhook Events
| Event | When Fired | Action |
|---|---|---|
kyc.validation_approved | User verified successfully | Update user status to verified, enable features |
kyc.validation_rejected | Verification failed | Update status to rejected, optionally allow retry |
kyc.validation_abandoned | User left without completing | Mark as incomplete, send reminder email |
kyc.validation_expired | Session expired | Allow user to request new URL |
Copy
Ask AI
app.post('/webhooks/gu1/kyc', async (req, res) => {
// Verify signature (see webhook guide)
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');
});
Complete Flow Example
Let’s see a complete end-to-end example:1. Backend: Generate URL
Copy
Ask AI
// 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 });
// Call Gu1 API
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
});
// Store validation ID
await db.users.update({
where: { id: userId },
data: {
kycValidationId: validation.id,
kycStatus: 'pending'
}
});
res.json({
verificationUrl: validation.validation_url
});
});
2. Frontend: Open Verification
Copy
Ask AI
// User clicks "Verify Identity"
async function startVerification() {
const { verificationUrl } = await fetch('/api/kyc/start', {
method: 'POST',
body: JSON.stringify({ userId: currentUser.id })
}).then(r => r.json());
// Open in WebView/iframe
openVerificationUI(verificationUrl);
// Start polling
pollForCompletion();
}
3. Backend: Receive Webhook
Copy
Ask AI
// 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: Detect Completion
Copy
Ask AI
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);
}
Testing
Sandbox Environment
Use sandbox mode for testing:Copy
Ask AI
// Backend
const gu1 = new Gu1Client({
apiKey: process.env.GU1_SANDBOX_API_KEY,
environment: 'sandbox' // Use sandbox for testing
});
- No real documents required
- You can simulate different outcomes
- Webhooks still fire normally
Testing Different Outcomes
To test rejected/expired scenarios, use different test data in sandbox mode.Troubleshooting
WebView shows blank page
WebView shows blank page
Possible causes:
- URL expired (generate a new one)
- JavaScript disabled in WebView
- Network connectivity issues
- Enable JavaScript:
settings.javaScriptEnabled = true - Check URL is valid and not expired
- Test URL in regular browser first
Camera not working in WebView
Camera not working in WebView
Possible causes:iOS:Web:
- Missing camera permissions
- Media playback requires user gesture
Copy
Ask AI
<uses-permission android:name="android.permission.CAMERA" />
Copy
Ask AI
configuration.mediaTypesRequiringUserActionForPlayback = []
Copy
Ask AI
<iframe allow="camera; microphone" />
Polling never detects completion
Polling never detects completion
Possible causes:
- Webhook not received by backend
- Webhook signature verification failing
- Database not being updated
- Check webhook logs in Gu1 dashboard
- Verify webhook signature implementation
- Add logging to webhook handler
- Test webhook endpoint manually
User stuck in verification
User stuck in verification
Possible causes:
- Poor lighting for document photos
- Unsupported document type
- Technical issues
- Provide clear instructions before starting
- Show examples of good vs bad photos
- Implement timeout (15-20 minutes)
- Allow user to exit and retry
Best Practices Summary
Security First
- Never expose API keys in client
- Always generate URLs from backend
- Verify webhook signatures
- Use HTTPS everywhere
User Experience
- Show loading states
- Provide clear instructions
- Handle errors gracefully
- Allow retry on failure
Reliability
- Implement polling with reasonable intervals
- Handle network failures
- Set proper timeouts
- Test all platforms thoroughly
Compliance
- Don’t store sensitive verification data
- Use Process-and-Purge pattern
- Respect data retention policies
- Document your integration