> ## Documentation Index
> Fetch the complete documentation index at: https://docs.gu1.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Client-Side Integration Guide

> Complete guide for integrating KYC verification in mobile apps and web applications — in the gu1 KYC API for identity verification flows.

## 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.

<Note>
  This guide assumes you've already set up your backend integration with Gu1's API. If not, start with the [KYC API Overview](/en/use-cases/kyc/overview).
</Note>

## Architecture Overview

```mermaid theme={null}
sequenceDiagram
    participant User
    participant YourApp as Your Frontend<br/>(Mobile/Web)
    participant YourBackend as Your Backend
    participant Gu1API as Gu1 API
    participant Gu1UI as Gu1 Verification UI

    User->>YourApp: Start KYC verification
    YourApp->>YourBackend: Request verification URL
    YourBackend->>Gu1API: POST /kyc/validations<br/>(with entity data)
    Gu1API-->>YourBackend: Returns validation_url
    YourBackend-->>YourApp: Returns secure URL
    YourApp->>Gu1UI: Open WebView/iframe with URL
    User->>Gu1UI: Complete verification<br/>(document + selfie)
    Gu1UI-->>User: Show completion message
    Gu1API->>YourBackend: Send webhook (approved/rejected)
    YourBackend->>YourBackend: Update user status
    YourApp->>YourBackend: Poll for status updates
    YourBackend-->>YourApp: Return verification result
    YourApp->>User: Show final result
```

**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

<Warning>
  **CRITICAL**: Never expose Gu1 API credentials or workflow IDs in your client-side code. Always generate verification URLs from your secure backend.
</Warning>

### ✅ DO

<AccordionGroup>
  <Accordion title="Generate URLs on-demand from your backend">
    ```javascript theme={null}
    // ✅ 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
    ```
  </Accordion>

  <Accordion title="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.

    ```javascript theme={null}
    // 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
    );
    ```
  </Accordion>

  <Accordion title="Use Process-and-Purge pattern">
    When receiving webhooks from Gu1:

    1. Extract only necessary data
    2. Update user status in your DB
    3. Don't store sensitive verification data long-term

    ```javascript theme={null}
    // 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');
    });
    ```
  </Accordion>

  <Accordion title="Validate webhook signatures">
    Always verify webhooks are from Gu1 using HMAC signatures.

    [Learn about webhook security →](/en/webhooks/security)
  </Accordion>
</AccordionGroup>

### ❌ DON'T

<AccordionGroup>
  <Accordion title="Never hardcode API keys or workflow IDs">
    ```javascript theme={null}
    // ❌ 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
      }
    });
    ```

    **Why it's dangerous:**

    * API keys exposed in app bundles can be extracted
    * Anyone can create validations on your behalf
    * Impossible to rotate compromised keys without app updates
  </Accordion>

  <Accordion title="Don't reuse verification URLs">
    ```javascript theme={null}
    // ❌ WRONG: Storing URL permanently
    localStorage.setItem('kycUrl', verificationUrl);

    // Later...
    window.open(localStorage.getItem('kycUrl')); // URL might be expired
    ```

    **Instead:** Always request a fresh URL from your backend when needed.
  </Accordion>

  <Accordion title="Don't rely on client-side validation">
    ```javascript theme={null}
    // ❌ WRONG: Trusting client to report completion
    function onVerificationComplete() {
      // User could fake this
      updateUserStatus('approved');
    }
    ```

    **Instead:** Only trust webhooks received by your backend.
  </Accordion>
</AccordionGroup>

***

## Mobile Integration

### Android (Kotlin)

<Tabs>
  <Tab title="Standard Android">
    ```kotlin theme={null}
    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()
        }
    }
    ```
  </Tab>

  <Tab title="Jetpack Compose">
    ```kotlin theme={null}
    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)
                        }
                    }
                )
            }
        }
    }
    ```
  </Tab>
</Tabs>

**Permissions (AndroidManifest.xml):**

```xml theme={null}
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
```

