Direct WebView Integration (Without SDK)
If you prefer not to use an SDK package, you can integrate Klero directly using a WebView on any platform. All Klero SDKs use this same approach internally.
How It Works
Klero embeds are web components loaded via a single JavaScript file. You create a simple HTML page containing the Klero script and the appropriate custom element, then load it in a WebView.
HTML Template
This is the HTML template used by all platforms. Replace YOUR-PROJECT with your project slug and adjust the element tag for your module type.
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<style>
html, body { height: 100%; margin: 0; background: transparent; }
klero-feedback { display: block; }
</style>
<script src="https://YOUR-PROJECT.klero.ai/embed/klero.js" async></script>
</head>
<body>
<klero-feedback data-config='CONFIG_JSON'></klero-feedback>
</body>
</html>Module Elements
| Module | HTML Element |
|---|---|
| Feedback | <klero-feedback> |
| Roadmap | <klero-roadmap> |
| Changelog | <klero-changelog> |
| Widget | <klero-widget> |
| Survey | Called via window.Klero.openSurvey('SURVEY_ULID') — no HTML element needed |
Config JSON Format
{
"projectId": "your-project-slug",
"baseUrl": "https://your-project-slug.klero.ai",
"customerToken": "optional-jwt-token",
"surveyUlid": "only-for-surveys",
"theme": "light | dark | system",
"background": false
}The projectId and baseUrl fields are required. customerToken is optional for authenticated users. surveyUlid is only needed for survey embeds.
theme overrides the appearance configured for the project in Klero. Embeds render inside a shadow root and never read the host page's own dark-mode class, so this is the only way to change their appearance from the page.
background controls what sits behind the embed. Embeds are transparent by default so they blend into the surrounding page. Pass true to paint the embed theme's own background colour, or a CSS colour string (for example "#ffffff") to choose one. Make sure a transparent embed's theme matches the page it sits on, or its text may be hard to read.
iOS (WKWebView)
import UIKit
import WebKit
class KleroViewController: UIViewController {
private var webView: WKWebView!
override func viewDidLoad() {
super.viewDidLoad()
let config = WKWebViewConfiguration()
config.preferences.javaScriptEnabled = true
webView = WKWebView(frame: view.bounds, configuration: config)
webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
webView.isOpaque = false
webView.backgroundColor = .clear
webView.scrollView.bounces = false
view.addSubview(webView)
let projectSlug = "myproject"
let domain = "https://\(projectSlug).klero.ai"
let configJson = """
{"projectId":"\(projectSlug)","baseUrl":"\(domain)"}
"""
let html = """
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<style>
html, body { height: 100%; margin: 0; background: transparent; }
klero-feedback { display: block; }
</style>
<script src="\(domain)/embed/klero.js" async></script>
</head>
<body>
<klero-feedback data-config='\(configJson)'></klero-feedback>
</body>
</html>
"""
webView.loadHTMLString(html, baseURL: URL(string: domain))
}
}Android (WebView)
import android.annotation.SuppressLint
import android.os.Bundle
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.app.AppCompatActivity
class KleroActivity : AppCompatActivity() {
private lateinit var webView: WebView
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
webView = WebView(this).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
setBackgroundColor(android.graphics.Color.TRANSPARENT)
webViewClient = WebViewClient()
}
setContentView(webView)
val projectSlug = "myproject"
val domain = "https://$projectSlug.klero.ai"
val configJson = """{"projectId":"$projectSlug","baseUrl":"$domain"}"""
val html = """
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<style>
html, body { height: 100%; margin: 0; background: transparent; }
klero-feedback { display: block; }
</style>
<script src="$domain/embed/klero.js" async></script>
</head>
<body>
<klero-feedback data-config='$configJson'></klero-feedback>
</body>
</html>
""".trimIndent()
webView.loadDataWithBaseURL(domain, html, "text/html", "UTF-8", null)
}
override fun onDestroy() {
super.onDestroy()
webView.destroy()
}
}Don't forget to add INTERNET permission in your AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />React Native (react-native-webview)
npm install react-native-webviewimport React from 'react';
import { View, StyleSheet } from 'react-native';
import { WebView } from 'react-native-webview';
function KleroFeedback() {
const projectSlug = 'myproject';
const domain = `https://${projectSlug}.klero.ai`;
const configJson = JSON.stringify({
projectId: projectSlug,
baseUrl: domain,
});
const html = `
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<style>
html, body { height: 100%; margin: 0; background: transparent; }
klero-feedback { display: block; }
</style>
<script src="${domain}/embed/klero.js" async></script>
</head>
<body>
<klero-feedback data-config='${configJson.replace(/'/g, "'")}'></klero-feedback>
</body>
</html>`;
return (
<View style={styles.container}>
<WebView
source={{ html, baseUrl: domain }}
originWhitelist={['*']}
javaScriptEnabled
domStorageEnabled
style={styles.webview}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
webview: { flex: 1, backgroundColor: 'transparent' },
});Flutter (webview_flutter)
# pubspec.yaml
dependencies:
webview_flutter: ^4.0.0import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
class KleroFeedback extends StatefulWidget {
final String projectSlug;
final String? customerToken;
const KleroFeedback({
super.key,
required this.projectSlug,
this.customerToken,
});
@override
State<KleroFeedback> createState() => _KleroFeedbackState();
}
class _KleroFeedbackState extends State<KleroFeedback> {
late final WebViewController _controller;
@override
void initState() {
super.initState();
final domain = 'https://${widget.projectSlug}.klero.ai';
final config = {
'projectId': widget.projectSlug,
'baseUrl': domain,
if (widget.customerToken != null) 'customerToken': widget.customerToken,
};
final configJson = config.entries
.map((e) => '"${e.key}":"${e.value}"')
.join(',');
final html = '''
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<style>
html, body { height: 100%; margin: 0; background: transparent; }
klero-feedback { display: block; }
</style>
<script src="$domain/embed/klero.js" async></script>
</head>
<body>
<klero-feedback data-config='{$configJson}'></klero-feedback>
</body>
</html>
''';
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.transparent)
..loadHtmlString(html, baseUrl: domain);
}
@override
Widget build(BuildContext context) {
return WebViewWidget(controller: _controller);
}
}
// Usage
class FeedbackPage extends StatelessWidget {
const FeedbackPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Feedback')),
body: const KleroFeedback(projectSlug: 'myproject'),
);
}
}Receiving Events via JS Bridge
To receive events from the embed (e.g., survey completion), set up a JavaScript bridge. The embed posts messages via window.webkit.messageHandlers.klero.postMessage() (iOS) or KleroAndroid.postMessage() (Android).
Event Format
{
"type": "klero:survey:completed",
"data": {
"surveyUlid": "01ABC123...",
"responseUlid": "01XYZ789...",
"answers": {}
}
}Available Events
| Event | Description |
|---|---|
klero:survey:completed | Survey was submitted |
klero:survey:closed | Survey was closed |
klero:survey:error | Survey failed to load |
klero:widget:opened | Widget panel opened |
klero:widget:closed | Widget panel closed |
Platform SDKs
| Platform | Status | Package |
|---|---|---|
| React | Available | @kleroai/react |
| Vue | Available | @kleroai/vue |
| React Native | Available | @kleroai/react-native |
| Flutter | Available | klero_flutter |
| iOS | Available | KleroSDK |
| Android | Available | klero-android |
| Expo | Planned | Wrapper around @kleroai/react-native with Expo config plugin |
| Capacitor/Ionic | No SDK needed | Web embeds work natively — just use the <script> tag |