Skip to content

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.

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="https://YOUR-PROJECT.klero.ai/embed/klero.js" async></script>
</head>
<body>
    <klero-feedback data-config='CONFIG_JSON'></klero-feedback>
</body>
</html>

Module Elements ​

ModuleHTML Element
Feedback<klero-feedback>
Roadmap<klero-roadmap>
Changelog<klero-changelog>
Widget<klero-widget>
SurveyCalled via window.Klero.openSurvey('SURVEY_ULID') — no HTML element needed

Config JSON Format ​

json
{
    "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) ​

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

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

xml
<uses-permission android:name="android.permission.INTERNET" />

React Native (react-native-webview) ​

bash
npm install react-native-webview
tsx
import 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, "&#39;")}'></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) ​

yaml
# pubspec.yaml
dependencies:
  webview_flutter: ^4.0.0
dart
import '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 ​

json
{
    "type": "klero:survey:completed",
    "data": {
        "surveyUlid": "01ABC123...",
        "responseUlid": "01XYZ789...",
        "answers": {}
    }
}

Available Events ​

EventDescription
klero:survey:completedSurvey was submitted
klero:survey:closedSurvey was closed
klero:survey:errorSurvey failed to load
klero:widget:openedWidget panel opened
klero:widget:closedWidget panel closed

Platform SDKs ​

PlatformStatusPackage
ReactAvailable@kleroai/react
VueAvailable@kleroai/vue
React NativeAvailable@kleroai/react-native
FlutterAvailableklero_flutter
iOSAvailableKleroSDK
AndroidAvailableklero-android
ExpoPlannedWrapper around @kleroai/react-native with Expo config plugin
Capacitor/IonicNo SDK neededWeb embeds work natively — just use the <script> tag

Customer Feedback Management