***

### iOS (Swift)

<Tabs>
  <Tab title="UIKit">
    ```swift theme={null}
    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()
        }
    }
    ```
  </Tab>

  <Tab title="SwiftUI">
    ```swift theme={null}
    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)
        }
    }
    ```
  </Tab>
</Tabs>

**Permissions (Info.plist):**

```xml theme={null}
<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:

<Tabs>
  <Tab title="Common Code">
    ```kotlin theme={null}
    // 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
        }
    }
    ```
  </Tab>

  <Tab title="Android Implementation">
    ```kotlin theme={null}
    // 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
                    }
                }
            }
        }
    }
    ```
  </Tab>

  <Tab title="iOS Implementation">
    ```swift theme={null}
    // 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()
                    }
                }
            }
        }
    }
    ```
  </Tab>
</Tabs>

***

### React Native

```typescript theme={null}
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'
  }
});
```

**Permissions:**

* Add camera permissions to `AndroidManifest.xml` and `Info.plist`
* Install: `npm install react-native-webview`

***

### Flutter

```dart theme={null}
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),
    );
  }
}
```

**Dependencies (pubspec.yaml):**

```yaml theme={null}
dependencies:
  webview_flutter: ^4.4.0
  http: ^1.1.0
```

**Permissions:**

* Android: Add camera permission to `AndroidManifest.xml`
* iOS: Add camera usage description to `Info.plist`

***

## Web Integration

### React

```typescript theme={null}
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

```vue theme={null}
<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)

```html theme={null}
<!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

<Warning>
  The WebView/iframe **cannot** directly notify your app when verification completes. The Gu1 verification UI runs in isolation for security reasons.
</Warning>

### Recommended Pattern: Backend Polling

Your frontend polls your backend, which receives webhooks from Gu1:

```mermaid theme={null}
sequenceDiagram
    participant User
    participant Frontend
    participant YourBackend
    participant Gu1

    User->>Gu1: Complete verification
    Gu1->>YourBackend: Webhook (approved/rejected)
    YourBackend->>YourBackend: Update user status in DB

    loop Every 3 seconds
        Frontend->>YourBackend: Poll: GET /kyc/status/:userId
        YourBackend-->>Frontend: Return current status
    end

    Frontend->>Frontend: Detect status change
    Frontend->>User: Show result
```

**Backend endpoint example:**

```javascript theme={null}
// 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
  });
});
```

**Frontend polling (all platforms):**

<Tabs>
  <Tab title="JavaScript">
    ```javascript theme={null}
    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
    }
    ```
  </Tab>

  <Tab title="Kotlin">
    ```kotlin theme={null}
    fun pollStatus(userId: String) {
      lifecycleScope.launch {
        while (true) {
          delay(3000)
          val status = apiClient.getKYCStatus(userId)
          if (status in listOf("approved", "rejected")) {
            handleComplete(status)
            break
          }
        }
      }
    }
    ```
  </Tab>

  <Tab title="Swift">
    ```swift theme={null}
    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)
          }
        }
      }
    }
    ```
  </Tab>
</Tabs>

### Alternative: WebSockets (Advanced)

For better UX, use WebSockets to push updates instead of polling:

```javascript theme={null}
// 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.

<Card title="Webhook Integration Guide" icon="webhook" href="/en/webhooks/events/kyc-events">
  See complete webhook integration guide with code examples, security, and payload structures
</Card>

### 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                       |

**Minimal webhook handler:**

```javascript theme={null}
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

```javascript theme={null}
// 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 });

  // Ensure person entity exists in Gu1 (create or get existing by externalId/taxId)
  let entityId = user.gu1EntityId;
  if (!entityId) {
    const entityRes = await gu1.post('/entities', {
      type: 'person',
      externalId: user.id,
      name: user.name,
      taxId: user.taxId,
      countryCode: user.countryCode || 'US'
    });
    entityId = entityRes.entity?.id ?? entityRes.id;
    await db.users.update({ where: { id: userId }, data: { gu1EntityId: entityId } });
  }

  // Create KYC validation (entityId + integrationCode)
  const validation = await gu1.post('/kyc/validations', {
    entityId,
    integrationCode: 'global_gueno_validation_kyc'
  });

  await db.users.update({
    where: { id: userId },
    data: {
      kycValidationId: validation.id,
      kycStatus: 'pending'
    }
  });

  res.json({
    verificationUrl: validation.providerSessionUrl
  });
});
```

