cardToken to your app through the WebView’s message bridge. Card data stays inside Henry’s iframe, so it never touches your app or your servers.
Load the page with an
https base URL, as in the examples below. Any URL
works; nothing is fetched from it. Without one, submit() never returns.1. Add the card page
Ship this page with your app. It mounts the Card Element and sends results to whichever bridge is present.card.html
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.jsdelivr.net/npm/@henrylabs/js@1.0.10"></script>
<style>
body { margin: 0; background: transparent; }
</style>
</head>
<body>
<div id="card"></div>
<script>
// Sends a JSON message to whichever native bridge is present
function send(message) {
const json = JSON.stringify(message);
if (window.webkit?.messageHandlers?.henry) {
window.webkit.messageHandlers.henry.postMessage(json); // iOS
} else if (window.HenryBridge) {
window.HenryBridge.postMessage(json); // Android
} else if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(json); // React Native
}
}
// Rejects if the card form doesn't answer within 15 seconds
function withTimeout(promise) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error("Timed out")), 15000);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
const loadTimer = setTimeout(
() => send({ type: "error", message: "Card form failed to load" }),
15000,
);
const cardEl = Henry.createCardElement("#card", {
onStyled: () => {
clearTimeout(loadTimer);
send({ type: "ready" });
},
styles: {
container: { padding: "1px" },
section1: { marginBottom: "12px" },
section2: { gap: "12px" },
inputWrappers: {
border: "1px solid #d0d5dd",
borderRadius: "8px",
padding: "12px",
background: "#fff",
},
cardNumber: { base: { fontSize: "16px", color: "#111" } },
cardExpiration: { base: { fontSize: "16px", color: "#111" } },
cardVerification: { base: { fontSize: "16px", color: "#111" } },
},
});
// Called by the app when the user taps its "Save card" button
let submitting = false;
window.submitCard = async () => {
if (submitting) return;
submitting = true;
try {
if (!(await withTimeout(cardEl.validate()))) {
return send({ type: "invalid" });
}
const card = await withTimeout(cardEl.submit());
send({ type: "token", ...card });
} catch (error) {
send({ type: "error", message: String(error) });
} finally {
submitting = false;
}
};
</script>
</body>
</html>
type | Meaning |
|---|---|
ready | The form has loaded. Enable your submit button |
token | Success: cardToken, cardBrand, cardBin, cardLast4 |
invalid | The buyer hasn’t finished entering the card |
error | message. The form failed to load, or the request failed or timed out |
2. Show it in your app
Keep your submit button disabled untilready arrives and while a submit is in progress. Give the WebView a height of about 140 points so both rows of fields fit.
- Swift
- Kotlin
- React Native
Add
card.html to your app target.import SwiftUI
import WebKit
struct CardResult: Decodable {
let type: String // "ready", "token", "invalid" or "error"
let cardToken: String?
let cardBrand: String?
let cardLast4: String?
let message: String?
}
final class HenryCardForm: NSObject, WKScriptMessageHandler {
var onResult: ((CardResult) -> Void)?
private weak var webView: WKWebView?
func makeWebView() -> WKWebView {
let config = WKWebViewConfiguration()
config.userContentController.add(self, name: "henry")
let webView = WKWebView(frame: .zero, configuration: config)
let url = Bundle.main.url(forResource: "card", withExtension: "html")!
let html = try! String(contentsOf: url, encoding: .utf8)
// Any https URL works; it gives the page an origin and is never fetched.
webView.loadHTMLString(html, baseURL: URL(string: "https://yourapp.com"))
self.webView = webView
return webView
}
func submit() {
webView?.evaluateJavaScript("submitCard()")
}
func userContentController(
_ controller: WKUserContentController,
didReceive message: WKScriptMessage
) {
guard let json = (message.body as? String)?.data(using: .utf8),
let result = try? JSONDecoder().decode(CardResult.self, from: json)
else { return }
onResult?(result)
}
}
struct CardFormView: UIViewRepresentable {
let form: HenryCardForm
func makeUIView(context: Context) -> WKWebView { form.makeWebView() }
func updateUIView(_ webView: WKWebView, context: Context) {}
}
struct AddCardScreen: View {
@State private var form = HenryCardForm()
@State private var isReady = false
@State private var isSubmitting = false
var body: some View {
VStack {
CardFormView(form: form).frame(height: 140)
Button("Save card") {
isSubmitting = true
form.submit()
}
.disabled(!isReady || isSubmitting)
}
.padding()
.onAppear {
form.onResult = { result in
isSubmitting = false
switch result.type {
case "ready": isReady = true
case "token": sendToBackend(result.cardToken)
case "invalid": showError("Check your card details")
default: showError(result.message)
}
}
}
}
}
Put Wire it to your layout’s WebView and button:
card.html in app/src/main/assets/, and make sure your manifest has the INTERNET permission.import android.annotation.SuppressLint
import android.webkit.JavascriptInterface
import android.webkit.WebView
import org.json.JSONObject
@SuppressLint("SetJavaScriptEnabled")
class HenryCardForm(
private val webView: WebView,
private val onResult: (JSONObject) -> Unit,
) {
init {
webView.settings.javaScriptEnabled = true
webView.addJavascriptInterface(this, "HenryBridge")
val html = webView.context.assets.open("card.html")
.bufferedReader().use { it.readText() }
// Any https URL works; it gives the page an origin and is never fetched.
webView.loadDataWithBaseURL(
"https://yourapp.com", html, "text/html", "utf-8", null,
)
}
fun submit() = webView.evaluateJavascript("submitCard()", null)
@JavascriptInterface
fun postMessage(json: String) {
// Called on a background thread
webView.post { onResult(JSONObject(json)) }
}
}
val saveButton = findViewById<Button>(R.id.save_card)
saveButton.isEnabled = false
var ready = false
val form = HenryCardForm(findViewById(R.id.card_web_view)) { result ->
when (result.getString("type")) {
"ready" -> ready = true
"token" -> sendToBackend(result.getString("cardToken"))
"invalid" -> showError("Check your card details")
"error" -> showError(result.getString("message"))
}
saveButton.isEnabled = ready
}
saveButton.setOnClickListener {
saveButton.isEnabled = false
form.submit()
}
Install
react-native-webview, then export the page from a module:cardHtml.ts
export const cardHtml = `<!doctype html> ...`; // contents of card.html
import { type ComponentRef, useRef, useState } from "react";
import { Button, View } from "react-native";
import { WebView } from "react-native-webview";
import { cardHtml } from "./cardHtml";
export function CardForm() {
const webView = useRef<ComponentRef<typeof WebView>>(null);
const [ready, setReady] = useState(false);
const [submitting, setSubmitting] = useState(false);
return (
<View>
<View style={{ height: 140 }}>
<WebView
ref={webView}
// Any https URL works; it gives the page an origin and is never fetched.
source={{ html: cardHtml, baseUrl: "https://yourapp.com" }}
onMessage={(event) => {
const result = JSON.parse(event.nativeEvent.data);
setSubmitting(false);
if (result.type === "ready") setReady(true);
else if (result.type === "token") sendToBackend(result.cardToken);
else if (result.type === "invalid") showError("Check your card details");
else showError(result.message);
}}
/>
</View>
<Button
title="Save card"
disabled={!ready || submitting}
onPress={() => {
setSubmitting(true);
webView.current?.injectJavaScript("submitCard(); true;");
}}
/>
</View>
);
}
On Android, WebView text follows the system font size. Check the form with
large text, or set
webView.settings.textZoom = 100. To remove the runtime
CDN dependency, paste the SDK’s henry.global.js into card.html instead of
loading it from the CDN.3. Use the token
SendcardToken to your backend and use it with the server SDK, exactly as you would from the web.