Skip to main content

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

// ✅ 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
Generate verification URLs that expire quickly (15-30 minutes). If user doesn’t start within that time, generate a new one.
// 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
);
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
// 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');
});
Always verify webhooks are from Gu1 using HMAC signatures.Learn about webhook security →

❌ DON’T

// ❌ 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
// ❌ 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.
// ❌ WRONG: Trusting client to report completion
function onVerificationComplete() {
  // User could fake this
  updateUserStatus('approved');
}
Instead: Only trust webhooks received by your backend.

Mobile Integration

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 // 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()
    }
}
Permissions (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()

        // 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()
    }
}
Permissions (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)

Perfect for sharing logic between Android and iOS:
// 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
    }
}

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); // 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

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):
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

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

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

<!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.
Your frontend polls your backend, which receives webhooks from Gu1: Backend endpoint example:
// 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):
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
}

Alternative: WebSockets (Advanced)

For better UX, use WebSockets to push updates instead of polling:
// 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

EventWhen FiredAction
kyc.validation_approvedUser verified successfullyUpdate user status to verified, enable features
kyc.validation_rejectedVerification failedUpdate status to rejected, optionally allow retry
kyc.validation_abandonedUser left without completingMark as incomplete, send reminder email
kyc.validation_expiredSession expiredAllow user to request new URL
Minimal webhook handler:
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

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

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

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

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:
// 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

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
Possible causes:
  • Missing camera permissions
  • Media playback requires user gesture
Solutions:Android:
<uses-permission android:name="android.permission.CAMERA" />
iOS:
configuration.mediaTypesRequiringUserActionForPlayback = []
Web:
<iframe allow="camera; microphone" />
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
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

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

Next Steps