### 2. Frontend: Open Verification

```javascript theme={null}
// 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

```javascript theme={null}
// 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

```javascript theme={null}
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:

```javascript theme={null}
// Backend
const gu1 = new Gu1Client({
  apiKey: process.env.GU1_SANDBOX_API_KEY,
  environment: 'sandbox' // Use sandbox for testing
});
```

In sandbox mode:

* 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

<AccordionGroup>
  <Accordion title="WebView shows blank page">
    **Possible causes:**

    * URL expired (generate a new one)
    * JavaScript disabled in WebView
    * Network connectivity issues

    **Solutions:**

    * Enable JavaScript: `settings.javaScriptEnabled = true`
    * Check URL is valid and not expired
    * Test URL in regular browser first
  </Accordion>

  <Accordion title="Camera not working in WebView">
    **Possible causes:**

    * Missing camera permissions
    * Media playback requires user gesture

    **Solutions:**

    **Android:**

    ```xml theme={null}
    <uses-permission android:name="android.permission.CAMERA" />
    ```

    **iOS:**

    ```swift theme={null}
    configuration.mediaTypesRequiringUserActionForPlayback = []
    ```

    **Web:**

    ```html theme={null}
    <iframe allow="camera; microphone" />
    ```
  </Accordion>

  <Accordion title="Polling never detects completion">
    **Possible causes:**

    * Webhook not received by backend
    * Webhook signature verification failing
    * Database not being updated

    **Solutions:**

    * Check webhook logs in Gu1 dashboard
    * Verify webhook signature implementation
    * Add logging to webhook handler
    * Test webhook endpoint manually
  </Accordion>

  <Accordion title="User stuck in verification">
    **Possible causes:**

    * Poor lighting for document photos
    * Unsupported document type
    * Technical issues

    **Solutions:**

    * Provide clear instructions before starting
    * Show examples of good vs bad photos
    * Implement timeout (15-20 minutes)
    * Allow user to exit and retry
  </Accordion>
</AccordionGroup>

***

## Best Practices Summary

<CardGroup cols={2}>
  <Card title="Security First" icon="shield-check">
    * Never expose API keys in client
    * Always generate URLs from backend
    * Verify webhook signatures
    * Use HTTPS everywhere
  </Card>

  <Card title="User Experience" icon="face-smile">
    * Show loading states
    * Provide clear instructions
    * Handle errors gracefully
    * Allow retry on failure
  </Card>

  <Card title="Reliability" icon="chart-line">
    * Implement polling with reasonable intervals
    * Handle network failures
    * Set proper timeouts
    * Test all platforms thoroughly
  </Card>

  <Card title="Compliance" icon="scale-balanced">
    * Don't store sensitive verification data
    * Use Process-and-Purge pattern
    * Respect data retention policies
    * Document your integration
  </Card>
</CardGroup>

***

## Next Steps

<CardGroup cols={2}>
  <Card title="KYC API Overview" icon="book" href="/en/use-cases/kyc/overview">
    Learn about the KYC API and backend integration
  </Card>

  <Card title="Webhook Integration" icon="webhook" href="/en/webhooks/events/kyc-events">
    Set up webhooks to receive verification results
  </Card>

  <Card title="Complete Flow Guide" icon="diagram-project" href="/en/use-cases/kyc/complete-flow">
    See the full KYC verification flow
  </Card>

  <Card title="Security Guide" icon="lock" href="/en/webhooks/security">
    Implement webhook security and HMAC verification
  </Card>
</CardGroup>
