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

# Native Apps

> Collect cards in an iOS, Android, or React Native app by showing the Card Element in a WebView.

There is no native SDK. Instead, show the [Card Element](https://docs.henrylabs.ai/v1/sdk/client/elements/card-element.md) in a WebView and pass the `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.

> [!WARNING]
>
> 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.

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

The app receives one of these messages:

| `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 until `ready` arrives and while a submit is in progress. Give the WebView a height of about `140` points so both rows of fields fit.

**Swift**

Add `card.html` to your app target.

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

**Kotlin**

Put `card.html` in `app/src/main/assets/`, and make sure your manifest has the `INTERNET` permission.

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

Wire it to your layout's WebView and button:

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

**React Native**

Install `react-native-webview`, then export the page from a module:

```ts cardHtml.ts theme={null}
export const cardHtml = `<!doctype html> ...`; // contents of card.html
```

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

> [!NOTE]
>
> 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

Send `cardToken` to your backend and use it with the [server SDK](https://docs.henrylabs.ai/v1/sdk/server/guides/checkout.md#headless-checkout), exactly as you would from the web.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.