> This page is for version 2024-10-01 (default).
> For other versions, use one of these documentation indexes:
> - 2024-10-01 (default): https://docs.getkard.com/2024-10-01/llms.txt
> - Legacy: https://docs.getkard.com/legacy/llms.txt

> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.getkard.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.getkard.com/_mcp/server.

# WebView

This guide covers how to integrate the Kard Rewards WebView in your mobile or web application.

## Introduction

Kard's Rewards WebView provides a turnkey front-end offers experience, complete with a rewards map and intuitive offer discovery for their users. Issuers can integrate Kard's WebView seamlessly into their existing experiences allowing them to launch and iterate on their rewards program quickly and easily.

It is designed to be embedded in:

* **Mobile apps** via native WebView components (iOS WKWebView, Android WebView, React Native WebView)
* **Web applications** via iframe

## Environment URLs

| Environment | URL                                           |
| ----------- | --------------------------------------------- |
| Test        | `https://webview-test-us-east-1.getkard.com/` |
| Production  | `https://webview-prod-us-east-1.getkard.com/` |

## Query Parameters

### **`token`** (required)

A JWT authentication token that identifies the user and organization.

**Required JWT claims:**

| Claim       | Description         |
| ----------- | ------------------- |
| `sub`       | The user ID         |
| `issuer_id` | The organization ID |

The token is used for API authentication when the WebView makes requests to Kard services.

### **`theme`** (optional)

A base64url-encoded JSON string containing design token overrides for customizing the appearance. Uses RFC4648 base64url encoding which is URL-safe (uses `-` instead of `+`, `_` instead of `/`, and omits padding). No `encodeURIComponent` is needed when using base64url encoding.

**Structure:**

```json
{
  "theme": "system" | "light" | "dark",
  "styles": {
    "light": { /* design tokens for light mode */ },
    "dark": { /* design tokens for dark mode */ },
    "layout": { /* mode-independent tokens: radii and font weights */ }
  },
  "labels": {
    "rewardsTitle": "Custom rewards page title",
    "nearbyOffersTitle": "Custom nearby offers title",
    "offersTitle": "Custom offers page title"
  },
  "layout": {
    "showMap": true | false
  },
  "fontFamily": {
    "source": "google" | "custom",
    "family": "Font family name",
    "url": "https://cdn.example.com/fonts.css",
    "weights": ["400", "600"]
  }
}
```

#### Color & theme tokens (styles.light / styles.dark)

These tokens are mode-dependent and can differ between light and dark themes:

| Token                      | Description               |
| -------------------------- | ------------------------- |
| `background`               | Page background color     |
| `primary`                  | Primary brand color       |
| `buttonPrimaryTextColor`   | Text on primary buttons   |
| `secondary`                | Secondary color           |
| `buttonSecondaryTextColor` | Text on secondary buttons |
| `textPrimary`              | Primary text color        |
| `textSecondary`            | Secondary text color      |
| `cardBackgroundColor`      | Card background           |
| `border`                   | Border color              |
| `linkColor`                | Link/button text color    |

All color values must be valid CSS color values (hex, rgb, hsl, oklch, named colors, etc.).

#### Layout tokens (styles.layout)

These tokens are mode-independent and apply to both light and dark themes:

| Token         | Description                                |
| ------------- | ------------------------------------------ |
| `radius`      | Border radius (CSS length)                 |
| `chipRadius`  | Filter chip border radius (CSS length)     |
| `imageRadius` | Image container border radius (CSS length) |
| `cardRadius`  | Card border radius (CSS length)            |

#### Font weight tokens (styles.layout)

The WebView applies a named weight to each text role. These tokens are mode-independent and each value is a numeric weight string (`"100"` through `"900"`):

| Token                    | Default | Applies to                                               |
| ------------------------ | ------- | -------------------------------------------------------- |
| `displayFontWeight`      | `600`   | Display and hero numbers (for example the rewards total) |
| `h1FontWeight`           | `600`   | Page titles                                              |
| `h2FontWeight`           | `600`   | Section headings                                         |
| `h3FontWeight`           | `600`   | Subheadings and page header titles                       |
| `bodyEmphasisFontWeight` | `600`   | Emphasized body text                                     |
| `bodyFontWeight`         | `400`   | Body text                                                |
| `buttonFontWeight`       | `500`   | Button labels                                            |

Only use weights your font actually loads. Setting `h1FontWeight` to `700` when `fontFamily.weights` is `["400", "600"]` leaves the browser to synthesize a fake bold, which usually looks worse than the real 600.

### `labels` (optional)

The `labels` property within the theme parameter allows customization of page titles displayed in the WebView. All label properties are optional — if not specified, the default values are used.

| Label               | Default         | Description                               |
| ------------------- | --------------- | ----------------------------------------- |
| `rewardsTitle`      | `Rewards`       | Title displayed on the rewards page       |
| `nearbyOffersTitle` | `Nearby offers` | Title displayed on the nearby offers page |
| `offersTitle`       | `Offers`        | Title displayed on the offers page        |

### `layout` (optional)

The `layout` property within the theme parameter controls the visibility of page sections. All layout properties are optional — if not specified, the default values are used.

| Property            | Type    | Default | Description                                                                                                                              |
| ------------------- | ------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `showMap`           | boolean | `true`  | Show or hide the nearby offers map section on the rewards page                                                                           |
| `showMapBackButton` | boolean | `true`  | Show or hide the back arrow on the [Map](#map) page. Hide it when your container opens `/map` on its own and provides its own navigation |

### `fontFamily` (optional)

The `fontFamily` property within the theme parameter replaces the UI font. The WebView emits the font's `<link>` tags while server-rendering the page and points its internal `--font-sans` variable at your family, so the first paint already uses your font, with no flash of the default one. Omitting `fontFamily` loads Inter.

The same UI on the default font, then with `fontFamily` set to `Plus Jakarta Sans`, each shown in light and dark:

![Kard WebView UI rendered in Inter, shown in light and dark mode side by side](/_fern-img/6398e3ac63cccbcca068cfdcb0ab86800d0d1c0c8187d1543dab9a1f7b99c62b.webp)![The same UI rendered in Plus Jakarta Sans, shown in light and dark mode side by side](/_fern-img/51450c13e1cd202e399baec32777a91284f9329b8b99872e565a33b875e669f3.webp)

| Field     | Type                     | Required      | Description                                                                                                                                                                                                                     |
| --------- | ------------------------ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `source`  | `"google"` \| `"custom"` | Yes           | Where the font is loaded from                                                                                                                                                                                                   |
| `family`  | string                   | Yes           | With `google`, a single Google Fonts family name such as `Open Sans` or `Source Sans 3` (no commas or quotes). With `custom`, either a single name or a full CSS stack such as `'Acme Sans', sans-serif`, with quotes balanced. |
| `url`     | string                   | With `custom` | HTTPS URL of a CSS stylesheet that contains your `@font-face` rules                                                                                                                                                             |
| `weights` | string\[]                | No            | Weights to load, each a numeric string (`"100"` through `"900"`). Defaults to `["400", "500", "600", "700"]`.                                                                                                                   |

A Google font, loading only the weights the theme uses:

```json
{
  "fontFamily": {
    "source": "google",
    "family": "Source Sans 3",
    "weights": ["400", "600"]
  }
}
```

A self-hosted font:

```json
{
  "fontFamily": {
    "source": "custom",
    "family": "'Acme Sans', sans-serif",
    "url": "https://cdn.example.com/fonts/acme.css"
  }
}
```

> **Note**
>
> For `source: "custom"`, `url` must point at a **stylesheet**, not at a font file. The WebView loads it with `<link rel="stylesheet">`, so a URL ending in `.woff`, `.woff2`, `.ttf`, `.otf`, or `.eot` is rejected. Serve a CSS file whose `@font-face` rules reference your font files, over HTTPS, readable cross-origin from the WebView's origin.

Whatever you configure, the WebView appends the platform sans-serif fallback stack behind it, so a font that fails to load falls back to the system UI font instead of blanking the page. To change the weight used for headings, body text, or buttons, set the font weight tokens in `styles.layout`, listed under the `theme` parameter above.

> **Warning**
>
> The `theme` parameter is validated as a single unit. One bad field discards **all** of the overrides in it and the WebView renders its defaults; overrides are never applied partially. `fontFamily` is the strictest part of the schema, so check these before shipping a theme: a comma or quote in a `google` family name, an `http://` URL, a `url` that points at a font binary, and a missing `url` on a `custom` font are all rejected.

## Standalone views

The root URL opens the full rewards experience. Every page inside it is also addressable on its own path, so a container can open one view directly: a push notification that lands on a single offer, a rewards tab that shows only the map, or a Kard placement carousel inside a screen you built yourself.

A standalone view is a path on the same base URL, and it reads the same query parameters as the root:

```
{baseUrl}/{path}?token={jwt}&theme={base64url}
```

`token` is required on every view. `theme` is optional and behaves exactly as it does at the root, so colors, radii, labels, and fonts carry over unchanged.

| View                                              | Path                                       |
| ------------------------------------------------- | ------------------------------------------ |
| [Offer details](#offer-details)                   | `/offers/{offerId}`                        |
| [Placement](#placement)                           | `/placements/{placementId}`                |
| [Placement slot details](#placement-slot-details) | `/placements/{placementId}/slots/{slotId}` |
| [Rewards history](#rewards-history)               | `/rewards-history`                         |
| [Map](#map)                                       | `/map`                                     |

Each view resolves its content for the user in the `token` and applies the same eligibility rules as the full experience. When the content is missing, expired, or not eligible for that user, the view renders a short unavailable message rather than an error page.

> **Note**
>
> Plan for dismissal in the host. A standalone view has no bottom navigation, and a deep link is usually the first entry in the WebView's history, so an in-page back control has nothing to pop. Give the user a native way out: a close button, a nav bar, or a sheet they can swipe away.

> **Warning**
>
> **Opening a detail view can activate its content.** When an offer or a batch slot carries an activation call-to-action, the WebView fires it as the page renders, without waiting for the user to press anything. Treat `/offers/{offerId}` and `/placements/{placementId}/slots/{slotId}` as actions rather than previews: don't prefetch them, and don't load them in a hidden WebView to warm a cache.

### Offer details

```
{baseUrl}/offers/{offerId}?token={jwt}&theme={base64url}&source={source}
```

A single offer's detail page. Deep link to it from push notifications, emails, SMS, or banners that promote one offer.

| Path / parameter | Description                                                                                                                 |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `{offerId}`      | The ID of the offer to display                                                                                              |
| `token`          | The same JWT as the root URL (required). The offer is fetched and eligibility-checked for this user.                        |
| `theme`          | The same optional `theme` parameter as the root URL.                                                                        |
| `source`         | Optional attribution source describing how the user reached the page (see [Attribution source](#attribution-source) below). |

The page is server-rendered with the issuer's theme and no entrance animation, so it paints immediately with no loading flash.

![Offer details page for Acme Coffee Co., showing the banner, 10% cash back, offer terms, an About section, and a Shop now button](/_fern-img/346596059d150eab8d185612fbb2e63b319b7c6c156a7c6d75a19eeb04050c3d.webp)

> **Note**
>
> Eligibility is enforced per user — the offer only renders if it's available to the user identified by the `token`. If the offer is unavailable (for example, it has expired or the user isn't eligible), a graceful "offer not available" message is shown instead.

### Attribution source

When a cardholder opens the offer detail page, the WebView fires image impression pixels for the offer's assets. By default those impressions are attributed to in-app browsing (`BROWSE`). When you deep link straight to an offer from a campaign — an email, a push notification, a map pin, or search — add the optional **`source`** query parameter so the impression is attributed to how the user actually arrived:

```
{baseUrl}/offers/{offerId}?token={jwt}&source=EMAIL
```

The WebView normalizes the value (it is uppercased) and overrides the `medium` on each tracking image URL before the pixels fire.

| `source` value | Use when the user arrived from                                 |
| -------------- | -------------------------------------------------------------- |
| `BROWSE`       | Organic in-app browsing (the default when `source` is omitted) |
| `EMAIL`        | A link in a partner/issuer email                               |
| `PUSH`         | A push notification                                            |
| `MAP`          | A map pin or location-based surface                            |
| `SEARCH`       | A search result                                                |

> **Note**
>
> The `source` parameter is case-insensitive — `email`, `Email`, and `EMAIL` are equivalent. Any value outside the set above falls back to `BROWSE`. The parameter only affects attribution analytics; it does not change which offer is shown or whether it is eligible.

### Placement

```
{baseUrl}/placements/{placementId}?token={jwt}&theme={base64url}
```

One placement's carousel and nothing else: no page header, no detail panel. Use it to place a Kard carousel inside a screen you own. The placement's display name becomes the section title, falling back to "Featured offers" or "Activate cash back" (as in the screenshots below) when that name hasn't resolved.

A placement holds one of two kinds of content, and the carousel follows whichever it returns.

**Standard offers.** One card per offer, each showing the merchant and its reward.

![Featured offers carousel: two offer cards showing 3% cash back at Hydro Flask and 5% cash back at Patagonia](/_fern-img/f1d8fcf3d7c86d3386e2c875fa788369f5526b69ef8f7e03887d000785d515cf.webp)

**Batch activation slots.** One card per slot, each standing for a bundle of offers the user turns on together.

![Batch activation carousel: a locked Newly Live slot reading tap to activate with three offer logos and a +3 count, next to an already-activated Travel slot](/_fern-img/9a8ea50983db894a2c3c78f076fdb57bc3e36a0d0cf9158972ebb2ddf81c141b.webp)

#### Batch activation

A batch-activation placement groups offers into **slots**. A slot is a named bundle — "Travel", "Dining" — holding the offers that resolve for that user under the slot's content strategy. The user activates the bundle rather than each offer in it: activating a slot turns on every offer inside, and those offers then appear in the user's main offers list.

Each slot card carries a lock icon while the slot is waiting to be activated and a check once the user has an active activation, along with a cluster of the bundle's offer logos and a `+N` count for the ones that don't fit.

Activation runs on a cycle. The placement's `refreshInterval` (an ISO-8601 duration such as `P7D`) sets how long one lasts, so a slot's `expiresAt` is its `lastActivatedAt` plus that interval. Once it passes, the slot goes stale and the user activates it again to pick up the next set of offers. A slot that has expired but resolves to no offers stays active on purpose, so your UI never invites a user to refresh into an empty bundle. Slots on a **group** placement have no activation cycle at all — they stay active and act purely as a grouping.

#### Handling taps

Since this view has no detail panel, it hands taps back to you instead of navigating. On each tap the WebView posts a `PLACEMENT_ITEM_CLICKED` message through the same channel it uses for location (`window.KardWebview.postMessage`, or the iframe parent):

```json
{
  "type": "PLACEMENT_ITEM_CLICKED",
  "payload": {
    "id": "the ID of the tapped offer or slot",
    "type": "offer"
  }
}
```

`payload.type` is `offer` for a standard offer and `placement` for a batch slot. Respond however you like: open your own screen, or load the [offer details](#offer-details) or [placement slot details](#placement-slot-details) view for that ID.

### Placement slot details

```
{baseUrl}/placements/{placementId}/slots/{slotId}?token={jwt}&theme={base64url}
```

The detail page for one [batch activation](#batch-activation) slot: the slot's name and artwork, the activation copy, and every offer in the bundle under an "Included offers" heading. Both IDs are required, since the slot is resolved inside the placement named in the path. Like offer details, the page is server-rendered, so the slot is already in the initial HTML.

![Placement slot details page for a Travel slot, showing activation copy and the list of included offers](/_fern-img/e423ed28f639f88ec06cf6f8f93425876f797be17bfedf050bd0fcc815f43f14.webp)

An unknown, expired, or ineligible slot renders an "Activation not available" message.

### Rewards history

```
{baseUrl}/rewards-history?token={jwt}&theme={base64url}
```

The user's earned cash back: a total, `12M` / `6M` / `3M` / `YTD` timeframe filters that rescope it, and the reward list split into pending and settled sections, grouped by month and paged as the user scrolls. There are no path parameters — the user comes from the `token`.

![Rewards history page showing \$3.66 earned over the last 12 months, timeframe filter chips, and a list of individual merchant rewards grouped under Sep 2026](/_fern-img/4429d4bbf415b3bf5d8950752cb7901c56247b4b2f4349c7aaab2bd86fe85a9f.webp)

This view drops the back button that the in-app version shows, since the host owns dismissal on a deep link.

### Map

```
{baseUrl}/map?token={jwt}&theme={base64url}
```

The nearby offers map: a pin per offer location around the user, a list of the same offers sorted by distance, and a "Redo search in this area" control once the user pans away from where the results were fetched. Phone widths show one at a time with a header button to switch; at desktop widths the list and the map sit side by side.

![Map view centered on Dallas with merchant logo pins across downtown and a recenter button in the top right](/_fern-img/8b91356a248320a851b14a3e3f076eebf2ef4a6af7eac7c6069f6555df50d2d1.webp)

This is the only view that needs the location bridge. It asks the container for coordinates on load and stays in its loading state until it gets an answer, so the container must reply to every `REQUEST_LOCATION` (see [Location Message Passing Contract](#location-message-passing-contract)).

> **Note**
>
> Two behaviors specific to this view: its back arrow navigates to the root rewards experience with the query parameters preserved, so a user who opened `/map` directly can still reach the full experience from it. Set `layout.showMapBackButton` to `false` to hide it. And `layout.showMap` only controls the map section on the rewards page — it does not affect this route.

## Fetching a WebView JWT Token

The Kard SDK provides methods to generate WebView tokens. The SDK is available in multiple languages:

* [**TypeScript SDK**](/2024-10-01/sdks/typescript-sdk/getting-started)
* [**Python SDK**](/2024-10-01/sdks/python-sdk/getting-started)

**SDK Method:**

```javascript
users.auth.getWebViewToken(organizationId, userId)
```

This returns a signed JWT that should be passed as the `token` query parameter.

WebView tokens can also be generated via REST API. Refer to the API documentation for [Get WebView Token](/2024-10-01/api/auth/get-web-view-token).

## Location Message Passing Contract

The WebView uses a message-passing protocol to request location data from the container application. This is required for the rewards map feature to display nearby offers.

> **Warning**
>
> The WebView intentionally does **not** time out while waiting for location — this gives the user unlimited time to respond to the OS permission prompt. Because of that, the container **must always reply to every `REQUEST_LOCATION` with exactly one `LOCATION_RESPONSE` or `ERROR`** — including when permission is denied, location services are off, or the underlying request fails. If the container never replies, the rewards map stays in its "Finding offers near you…" loading state indefinitely.

### How the WebView selects a location source

When the map needs location, the WebView chooses a source in this order:

1. **`window.KardWebview.postMessage`** (or **`window.ReactNativeWebView.postMessage`**) — if present, the WebView sends `REQUEST_LOCATION` through it and waits for the container to post a response back. This is the path native containers use.
2. **iframe parent** — if the WebView is running inside an iframe (`window.self !== window.top`), it posts `REQUEST_LOCATION` to `window.parent`.
3. **`navigator.geolocation`** — otherwise it falls back to the browser's geolocation API.

> **Note**
>
> Native **iOS (WKWebView)** and **Android (WebView)** containers must expose a `window.KardWebview.postMessage` function — a small bridge injected at document start (shown in the examples below). A bare WKWebView / Android WebView does not provide it, and `navigator.geolocation` is unreliable inside native web views, so **without this bridge the WebView never asks the container for location and the map will not load.** (`window.ReactNativeWebView` is also accepted for backwards compatibility — `react-native-webview` injects it automatically, which is why the React Native example below doesn't add a bridge.)

### Message Types

| Type                | Direction           | Description                       |
| ------------------- | ------------------- | --------------------------------- |
| `REQUEST_LOCATION`  | WebView → Container | WebView requests current location |
| `LOCATION_RESPONSE` | Container → WebView | Container sends location data     |
| `ERROR`             | Container → WebView | Container reports an error        |

### Request Format (from WebView)

When the WebView needs location data, it sends:

```json
{
  "type": "REQUEST_LOCATION"
}
```

### Success Response Format (to WebView)

When location is successfully retrieved:

```json
{
  "type": "LOCATION_RESPONSE",
  "payload": {
    "ok": true,
    "coords": {
      "latitude": 40.7128,
      "longitude": -74.0060,
      "accuracy": 10,
      "altitude": null,
      "heading": null,
      "speed": null
    },
    "timestamp": 1706380800000
  }
}
```

**Coordinate fields:**

| Field       | Type           | Description            |
| ----------- | -------------- | ---------------------- |
| `latitude`  | number         | Latitude in degrees    |
| `longitude` | number         | Longitude in degrees   |
| `accuracy`  | number \| null | Accuracy in meters     |
| `altitude`  | number \| null | Altitude in meters     |
| `heading`   | number \| null | Heading in degrees     |
| `speed`     | number \| null | Speed in meters/second |

### Error Response Format (to WebView)

When location cannot be retrieved:

```json
{
  "type": "ERROR",
  "payload": {
    "ok": false,
    "error": "Location permission not granted"
  }
}
```

## Platform Integration Examples

#### React Native

```typescript
import { useCallback, useRef } from 'react';
import * as Location from 'expo-location';
import WebView, { ShouldStartLoadRequest } from 'react-native-webview';

const WEBVIEW_URL = 'https://webview-prod-us-east-1.getkard.com/';
const ALLOWED_ORIGIN = new URL(WEBVIEW_URL).origin;

interface RewardsWebViewProps {
  token: string;
  themeOverrides?: {
    theme?: 'system' | 'light' | 'dark';
    styles?: {
      light?: Record<string, string>;
      dark?: Record<string, string>;
      layout?: Record<string, string>;
    };
    labels?: {
      rewardsTitle?: string;
      nearbyOffersTitle?: string;
      offersTitle?: string;
    };
    fontFamily?: {
      source: 'google' | 'custom';
      family: string;
      url?: string;
      weights?: Array<string>;
    };
  };
}

// RFC4648 base64url encoding (URL-safe, no padding)
function encodeThemeOverrides(overrides: object): string {
  return btoa(JSON.stringify(overrides))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

export function RewardsWebView({ token, themeOverrides }: RewardsWebViewProps) {
  const webviewRef = useRef<WebView>(null);

  const handleMessage = useCallback(async (event: any) => {
    let msg: any;
    try {
      msg = JSON.parse(event?.nativeEvent?.data);
    } catch {
      return; // not a JSON message we handle
    }

    if (msg?.type !== 'REQUEST_LOCATION') return;

    try {
      // Request location permission
      const { status } = await Location.requestForegroundPermissionsAsync();

      if (status !== 'granted') {
        webviewRef.current?.postMessage(JSON.stringify({
          type: 'ERROR',
          payload: { ok: false, error: 'Location permission not granted' }
        }));
        return;
      }

      // Get current position
      const position = await Location.getCurrentPositionAsync({});

      webviewRef.current?.postMessage(JSON.stringify({
        type: 'LOCATION_RESPONSE',
        payload: {
          ok: true,
          coords: {
            latitude: position.coords.latitude,
            longitude: position.coords.longitude,
            accuracy: position.coords.accuracy,
            altitude: position.coords.altitude,
            heading: position.coords.heading,
            speed: position.coords.speed,
          },
          timestamp: position.timestamp,
        }
      }));
    } catch (error) {
      // Always reply so the WebView's map doesn't wait forever.
      webviewRef.current?.postMessage(JSON.stringify({
        type: 'ERROR',
        payload: { ok: false, error: error instanceof Error ? error.message : 'Failed to get location' }
      }));
    }
  }, []);

  // base64url is URL-safe, no encodeURIComponent needed for theme param
  const themeParam = themeOverrides
    ? `&theme=${encodeThemeOverrides(themeOverrides)}`
    : '';

  const handleShouldStartLoadWithRequest = useCallback(
    (request: ShouldStartLoadRequest) => {
      try {
        const url = new URL(request.url);
        return url.origin === ALLOWED_ORIGIN;
      } catch {
        return false;
      }
    },
    [],
  );

  return (
    <WebView
      ref={webviewRef}
      source={{ uri: `${WEBVIEW_URL}?token=${encodeURIComponent(token)}${themeParam}` }}
      onMessage={handleMessage}
      onShouldStartLoadWithRequest={handleShouldStartLoadWithRequest}
      originWhitelist={[ALLOWED_ORIGIN]}
    />
  );
}
```

#### iOS (Swift)

```swift
import WebKit
import CoreLocation

class RewardsWebViewController: UIViewController {
    private var webView: WKWebView!
    private let locationManager = CLLocationManager()
    private var pendingLocationRequest = false

    private let webviewURL = "https://webview-prod-us-east-1.getkard.com/"
    private let webviewOrigin = "https://webview-prod-us-east-1.getkard.com"

    override func viewDidLoad() {
        super.viewDidLoad()

        locationManager.delegate = self

        // Configure WebView with message handler
        let config = WKWebViewConfiguration()
        let contentController = WKUserContentController()
        contentController.add(self, name: "nativeHandler")
        config.userContentController = contentController

        // The WebView routes location (and other native) messages through
        // window.KardWebview.postMessage. A bare WKWebView doesn't provide
        // it, so inject a shim at document start that forwards to our
        // message handler.
        let script = """
            window.KardWebview = {
                postMessage: function (message) {
                    window.webkit.messageHandlers.nativeHandler.postMessage(message);
                }
            };
        """
        let userScript = WKUserScript(
            source: script,
            injectionTime: .atDocumentStart,
            forMainFrameOnly: true
        )
        config.userContentController.addUserScript(userScript)

        webView = WKWebView(frame: view.bounds, configuration: config)

        // Match the background and make the WebView non-opaque so unpainted
        // regions composite over your color instead of showing through as a
        // transparent strip (an opaque WKWebView ignores backgroundColor). Use
        // your theme's background color here — a dark value for the dark theme.
        view.backgroundColor = .white
        webView.isOpaque = false
        webView.backgroundColor = .white
        webView.scrollView.backgroundColor = .white

        // Don't inset content for the safe area. Otherwise a full-height page
        // (the map) becomes viewport + top inset + bottom inset tall and scrolls
        // at the top and bottom; the page manages its own internal spacing.
        webView.scrollView.contentInsetAdjustmentBehavior = .never

        // Pin the WebView below the top safe area so the page's header and
        // controls sit just under the Dynamic Island (the white view background
        // fills the status-bar strip); full-bleed on the sides and bottom.
        webView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(webView)
        NSLayoutConstraint.activate([
            webView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            webView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            webView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        ])
    }

    func load(token: String, themeOverrides: [String: Any]? = nil) {
        var urlString = "\(webviewURL)?token=\(token.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? token)"

        if let theme = themeOverrides,
           let jsonData = try? JSONSerialization.data(withJSONObject: theme),
           let jsonString = String(data: jsonData, encoding: .utf8) {
            // RFC4648 base64url encoding (URL-safe, no escaping needed)
            let base64Theme = Data(jsonString.utf8).base64EncodedString()
                .replacingOccurrences(of: "+", with: "-")
                .replacingOccurrences(of: "/", with: "_")
                .trimmingCharacters(in: CharacterSet(charactersIn: "="))
            urlString += "&theme=\(base64Theme)"
        }

        if let url = URL(string: urlString) {
            webView.load(URLRequest(url: url))
        }
    }

    private func sendLocationToWebView(_ location: CLLocation) {
        let payload: [String: Any] = [
            "type": "LOCATION_RESPONSE",
            "payload": [
                "ok": true,
                "coords": [
                    "latitude": location.coordinate.latitude,
                    "longitude": location.coordinate.longitude,
                    "accuracy": location.horizontalAccuracy,
                    "altitude": location.altitude,
                    "heading": location.course >= 0 ? (location.course as Any) : (NSNull() as Any),
                    "speed": location.speed >= 0 ? (location.speed as Any) : (NSNull() as Any)
                ],
                "timestamp": Int(location.timestamp.timeIntervalSince1970 * 1000)
            ]
        ]
        sendMessageToWebView(payload)
    }

    private func sendErrorToWebView(_ error: String) {
        let payload: [String: Any] = [
            "type": "ERROR",
            "payload": [
                "ok": false,
                "error": error
            ]
        ]
        sendMessageToWebView(payload)
    }

    private func sendMessageToWebView(_ message: [String: Any]) {
        guard let jsonData = try? JSONSerialization.data(withJSONObject: message),
              let jsonString = String(data: jsonData, encoding: .utf8) else {
            return
        }
        let script = "window.postMessage(\(jsonString), '\(webviewOrigin)');"
        webView.evaluateJavaScript(script, completionHandler: nil)
    }
}

// MARK: - WKScriptMessageHandler
extension RewardsWebViewController: WKScriptMessageHandler {
    func userContentController(
        _ userContentController: WKUserContentController,
        didReceive message: WKScriptMessage
    ) {
        // window.KardWebview.postMessage delivers a JSON string;
        // tolerate a dictionary too in case the page sends one.
        var type: String?
        if let dict = message.body as? [String: Any] {
            type = dict["type"] as? String
        } else if let str = message.body as? String,
                  let data = str.data(using: .utf8),
                  let dict = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] {
            type = dict["type"] as? String
        }

        guard type == "REQUEST_LOCATION" else { return }

        // Authorization is asynchronous. If it isn't decided yet, ask and let
        // locationManagerDidChangeAuthorization follow up once the user responds.
        pendingLocationRequest = true
        if locationManager.authorizationStatus == .notDetermined {
            locationManager.requestWhenInUseAuthorization()
        } else {
            resolvePendingLocationRequest()
        }
    }
}

// MARK: - CLLocationManagerDelegate
extension RewardsWebViewController: CLLocationManagerDelegate {
    // Request a one-shot location only once authorization is resolved, and
    // always reply to the WebView when the user denies access.
    private func resolvePendingLocationRequest() {
        guard pendingLocationRequest else { return }
        switch locationManager.authorizationStatus {
        case .authorizedWhenInUse, .authorizedAlways:
            locationManager.requestLocation()
        case .denied, .restricted:
            pendingLocationRequest = false
            sendErrorToWebView("Location permission not granted")
        case .notDetermined:
            break // still waiting for the user's response to the prompt
        @unknown default:
            pendingLocationRequest = false
            sendErrorToWebView("Location unavailable")
        }
    }

    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        resolvePendingLocationRequest()
    }

    func locationManager(
        _ manager: CLLocationManager,
        didUpdateLocations locations: [CLLocation]
    ) {
        guard pendingLocationRequest, let location = locations.last else { return }
        pendingLocationRequest = false
        sendLocationToWebView(location)
    }

    func locationManager(
        _ manager: CLLocationManager,
        didFailWithError error: Error
    ) {
        guard pendingLocationRequest else { return }
        pendingLocationRequest = false
        sendErrorToWebView(error.localizedDescription)
    }
}
```

#### Android (Kotlin)

**Gradle dependencies** (`app/build.gradle.kts`) — the example needs these beyond a default project:

```kotlin
dependencies {
    implementation("androidx.webkit:webkit:1.16.0")                        // WebViewCompat / addDocumentStartJavaScript
    implementation("com.google.android.gms:play-services-location:21.3.0") // FusedLocationProviderClient
    implementation("androidx.core:core-ktx:1.19.0")                        // ContextCompat, ViewCompat, WindowInsetsCompat (often already present)
}
```

**Manifest permissions** (`AndroidManifest.xml`) — `INTERNET` is required for the page to load at all; the location permissions are needed for the rewards map:

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

```kotlin
import android.Manifest
import android.annotation.SuppressLint
import android.content.pm.PackageManager
import android.location.Location
import android.os.Bundle
import android.util.Base64
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.google.android.gms.location.CurrentLocationRequest
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import org.json.JSONObject

// ComponentActivity needs no AppCompat theme (avoiding a common runtime
// crash on Material/framework themes); this also compiles unchanged inside
// an AppCompatActivity if your app already uses one.
class RewardsWebViewActivity : ComponentActivity() {
    private lateinit var webView: WebView
    private lateinit var fusedLocationClient: FusedLocationProviderClient

    private val webviewURL = "https://webview-prod-us-east-1.getkard.com/"
    private val webviewOrigin = "https://webview-prod-us-east-1.getkard.com"

    private val locationPermissionRequest = registerForActivityResult(
        ActivityResultContracts.RequestMultiplePermissions()
    ) { permissions ->
        when {
            permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
            permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true -> {
                requestLocation()
            }
            else -> {
                sendErrorToWebView("Location permission not granted")
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)

        webView = WebView(this).apply {
            settings.javaScriptEnabled = true
            settings.domStorageEnabled = true
            webViewClient = WebViewClient()
            addJavascriptInterface(WebViewBridge(), "AndroidBridge")
        }
        setContentView(webView)

        // On targetSdk 35+ the app is edge-to-edge by default, so the page
        // would otherwise draw under the status bar. Pad the WebView by the
        // system bar insets so the page's header sits just below them.
        ViewCompat.setOnApplyWindowInsetsListener(webView) { view, insets ->
            val bars = insets.getInsets(
                WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
            )
            view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
            insets
        }

        // The WebView routes native messages through
        // window.KardWebview.postMessage. Inject a shim at document start
        // (before the page's own scripts) that forwards to our
        // JavascriptInterface. Requires the androidx.webkit:webkit library and a
        // System WebView new enough to support DOCUMENT_START_SCRIPT (the
        // isFeatureSupported guard below). On older WebViews the feature is
        // unavailable, so window.KardWebview is never defined and there is NO
        // pre-load fallback — the rewards map cannot obtain location and will fail
        // there. Keep the device's System WebView current (e.g. prompt to update
        // Android System WebView / Chrome) to support those users.
        if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
            WebViewCompat.addDocumentStartJavaScript(
                webView,
                """
                window.KardWebview = {
                    postMessage: function (message) { AndroidBridge.postMessage(message); }
                };
                """.trimIndent(),
                setOf(webviewOrigin)
            )
        }

        // Provide the WebView JWT from your auth layer and load the page —
        // here the token is passed in as an Intent extra.
        intent.getStringExtra("token")?.let { load(it) }
    }

    fun load(token: String, themeOverrides: JSONObject? = null) {
        var url = "$webviewURL?token=${java.net.URLEncoder.encode(token, "UTF-8")}"

        themeOverrides?.let { theme ->
            // RFC4648 base64url encoding (URL-safe, no escaping needed)
            val base64Theme = Base64.encodeToString(
                theme.toString().toByteArray(),
                Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
            )
            url += "&theme=$base64Theme"
        }

        webView.loadUrl(url)
    }

    @SuppressLint("MissingPermission")
    private fun requestLocation() {
        if (!hasLocationPermission()) {
            locationPermissionRequest.launch(arrayOf(
                Manifest.permission.ACCESS_FINE_LOCATION,
                Manifest.permission.ACCESS_COARSE_LOCATION
            ))
            return
        }

        // getCurrentLocation computes a fresh fix; lastLocation is often null.
        val request = CurrentLocationRequest.Builder()
            .setPriority(Priority.PRIORITY_HIGH_ACCURACY)
            .build()
        fusedLocationClient.getCurrentLocation(request, null)
            .addOnSuccessListener { location ->
                if (location != null) {
                    sendLocationToWebView(location)
                } else {
                    sendErrorToWebView("Unable to get location")
                }
            }
            .addOnFailureListener { e ->
                sendErrorToWebView(e.message ?: "Location request failed")
            }
    }

    private fun hasLocationPermission(): Boolean {
        return ContextCompat.checkSelfPermission(
            this, Manifest.permission.ACCESS_FINE_LOCATION
        ) == PackageManager.PERMISSION_GRANTED ||
        ContextCompat.checkSelfPermission(
            this, Manifest.permission.ACCESS_COARSE_LOCATION
        ) == PackageManager.PERMISSION_GRANTED
    }

    private fun sendLocationToWebView(location: Location) {
        val payload = JSONObject().apply {
            put("type", "LOCATION_RESPONSE")
            put("payload", JSONObject().apply {
                put("ok", true)
                put("coords", JSONObject().apply {
                    put("latitude", location.latitude)
                    put("longitude", location.longitude)
                    put("accuracy", location.accuracy)
                    put("altitude", if (location.hasAltitude()) location.altitude else JSONObject.NULL)
                    put("heading", if (location.hasBearing()) location.bearing else JSONObject.NULL)
                    put("speed", if (location.hasSpeed()) location.speed else JSONObject.NULL)
                })
                put("timestamp", location.time)
            })
        }

        runOnUiThread {
            webView.evaluateJavascript(
                "window.postMessage(${payload}, '$webviewOrigin');",
                null
            )
        }
    }

    private fun sendErrorToWebView(error: String) {
        val payload = JSONObject().apply {
            put("type", "ERROR")
            put("payload", JSONObject().apply {
                put("ok", false)
                put("error", error)
            })
        }

        runOnUiThread {
            webView.evaluateJavascript(
                "window.postMessage(${payload}, '$webviewOrigin');",
                null
            )
        }
    }

    inner class WebViewBridge {
        // Receives the JSON string passed to window.KardWebview.postMessage.
        @JavascriptInterface
        fun postMessage(message: String) {
            val type = try {
                JSONObject(message).optString("type")
            } catch (e: Exception) {
                return
            }
            if (type == "REQUEST_LOCATION") {
                // JavascriptInterface callbacks arrive on a binder thread.
                runOnUiThread { requestLocation() }
            }
        }
    }
}
```

> **Note**
>
> **Production considerations for this example:**
>
> **Google Play services** — `FusedLocationProviderClient` works on Google Play / `google_apis` emulator images and most phones, but fails silently on devices without GMS. Fall back to `android.location.LocationManager` for those.
>
> **Restrict navigation** — this example uses a bare `WebViewClient()`. In production, keep navigation on the Kard origin (parity with the iOS / React Native origin allowlists):
>
> ```kotlin
> webViewClient = object : WebViewClient() {
>     override fun shouldOverrideUrlLoading(
>         view: WebView, request: WebResourceRequest
>     ): Boolean {
>         // Block navigation away from the Kard origin.
>         return request.url.host != "webview-prod-us-east-1.getkard.com"
>     }
> }
> ```
>
> **Message parsing** — the `@JavascriptInterface` handler accepts only a `String`. That matches the contract (the page sends a JSON string), but unlike the iOS handler it will not tolerate a non-string argument.

#### Android (Jetpack Compose)

Same WebView, same bridge and location contract as the Android (Kotlin) tab — only the hosting changes. In a Compose app there is no `setContentView`, so the `WebView` is embedded with `AndroidView`, and permissions are requested with `rememberLauncherForActivityResult` instead of an Activity-level `registerForActivityResult`.

**Gradle dependencies** (`app/build.gradle.kts`) — the same dependencies as the Android (Kotlin) example, plus the Compose interop pieces (most Compose apps already have these):

```kotlin
dependencies {
    implementation("androidx.webkit:webkit:1.16.0")                        // WebViewCompat / addDocumentStartJavaScript
    implementation("com.google.android.gms:play-services-location:21.3.0") // FusedLocationProviderClient
    implementation("androidx.core:core-ktx:1.19.0")                        // ContextCompat, ViewCompat, WindowInsetsCompat (often already present)
    implementation("androidx.activity:activity-compose:1.10.1")            // rememberLauncherForActivityResult (usually already present)
    implementation("androidx.compose.ui:ui:1.9.0")                         // AndroidView (usually already present)
}
```

**Manifest permissions** (`AndroidManifest.xml`) — identical to the Android (Kotlin) example. `INTERNET` is required for the page to load at all; the location permissions are needed for the rewards map:

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

```kotlin
import android.Manifest
import android.annotation.SuppressLint
import android.content.pm.PackageManager
import android.location.Location
import android.net.Uri
import android.util.Base64
import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.google.android.gms.location.CurrentLocationRequest
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import org.json.JSONObject

private const val WEBVIEW_URL = "https://webview-prod-us-east-1.getkard.com/"
private const val WEBVIEW_ORIGIN = "https://webview-prod-us-east-1.getkard.com"
private const val WEBVIEW_HOST = "webview-prod-us-east-1.getkard.com"

// Receives the JSON string passed to window.KardWebview.postMessage and
// forwards REQUEST_LOCATION. JavascriptInterface callbacks arrive on a binder
// thread, so onRequestLocation re-posts to the main thread (see the factory).
private class WebViewBridge(private val onRequestLocation: () -> Unit) {
    @JavascriptInterface
    fun postMessage(message: String) {
        val type = try {
            JSONObject(message).optString("type")
        } catch (e: Exception) {
            return
        }
        if (type == "REQUEST_LOCATION") onRequestLocation()
    }
}

private fun buildUrl(token: String, themeOverrides: JSONObject?): String {
    var url = "$WEBVIEW_URL?token=${Uri.encode(token)}"
    themeOverrides?.let { theme ->
        // RFC4648 base64url encoding (URL-safe, no escaping needed)
        val base64Theme = Base64.encodeToString(
            theme.toString().toByteArray(),
            Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
        )
        url += "&theme=$base64Theme"
    }
    return url
}

// getCurrentLocation needs the permission check; suppressed because the
// callers (fetchLocation / the permission launcher) only run once a
// location permission has been granted.
@SuppressLint("MissingPermission")
@Composable
fun RewardsWebView(
    token: String,
    themeOverrides: JSONObject? = null,
    modifier: Modifier = Modifier,
) {
    val context = LocalContext.current
    val fusedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) }

    // Hold the WebView so the location/error callbacks can post messages
    // back into the page after it has been created by the AndroidView factory.
    val webViewRef = remember { mutableStateOf<WebView?>(null) }

    fun sendMessageToWebView(payload: JSONObject) {
        val webView = webViewRef.value ?: return
        // evaluateJavascript must run on the main thread.
        webView.post {
            webView.evaluateJavascript(
                "window.postMessage($payload, '$WEBVIEW_ORIGIN');",
                null
            )
        }
    }

    fun sendLocationToWebView(location: Location) {
        val payload = JSONObject().apply {
            put("type", "LOCATION_RESPONSE")
            put("payload", JSONObject().apply {
                put("ok", true)
                put("coords", JSONObject().apply {
                    put("latitude", location.latitude)
                    put("longitude", location.longitude)
                    put("accuracy", location.accuracy)
                    put("altitude", if (location.hasAltitude()) location.altitude else JSONObject.NULL)
                    put("heading", if (location.hasBearing()) location.bearing else JSONObject.NULL)
                    put("speed", if (location.hasSpeed()) location.speed else JSONObject.NULL)
                })
                put("timestamp", location.time)
            })
        }
        sendMessageToWebView(payload)
    }

    fun sendErrorToWebView(error: String) {
        val payload = JSONObject().apply {
            put("type", "ERROR")
            put("payload", JSONObject().apply {
                put("ok", false)
                put("error", error)
            })
        }
        sendMessageToWebView(payload)
    }

    fun fetchLocation() {
        // getCurrentLocation computes a fresh fix; lastLocation is often null.
        val request = CurrentLocationRequest.Builder()
            .setPriority(Priority.PRIORITY_HIGH_ACCURACY)
            .build()
        fusedLocationClient.getCurrentLocation(request, null)
            .addOnSuccessListener { location ->
                if (location != null) {
                    sendLocationToWebView(location)
                } else {
                    sendErrorToWebView("Unable to get location")
                }
            }
            .addOnFailureListener { e ->
                sendErrorToWebView(e.message ?: "Location request failed")
            }
    }

    // Always reply to the WebView so its map doesn't wait forever, including
    // when the user denies the permission prompt.
    val locationPermissionRequest = rememberLauncherForActivityResult(
        ActivityResultContracts.RequestMultiplePermissions()
    ) { permissions ->
        if (permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
            permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true
        ) {
            fetchLocation()
        } else {
            sendErrorToWebView("Location permission not granted")
        }
    }

    fun hasLocationPermission(): Boolean {
        return ContextCompat.checkSelfPermission(
            context, Manifest.permission.ACCESS_FINE_LOCATION
        ) == PackageManager.PERMISSION_GRANTED ||
        ContextCompat.checkSelfPermission(
            context, Manifest.permission.ACCESS_COARSE_LOCATION
        ) == PackageManager.PERMISSION_GRANTED
    }

    fun requestLocation() {
        if (hasLocationPermission()) {
            fetchLocation()
        } else {
            locationPermissionRequest.launch(
                arrayOf(
                    Manifest.permission.ACCESS_FINE_LOCATION,
                    Manifest.permission.ACCESS_COARSE_LOCATION
                )
            )
        }
    }

    AndroidView(
        modifier = modifier,
        factory = { ctx ->
            WebView(ctx).apply {
                settings.javaScriptEnabled = true
                settings.domStorageEnabled = true

                // Keep navigation on the Kard origin (parity with the iOS /
                // React Native origin allowlists).
                webViewClient = object : WebViewClient() {
                    override fun shouldOverrideUrlLoading(
                        view: WebView, request: WebResourceRequest
                    ): Boolean {
                        return request.url.host != WEBVIEW_HOST
                    }
                }

                addJavascriptInterface(
                    // JavascriptInterface callbacks arrive on a binder thread,
                    // so hop back to the WebView's (main) thread before touching
                    // permissions/UI.
                    WebViewBridge(onRequestLocation = { post { requestLocation() } }),
                    "AndroidBridge"
                )

                // On targetSdk 35+ the app is edge-to-edge by default, so the
                // page would otherwise draw under the status bar. Pad the WebView
                // by the system bar insets so the page's header sits just below
                // them.
                ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets ->
                    val bars = insets.getInsets(
                        WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
                    )
                    view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
                    insets
                }

                // The WebView routes native messages through
                // window.KardWebview.postMessage. Inject a shim at document start
                // (before the page's own scripts) that forwards to our
                // JavascriptInterface. Requires the androidx.webkit:webkit library
                // and a System WebView new enough to support DOCUMENT_START_SCRIPT
                // (the isFeatureSupported guard below). On older WebViews the
                // feature is unavailable, so window.KardWebview is never defined
                // and there is NO pre-load fallback — the rewards map cannot
                // obtain location and will fail there. Keep the device's System
                // WebView current (e.g. prompt to update Android System WebView /
                // Chrome) to support those users.
                if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
                    WebViewCompat.addDocumentStartJavaScript(
                        this,
                        """
                        window.KardWebview = {
                            postMessage: function (message) { AndroidBridge.postMessage(message); }
                        };
                        """.trimIndent(),
                        setOf(WEBVIEW_ORIGIN)
                    )
                }

                webViewRef.value = this
                loadUrl(buildUrl(token, themeOverrides))
            }
        },
        // Reload when the token or theme changes (e.g. on token refresh).
        update = { webView ->
            val url = buildUrl(token, themeOverrides)
            if (webView.url != url) webView.loadUrl(url)
        }
    )
}
```

> **Note**
>
> **Production considerations for this example** are the same as the Android (Kotlin) tab:
>
> **Google Play services** — `FusedLocationProviderClient` works on Google Play / `google_apis` emulator images and most phones, but fails silently on devices without GMS. Fall back to `android.location.LocationManager` for those.
>
> **Message parsing** — the `@JavascriptInterface` handler accepts only a `String`. That matches the contract (the page sends a JSON string) but, unlike the iOS handler, it will not tolerate a non-string argument.
>
> **Reuse vs. duplication** — `WebViewBridge`, `buildUrl`, and the location/message helpers are identical to the View-based example; if you ship both, lift them into a shared file rather than copying.

#### Web (iframe)

```typescript
import { useEffect, useRef } from 'react';

const WEBVIEW_URL = 'https://webview-prod-us-east-1.getkard.com/';
const EXPECTED_ORIGIN = new URL(WEBVIEW_URL).origin;

interface ThemeOverrides {
  theme?: 'system' | 'light' | 'dark';
  styles?: {
    light?: Record<string, string>;
    dark?: Record<string, string>;
    layout?: Record<string, string>;
  };
  labels?: {
    rewardsTitle?: string;
    nearbyOffersTitle?: string;
    offersTitle?: string;
  };
  fontFamily?: {
    source: 'google' | 'custom';
    family: string;
    url?: string;
    weights?: Array<string>;
  };
}

interface RewardsIFrameProps {
  token: string;
  themeOverrides?: ThemeOverrides;
}

// RFC4648 base64url encoding (URL-safe, no padding)
function encodeThemeOverrides(overrides: ThemeOverrides): string {
  return btoa(JSON.stringify(overrides))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

export function RewardsIFrame({ token, themeOverrides }: RewardsIFrameProps) {
  const iframeRef = useRef<HTMLIFrameElement>(null);

  useEffect(() => {
    function handleMessage(event: MessageEvent) {
      // Verify message origin and source match our iframe
      if (
        event.origin !== EXPECTED_ORIGIN ||
        !iframeRef.current ||
        event.source !== iframeRef.current.contentWindow
      ) {
        return;
      }

      const msg = event.data;
      if (msg?.type === 'REQUEST_LOCATION') {
        navigator.geolocation.getCurrentPosition(
          (position) => {
            iframeRef.current?.contentWindow?.postMessage({
              type: 'LOCATION_RESPONSE',
              payload: {
                ok: true,
                coords: {
                  latitude: position.coords.latitude,
                  longitude: position.coords.longitude,
                  accuracy: position.coords.accuracy,
                  altitude: position.coords.altitude,
                  heading: position.coords.heading,
                  speed: position.coords.speed,
                },
                timestamp: position.timestamp,
              }
            }, EXPECTED_ORIGIN);
          },
          (error) => {
            iframeRef.current?.contentWindow?.postMessage({
              type: 'ERROR',
              payload: {
                ok: false,
                error: error.message
              }
            }, EXPECTED_ORIGIN);
          },
          { enableHighAccuracy: true }
        );
      }
    }

    window.addEventListener('message', handleMessage);
    return () => window.removeEventListener('message', handleMessage);
  }, []);

  // base64url is URL-safe, no encodeURIComponent needed for theme param
  const themeParam = themeOverrides
    ? `&theme=${encodeThemeOverrides(themeOverrides)}`
    : '';

  return (
    <iframe
      ref={iframeRef}
      src={`${WEBVIEW_URL}?token=${encodeURIComponent(token)}${themeParam}`}
      style={{ width: '100%', height: '100%', border: 'none' }}
      title="Kard Rewards"
    />
  );
}
```

## Theming Resources

Below is an example showing how to apply custom branding:

```typescript
// Define your brand colors
const themeOverrides = {
  theme: 'light' as const,
  styles: {
    light: {
      primary: '#0066cc',
      buttonPrimaryTextColor: '#ffffff',
      secondary: '#e0e0e0',
      buttonSecondaryTextColor: '#1a1a1a',
      background: '#f5f5f5',
      textPrimary: '#1a1a1a',
      textSecondary: '#6b7280',
      cardBackgroundColor: '#ffffff',
      border: '#e0e0e0',
      linkColor: '#0066cc',
    },
    dark: {
      primary: '#4da6ff',
      buttonPrimaryTextColor: '#000000',
      secondary: '#404040',
      buttonSecondaryTextColor: '#f5f5f5',
      background: '#1a1a1a',
      textPrimary: '#f5f5f5',
      textSecondary: '#a0a0a0',
      cardBackgroundColor: '#2a2a2a',
      border: '#404040',
      linkColor: '#4da6ff',
    },
    layout: {
      radius: '8px',
      chipRadius: '9999px',
      imageRadius: '9999px',
      cardRadius: '12px',
      h1FontWeight: '700',
      bodyFontWeight: '400',
      buttonFontWeight: '600',
    }
  },
  labels: {
    rewardsTitle: 'My Rewards',
    nearbyOffersTitle: 'Deals near you',
    offersTitle: 'All deals',
  },
  fontFamily: {
    source: 'google' as const,
    family: 'Source Sans 3',
    weights: ['400', '600', '700'],
  }
};

// RFC4648 base64url encoding (URL-safe, no padding)
function encodeThemeOverrides(overrides: object): string {
  return btoa(JSON.stringify(overrides))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

// base64url is URL-safe, no encodeURIComponent needed
const themeParam = encodeThemeOverrides(themeOverrides);
const url = `https://webview-prod-us-east-1.getkard.com/?token=${token}&theme=${themeParam}`;
```