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

# React Native

> Manual frame integration for React Native using react-native-webview.

Use `react-native-webview` to embed frames in React Native applications. The WebView communicates with your app through the `ReactNativeWebView` JavaScript interface.

<Callout icon="book" iconType="regular">
  Read the [manual integration
  overview](/platform/guides/manual-integration/overview) for core concepts
  before you continue.
</Callout>

## Setup

<Info>
  The `react-native-webview` library automatically injects the
  `ReactNativeWebView` JavaScript interface into the WebView. The frame detects
  this interface and uses it for communication between the frame and your app.
  You don't need to inject a custom JavaScript bridge.
</Info>

### Dependencies

Install `react-native-webview` to render frames in your app.

<Tabs>
  <Tab title="React Native CLI">
    <CodeGroup>
      ```bash pnpm theme={null}
      pnpm i react-native-webview
      ```

      ```bash bun theme={null}
      bun add react-native-webview
      ```

      ```bash npm theme={null}
      npm i react-native-webview
      ```
    </CodeGroup>

    If targeting iOS, you may also need to run:

    ```bash theme={null}
    cd ios && pod install
    ```
  </Tab>

  <Tab title="Expo">
    ```bash theme={null}
    npx expo install react-native-webview
    ```

    See the [Expo WebView docs](https://docs.expo.dev/versions/latest/sdk/webview/) for additional platform configuration.
  </Tab>
</Tabs>

The connect and check frames require X25519 key exchange to encrypt client credentials. The examples below use [noble-curves](https://github.com/paulmillr/noble-curves), but you can use any library that supports X25519 and AES-GCM. You also need a [polyfill for `getRandomValues`](https://github.com/LinusU/react-native-get-random-values) ([MDN](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues)) which is only available in browsers.

<CodeGroup>
  ```bash pnpm theme={null}
  pnpm i react-native-get-random-values @noble/curves @noble/hashes @noble/ciphers
  ```

  ```bash bun theme={null}
  bun add react-native-get-random-values @noble/curves @noble/hashes @noble/ciphers
  ```

  ```bash npm theme={null}
  npm i react-native-get-random-values @noble/curves @noble/hashes @noble/ciphers
  ```
</CodeGroup>

### Base WebView component

Create a reusable base component for frame communication:

```tsx twoslash MoonPayWebView.tsx theme={null}
import React, {
  useRef,
  useCallback,
  useImperativeHandle,
  forwardRef,
} from "react";
import { View, ViewStyle, StyleSheet } from "react-native";
import { WebView, WebViewMessageEvent } from "react-native-webview";

type MoonPayWebViewProps = {
  url: string;
  channelId: string;
  onMessage: (data: FrameMessage) => void;
  onHandshake: () => void;
  style?: ViewStyle;
};

export type MoonPayWebViewRef = {
  sendMessage: (kind: string, payload?: object) => void;
};

export type FrameMessage = {
  version: number;
  meta: { channelId: string };
  kind: string;
  payload?: unknown;
};

export const MoonPayWebView = forwardRef<
  MoonPayWebViewRef,
  MoonPayWebViewProps
>(({ url, channelId, onMessage, onHandshake, style }, ref) => {
  const webViewRef = useRef<WebView>(null);

  const sendMessage = useCallback(
    (kind: string, payload?: object) => {
      const message = {
        version: 2,
        meta: { channelId },
        kind,
        ...(payload && { payload }),
      };

      webViewRef.current?.postMessage(JSON.stringify(message));
    },
    [channelId],
  );

  useImperativeHandle(ref, () => ({ sendMessage }), [sendMessage]);

  const handleMessage = useCallback(
    (event: WebViewMessageEvent) => {
      try {
        const data: FrameMessage = JSON.parse(event.nativeEvent.data);
        if (data.meta?.channelId !== channelId) return;

        if (data.kind === "handshake") {
          sendMessage("ack");
          onHandshake();
        }

        onMessage(data);
      } catch {
        // Ignore malformed messages
      }
    },
    [channelId, sendMessage, onMessage, onHandshake],
  );

  return (
    <View style={[styles.container, style]}>
      <WebView
        ref={webViewRef}
        source={{ uri: url }}
        onMessage={handleMessage}
        javaScriptEnabled
        domStorageEnabled
        allowsInlineMediaPlayback
        originWhitelist={["*"]}
        style={styles.webview}
      />
    </View>
  );
});

const styles = StyleSheet.create({
  container: { flex: 1 },
  webview: { flex: 1 },
});
```

### Encryption utility

When using the connect or check frame, you generate an X25519 keypair and provide the public key to the frame. The frame encrypts the returned client credentials with this key.

#### Key generation

Generate an X25519 keypair for secure communication:

```ts crypto.ts theme={null}
import { x25519 } from "@noble/curves/ed25519";
import { bytesToHex } from "@noble/hashes/utils";
import "react-native-get-random-values"; // Polyfill for crypto.getRandomValues

export function generateKeyPair() {
  const { secretKey, publicKey } = x25519.keygen();

  return {
    privateKeyHex: bytesToHex(secretKey),
    publicKeyHex: bytesToHex(publicKey),
  };
}

export function generateChannelId() {
  return `ch_${Date.now()}_${Math.random().toString(36).slice(2)}`;
}
```

#### Decryption utility

Decrypt the encrypted credentials returned from connect/check frames:

```ts decrypt.ts theme={null}
import { x25519 } from "@noble/curves/ed25519";
import { hkdf } from "@noble/hashes/hkdf";
import { sha256 } from "@noble/hashes/sha256";
import { gcm } from "@noble/ciphers/aes";
import { hexToBytes } from "@noble/hashes/utils";

interface ClientCredentials {
  accessToken: string;
  clientToken: string;
}

export function decryptClientCredentials(
  encryptedValue: string,
  privateKeyHex: string,
): ClientCredentials {
  const encrypted = JSON.parse(encryptedValue) as {
    ephemeralPublicKey: string;
    iv: string;
    ciphertext: string;
  };

  const sharedSecret = x25519.getSharedSecret(
    hexToBytes(privateKeyHex),
    hexToBytes(encrypted.ephemeralPublicKey),
  );

  const derivedKey = hkdf(sha256, sharedSecret, undefined, undefined, 32);

  const aes = gcm(derivedKey, hexToBytes(encrypted.iv));
  const decrypted = aes.decrypt(hexToBytes(encrypted.ciphertext));
  const text = new TextDecoder().decode(decrypted);

  return JSON.parse(text) as ClientCredentials;
}
```

***

## Check frame

The check frame verifies whether a customer already has an active connection. It's headless — no UI is rendered. See [check frame reference](/platform/frames/check) for event details.

### Check component

```tsx theme={null}
import React, { useState, useCallback } from "react";
import { MoonPayWebView, FrameMessage } from "./MoonPayWebView";
import {
  generateKeyPair,
  generateChannelId,
  decryptClientCredentials,
} from "./crypto";

const FRAME_ORIGIN = "https://blocks.moonpay.com";

interface CheckFrameProps {
  sessionToken: string;
  onActive: (credentials: { accessToken: string; clientToken: string }) => void;
  onConnectionRequired: (credentials: {
    accessToken: string;
    clientToken: string;
  }) => void;
  onError: (error: { code: string; message: string }) => void;
  onPending?: () => void;
  onUnavailable?: () => void;
}

export function MoonPayCheckFrame({
  sessionToken,
  onActive,
  onConnectionRequired,
  onError,
  onPending,
  onUnavailable,
}: CheckFrameProps) {
  const [channelId] = useState(generateChannelId);
  const [keyPair] = useState(generateKeyPair);

  const frameUrl = `${FRAME_ORIGIN}/platform/v1/check-connection?${new URLSearchParams(
    {
      sessionToken,
      publicKey: keyPair.publicKeyHex,
      channelId,
    },
  ).toString()}`;

  const handleMessage = useCallback(
    (data: FrameMessage) => {
      switch (data.kind) {
        case "complete":
          const payload = data.payload as {
            status: string;
            credentials?: string;
            expiresAt?: string;
            customer?: {
              id: string;
              country: string;
              administrativeArea?: string;
              area?: string;
            };
            /** Present only when the session's email and phone number resolve to different MoonPay customers. */
            mismatch?: boolean;
            reason?: string;
          };

          switch (payload.status) {
            case "active":
              const credentials = decryptClientCredentials(
                payload.credentials!,
                keyPair.privateKeyHex,
              );
              // Read payload.customer.country, payload.customer.administrativeArea,
              // and payload.customer.area to determine geo-based disclosure requirements.
              // payload.capabilities.ramps.requirements.paymentDisclosures is deprecated.
              onActive(credentials);
              break;
            case "connectionRequired":
              const anonymousCredentials = decryptClientCredentials(
                payload.credentials!,
                keyPair.privateKeyHex,
              );
              // payload.mismatch is true when the session's email and phone resolve to different customers — route through the connect flow first.
              onConnectionRequired(anonymousCredentials);
              break;
            case "pending":
              onPending?.();
              break;
            case "unavailable":
              onUnavailable?.();
              break;
            case "failed":
              onError({
                code: "failed",
                message: payload.reason || "Check failed",
              });
              break;
          }
          break;

        case "error":
          onError(data.payload as { code: string; message: string });
          break;
      }
    },
    [
      keyPair,
      onActive,
      onConnectionRequired,
      onError,
      onPending,
      onUnavailable,
    ],
  );

  return (
    <MoonPayWebView
      url={frameUrl}
      channelId={channelId}
      onMessage={handleMessage}
      onHandshake={() => {}}
    />
  );
}
```

### Usage

```tsx theme={null}
import { MoonPayCheckFrame } from "./MoonPayCheckFrame";

function SplashScreen({ navigation }) {
  return (
    <MoonPayCheckFrame
      sessionToken="your-session-token"
      onActive={({ accessToken, clientToken }) => {
        // Customer is already connected — skip to payment
        console.log("Already connected!", { accessToken, clientToken });
        navigation.navigate("Payment");
      }}
      onConnectionRequired={({ accessToken, clientToken }) => {
        // Store both tokens in memory, then pass clientToken to the connect frame
        CredentialsStore.set({ accessToken, clientToken });
        navigation.navigate("Connect", { clientToken });
      }}
      onError={(error) => {
        console.error("Check failed:", error);
      }}
    />
  );
}
```

***

## Connect frame

The connect frame establishes a customer connection to your application. See [connect frame reference](/platform/frames/connect) for event details.

### Connect component

```tsx theme={null}
import React, { useState, useCallback } from "react";
import { MoonPayWebView, FrameMessage } from "./MoonPayWebView";
import {
  generateKeyPair,
  generateChannelId,
  decryptClientCredentials,
} from "./crypto";

const FRAME_ORIGIN = "https://blocks.moonpay.com";

interface ConnectFrameProps {
  clientToken: string;
  onComplete: (credentials: {
    accessToken: string;
    clientToken: string;
  }) => void;
  onError: (error: { code: string; message: string }) => void;
  onPending?: () => void;
  onUnavailable?: () => void;
}

export function MoonPayConnectFrame({
  clientToken,
  onComplete,
  onError,
  onPending,
  onUnavailable,
}: ConnectFrameProps) {
  const [channelId] = useState(generateChannelId);
  const [keyPair] = useState(generateKeyPair);

  const frameUrl = `${FRAME_ORIGIN}/platform/v1/connect?${new URLSearchParams({
    clientToken,
    publicKey: keyPair.publicKeyHex,
    channelId,
  }).toString()}`;

  const handleMessage = useCallback(
    (data: FrameMessage) => {
      switch (data.kind) {
        case "complete":
          const payload = data.payload as {
            status: string;
            credentials?: string;
            expiresAt?: string;
            customer?: {
              id: string;
              country: string;
              administrativeArea?: string;
              area?: string;
            };
            reason?: string;
          };

          switch (payload.status) {
            case "active":
              const credentials = decryptClientCredentials(
                payload.credentials!,
                keyPair.privateKeyHex,
              );
              // Read payload.customer.country, payload.customer.administrativeArea,
              // and payload.customer.area to determine geo-based disclosure requirements.
              // payload.capabilities.ramps.requirements.paymentDisclosures is deprecated.
              onComplete(credentials);
              break;
            case "pending":
              onPending?.();
              break;
            case "unavailable":
              onUnavailable?.();
              break;
            case "failed":
              onError({
                code: "failed",
                message: payload.reason || "Connection failed",
              });
              break;
          }
          break;

        case "error":
          onError(data.payload as { code: string; message: string });
          break;
      }
    },
    [keyPair, onComplete, onError, onPending, onUnavailable],
  );

  return (
    <MoonPayWebView
      url={frameUrl}
      channelId={channelId}
      onMessage={handleMessage}
      onHandshake={() => {}}
    />
  );
}
```

### Usage

```tsx theme={null}
import { MoonPayConnectFrame } from "./MoonPayConnectFrame";

function ConnectScreen({ route, navigation }) {
  // The clientToken is the anonymous token passed from the check frame's
  // `connectionRequired` response.
  const { clientToken } = route.params;

  const handleComplete = ({ accessToken, clientToken }) => {
    // Replace the anonymous credentials with the authenticated ones and store
    // them in memory (e.g., React Context or state management).
    console.log("Connected!", { accessToken, clientToken });
    navigation.navigate("Home");
  };

  const handleError = (error) => {
    console.error("Connection failed:", error);
    // Show error UI
  };

  return (
    <MoonPayConnectFrame
      clientToken={clientToken}
      onComplete={handleComplete}
      onError={handleError}
      onPending={() => console.log("Connection pending")}
      onUnavailable={() => console.log("Connection unavailable")}
    />
  );
}
```

***

## Apple Pay frame

The Apple Pay frame renders the Apple Pay button and handles the payment flow. See [Apple Pay frame reference](/platform/frames/apple-pay) for event details.

### Apple Pay component

```tsx theme={null}
import React, {
  useState,
  useCallback,
  useRef,
  useImperativeHandle,
  forwardRef,
} from "react";
import {
  MoonPayWebView,
  MoonPayWebViewRef,
  FrameMessage,
} from "./MoonPayWebView";
import { generateChannelId } from "./crypto";

const FRAME_ORIGIN = "https://blocks.moonpay.com";

interface ApplePayFrameProps {
  clientToken: string;
  quoteSignature: string;
  onComplete: (transaction: { id: string; status: string }) => void;
  onChallenge: (url: string) => void;
  onError: (error: { code: string; message: string }) => void;
  onQuoteExpired: () => void;
  onReady?: () => void;
}

export type ApplePayFrameRef = {
  updateQuote: (signature: string) => void;
};

export const MoonPayApplePayFrame = forwardRef<
  ApplePayFrameRef,
  ApplePayFrameProps
>(
  (
    {
      clientToken,
      quoteSignature,
      onComplete,
      onChallenge,
      onError,
      onQuoteExpired,
      onReady,
    },
    ref,
  ) => {
    const frameRef = useRef<MoonPayWebViewRef>(null);
    const [channelId] = useState(generateChannelId);

    const frameUrl = `${FRAME_ORIGIN}/platform/v1/apple-pay?${new URLSearchParams(
      {
        clientToken,
        channelId,
        signature: quoteSignature,
      },
    ).toString()}`;

    useImperativeHandle(
      ref,
      () => ({
        updateQuote: (signature: string) => {
          frameRef.current?.sendMessage("setQuote", {
            quote: { signature },
          });
        },
      }),
      [],
    );

    const handleMessage = useCallback(
      (data: FrameMessage) => {
        switch (data.kind) {
          case "ready":
            onReady?.();
            break;

          case "complete":
            const payload = data.payload as {
              transaction:
                | { id: string; status: string }
                | { status: "failed"; failureReason: string };
            };

            if (payload.transaction.status === "failed") {
              onError({
                code: "transactionFailed",
                message: (payload.transaction as { failureReason: string })
                  .failureReason,
              });
            } else {
              onComplete(payload.transaction as { id: string; status: string });
            }
            break;

          case "challenge":
            const challengePayload = data.payload as {
              kind: string;
              url: string;
            };
            onChallenge(challengePayload.url);
            break;

          case "error":
            const error = data.payload as { code: string; message: string };
            if (error.code === "quoteExpired") {
              onQuoteExpired();
            } else {
              onError(error);
            }
            break;
        }
      },
      [onComplete, onChallenge, onError, onQuoteExpired, onReady],
    );

    return (
      <MoonPayWebView
        ref={frameRef}
        url={frameUrl}
        channelId={channelId}
        onMessage={handleMessage}
        onHandshake={() => {}}
        style={{ height: 56 }}
      />
    );
  },
);
```

### Usage

```tsx theme={null}
import { useRef } from "react";
import { MoonPayApplePayFrame, ApplePayFrameRef } from "./MoonPayApplePayFrame";

function PaymentScreen({ clientToken, quoteSignature }) {
  const applePayRef = useRef<ApplePayFrameRef>(null);

  const handleQuoteExpired = async () => {
    // Fetch a new quote and send it to the frame
    const newQuote = await fetchNewQuote();
    applePayRef.current?.updateQuote(newQuote.signature);
  };

  return (
    <MoonPayApplePayFrame
      ref={applePayRef}
      clientToken={clientToken}
      quoteSignature={quoteSignature}
      onComplete={(tx) => console.log("Transaction initiated:", tx.id)}
      onChallenge={(url) => console.log("Challenge required:", url)}
      onError={(error) => console.error("Payment error:", error)}
      onQuoteExpired={handleQuoteExpired}
      onReady={() => console.log("Apple Pay button ready")}
    />
  );
}
```

***

## Google Pay frame

The Google Pay frame renders the Google Pay button and handles the payment flow. See [Google Pay frame reference](/platform/frames/google-pay) for event details.

### Google Pay component

```tsx theme={null}
import React, {
  useState,
  useCallback,
  useRef,
  useImperativeHandle,
  forwardRef,
} from "react";
import {
  MoonPayWebView,
  MoonPayWebViewRef,
  FrameMessage,
} from "./MoonPayWebView";
import { generateChannelId } from "./crypto";

const FRAME_ORIGIN = "https://blocks.moonpay.com";

interface GooglePayFrameProps {
  clientToken: string;
  quoteSignature: string;
  onComplete: (transaction: { id: string; status: string }) => void;
  onChallenge: (url: string) => void;
  onError: (error: { code: string; message: string }) => void;
  onQuoteExpired: () => void;
  onReady?: () => void;
}

export type GooglePayFrameRef = {
  updateQuote: (signature: string) => void;
};

export const MoonPayGooglePayFrame = forwardRef<
  GooglePayFrameRef,
  GooglePayFrameProps
>(
  (
    {
      clientToken,
      quoteSignature,
      onComplete,
      onChallenge,
      onError,
      onQuoteExpired,
      onReady,
    },
    ref,
  ) => {
    const frameRef = useRef<MoonPayWebViewRef>(null);
    const [channelId] = useState(generateChannelId);

    const frameUrl = `${FRAME_ORIGIN}/platform/v1/google-pay?${new URLSearchParams(
      {
        clientToken,
        channelId,
        signature: quoteSignature,
      },
    ).toString()}`;

    useImperativeHandle(
      ref,
      () => ({
        updateQuote: (signature: string) => {
          frameRef.current?.sendMessage("setQuote", {
            quote: { signature },
          });
        },
      }),
      [],
    );

    const handleMessage = useCallback(
      (data: FrameMessage) => {
        switch (data.kind) {
          case "ready":
            onReady?.();
            break;

          case "complete":
            const payload = data.payload as {
              transaction:
                | { id: string; status: string }
                | { status: "failed"; failureReason: string };
            };

            if (payload.transaction.status === "failed") {
              onError({
                code: "transactionFailed",
                message: (payload.transaction as { failureReason: string })
                  .failureReason,
              });
            } else {
              onComplete(payload.transaction as { id: string; status: string });
            }
            break;

          case "challenge":
            const challengePayload = data.payload as {
              kind: string;
              url: string;
            };
            onChallenge(challengePayload.url);
            break;

          case "error":
            const error = data.payload as { code: string; message: string };
            if (error.code === "quoteExpired") {
              onQuoteExpired();
            } else {
              onError(error);
            }
            break;
        }
      },
      [onComplete, onChallenge, onError, onQuoteExpired, onReady],
    );

    return (
      <MoonPayWebView
        ref={frameRef}
        url={frameUrl}
        channelId={channelId}
        onMessage={handleMessage}
        onHandshake={() => {}}
        style={{ height: 56 }}
      />
    );
  },
);
```

### Usage

```tsx theme={null}
import { useRef } from "react";
import {
  MoonPayGooglePayFrame,
  GooglePayFrameRef,
} from "./MoonPayGooglePayFrame";

function PaymentScreen({ clientToken, quoteSignature }) {
  const googlePayRef = useRef<GooglePayFrameRef>(null);

  const handleQuoteExpired = async () => {
    // Fetch a new quote and send it to the frame
    const newQuote = await fetchNewQuote();
    googlePayRef.current?.updateQuote(newQuote.signature);
  };

  return (
    <MoonPayGooglePayFrame
      ref={googlePayRef}
      clientToken={clientToken}
      quoteSignature={quoteSignature}
      onComplete={(tx) => console.log("Transaction initiated:", tx.id)}
      onChallenge={(url) => console.log("Challenge required:", url)}
      onError={(error) => console.error("Payment error:", error)}
      onQuoteExpired={handleQuoteExpired}
      onReady={() => console.log("Google Pay button ready")}
    />
  );
}
```

***

## Add Card frame

The add card frame lets a customer save a new card to their account. See [add card frame reference](/platform/frames/add-card) for event details.

### What you'll need

Before you initialize the add card frame, you need:

1. A `clientToken` from a successful [connect flow](#connect-frame)

### Add Card component

```tsx theme={null}
import React, { useState, useCallback } from "react";
import { MoonPayWebView, FrameMessage } from "./MoonPayWebView";
import { generateChannelId } from "./crypto";

const FRAME_ORIGIN = "https://blocks.moonpay.com";

interface CardResult {
  id: string;
  brand: string;
  last4: string;
  cardType: string;
  expirationMonth: number;
  expirationYear: number;
  availability: { active: boolean };
}

interface AddCardFrameProps {
  clientToken: string;
  onComplete: (card: CardResult) => void;
  onError: (error: { code: string; message: string }) => void;
  onReady?: () => void;
}

export function MoonPayAddCardFrame({
  clientToken,
  onComplete,
  onError,
  onReady,
}: AddCardFrameProps) {
  const [channelId] = useState(generateChannelId);

  const frameUrl = `${FRAME_ORIGIN}/platform/v1/add-card?${new URLSearchParams({
    clientToken,
    channelId,
  }).toString()}`;

  const handleMessage = useCallback(
    (data: FrameMessage) => {
      switch (data.kind) {
        case "ready":
          onReady?.();
          break;

        case "complete":
          const payload = data.payload as { card: CardResult };
          onComplete(payload.card);
          break;

        case "error":
          onError(data.payload as { code: string; message: string });
          break;
      }
    },
    [onComplete, onError, onReady],
  );

  return (
    <MoonPayWebView
      url={frameUrl}
      channelId={channelId}
      onMessage={handleMessage}
      onHandshake={() => {}}
      style={{ flex: 1 }}
    />
  );
}
```

### Usage

```tsx theme={null}
import { MoonPayAddCardFrame } from "./MoonPayAddCardFrame";

function SaveCardScreen({ clientToken, navigation }) {
  return (
    <MoonPayAddCardFrame
      clientToken={clientToken}
      onComplete={(card) => {
        console.log("Card saved:", card.id, card.brand, card.last4);
        navigation.navigate("Payment");
      }}
      onError={(error) => {
        console.error("Add card error:", error);
      }}
      onReady={() => console.log("Add card frame ready")}
    />
  );
}
```

***

## Buy frame

The buy frame processes a card payment for a quote. It is headless — rendered at zero size — while the customer completes payment. If 3-D Secure is required, the frame emits a `challenge` event with a URL you open in a separate challenge frame. See [buy frame reference](/platform/frames/buy) for event details.

### What you'll need

Before you initialize the buy frame, you need:

1. A `clientToken` from a successful [connect flow](#connect-frame)
2. A valid [quote signature](/api-reference/platform/endpoints/quotes/get) for the transaction

### Buy component

```tsx theme={null}
import React, {
  useState,
  useCallback,
  useRef,
  useImperativeHandle,
  forwardRef,
} from "react";
import { StyleSheet } from "react-native";
import {
  MoonPayWebView,
  MoonPayWebViewRef,
  FrameMessage,
} from "./MoonPayWebView";
import { generateChannelId } from "./crypto";

const FRAME_ORIGIN = "https://blocks.moonpay.com";

interface BuyFrameProps {
  clientToken: string;
  quoteSignature: string;
  onComplete: (transaction: { id: string; status: string }) => void;
  onChallenge: (url: string) => void;
  onError: (error: { code: string; message: string }) => void;
  onQuoteExpired?: () => void;
}

export type BuyFrameRef = {
  updateQuote: (signature: string) => void;
};

export const MoonPayBuyFrame = forwardRef<BuyFrameRef, BuyFrameProps>(
  (
    {
      clientToken,
      quoteSignature,
      onComplete,
      onChallenge,
      onError,
      onQuoteExpired,
    },
    ref,
  ) => {
    const frameRef = useRef<MoonPayWebViewRef>(null);
    const [channelId] = useState(generateChannelId);

    const frameUrl = `${FRAME_ORIGIN}/platform/v1/buy?${new URLSearchParams({
      clientToken,
      channelId,
      signature: quoteSignature,
    }).toString()}`;

    useImperativeHandle(
      ref,
      () => ({
        updateQuote: (signature: string) => {
          frameRef.current?.sendMessage("setQuote", {
            quote: { signature },
          });
        },
      }),
      [],
    );

    const handleMessage = useCallback(
      (data: FrameMessage) => {
        switch (data.kind) {
          case "complete":
            onComplete(
              (data.payload as { transaction: { id: string; status: string } })
                .transaction,
            );
            break;

          case "challenge":
            const challenge = data.payload as { kind: string; url: string };
            onChallenge(challenge.url);
            break;

          case "error":
            const error = data.payload as { code: string; message: string };
            if (error.code === "quoteExpired") {
              onQuoteExpired?.();
            } else {
              onError(error);
            }
            break;
        }
      },
      [onComplete, onChallenge, onError, onQuoteExpired],
    );

    return (
      <MoonPayWebView
        ref={frameRef}
        url={frameUrl}
        channelId={channelId}
        onMessage={handleMessage}
        onHandshake={() => {}}
        style={StyleSheet.absoluteFillObject}
      />
    );
  },
);
```

### Challenge component

```tsx theme={null}
import React, { useState, useCallback } from "react";
import { MoonPayWebView, FrameMessage } from "./MoonPayWebView";
import { generateChannelId } from "./crypto";

const FRAME_ORIGIN = "https://blocks.moonpay.com";

interface ChallengeFrameProps {
  challengeUrl: string;
  onComplete: (transaction: { id: string; status: string }) => void;
  onCancelled: () => void;
  onError: (error: { code: string; message: string }) => void;
}

export function MoonPayChallengeFrame({
  challengeUrl,
  onComplete,
  onCancelled,
  onError,
}: ChallengeFrameProps) {
  const [channelId] = useState(generateChannelId);

  const frameUrl = useMemo(() => {
    const url = new URL(challengeUrl);
    url.searchParams.set("channelId", channelId);
    return url.toString();
  }, [challengeUrl, channelId]);

  const handleMessage = useCallback(
    (data: FrameMessage) => {
      switch (data.kind) {
        case "complete":
          const payload = data.payload as {
            flow: "buy";
            transaction: { id: string; status: string };
          };
          onComplete(payload.transaction);
          break;

        case "cancelled":
          onCancelled();
          break;

        case "error":
          onError(data.payload as { code: string; message: string });
          break;
      }
    },
    [onComplete, onCancelled, onError],
  );

  return (
    <MoonPayWebView
      url={frameUrl}
      channelId={channelId}
      onMessage={handleMessage}
      onHandshake={() => {}}
    />
  );
}
```

### Usage

```tsx theme={null}
import { useRef, useState } from "react";
import { View, StyleSheet } from "react-native";
import { MoonPayBuyFrame, BuyFrameRef } from "./MoonPayBuyFrame";
import { MoonPayChallengeFrame } from "./MoonPayChallengeFrame";

function BuyPaymentScreen({ clientToken, quoteSignature, navigation }) {
  const buyRef = useRef<BuyFrameRef>(null);
  const [challengeUrl, setChallengeUrl] = useState<string | null>(null);

  const handleComplete = (transaction: { id: string; status: string }) => {
    setChallengeUrl(null);
    console.log("Transaction initiated:", transaction.id);
    navigation.navigate("TransactionStatus", { transactionId: transaction.id });
  };

  const handleChallengeResult = (transaction: {
    id: string;
    status: string;
  }) => {
    setChallengeUrl(null);
    console.log("Transaction initiated:", transaction.id);
    navigation.navigate("TransactionStatus", { transactionId: transaction.id });
  };

  const handleQuoteExpired = async () => {
    const newQuote = await fetchNewQuote();
    buyRef.current?.updateQuote(newQuote.signature);
  };

  return (
    <View style={StyleSheet.absoluteFill}>
      <MoonPayBuyFrame
        ref={buyRef}
        clientToken={clientToken}
        quoteSignature={quoteSignature}
        onComplete={handleComplete}
        onChallenge={(url) => setChallengeUrl(url)}
        onError={(error) => console.error("Buy error:", error)}
        onQuoteExpired={handleQuoteExpired}
      />
      {challengeUrl && (
        <View style={StyleSheet.absoluteFill}>
          <MoonPayChallengeFrame
            challengeUrl={challengeUrl}
            onComplete={handleChallengeResult}
            onCancelled={() => setChallengeUrl(null)}
            onError={(error) => {
              console.error("Challenge error:", error);
              setChallengeUrl(null);
            }}
          />
        </View>
      )}
    </View>
  );
}
```

***

## Widget frame

For payment methods beyond Apple Pay — including credit/debit cards, Google Pay, bank transfers, and more — use the [widget frame](/platform/frames/widget). It renders the full MoonPay buy experience inside a WebView, including payment-method selection and transaction confirmation. See [pay with widget](/platform/guides/pay-with-widget) for a full walkthrough.

### Widget component

```tsx theme={null}
import React, { useState, useCallback } from "react";
import { MoonPayWebView, FrameMessage } from "./MoonPayWebView";
import { generateChannelId } from "./crypto";

const FRAME_ORIGIN = "https://blocks.moonpay.com";

interface WidgetFrameProps {
  clientToken: string;
  quoteSignature: string;
  onTransactionCreated?: (transaction: { id: string; status: string }) => void;
  onComplete: (transaction: { id: string; status: string }) => void;
  onError: (error: { code: string; message: string }) => void;
  onReady?: () => void;
}

export function MoonPayWidgetFrame({
  clientToken,
  quoteSignature,
  onTransactionCreated,
  onComplete,
  onError,
  onReady,
}: WidgetFrameProps) {
  const [channelId] = useState(generateChannelId);

  const frameUrl = `${FRAME_ORIGIN}/platform/v1/widget?${new URLSearchParams({
    flow: "buy",
    clientToken,
    quoteSignature,
    channelId,
  }).toString()}`;

  const handleMessage = useCallback(
    (data: FrameMessage) => {
      switch (data.kind) {
        case "ready":
          onReady?.();
          break;

        case "transactionCreated":
          const created = data.payload as {
            transaction: { id: string; status: string };
          };
          onTransactionCreated?.(created.transaction);
          break;

        case "complete":
          const payload = data.payload as {
            transaction:
              | { id: string; status: string }
              | { status: "failed"; failureReason: string };
          };

          if (payload.transaction.status === "failed") {
            onError({
              code: "transactionFailed",
              message: (payload.transaction as { failureReason: string })
                .failureReason,
            });
          } else {
            onComplete(payload.transaction as { id: string; status: string });
          }
          break;

        case "error":
          onError(data.payload as { code: string; message: string });
          break;
      }
    },
    [onComplete, onError, onTransactionCreated, onReady],
  );

  return (
    <MoonPayWebView
      url={frameUrl}
      channelId={channelId}
      onMessage={handleMessage}
      onHandshake={() => {}}
    />
  );
}
```

### Usage

```tsx theme={null}
import { MoonPayWidgetFrame } from "./MoonPayWidgetFrame";

function WidgetPaymentScreen({ clientToken, quoteSignature }) {
  return (
    <MoonPayWidgetFrame
      clientToken={clientToken}
      quoteSignature={quoteSignature}
      onReady={() => console.log("Widget loaded")}
      onTransactionCreated={(tx) =>
        console.log("Transaction created:", tx.id, tx.status)
      }
      onComplete={(tx) => console.log("Transaction complete:", tx.id)}
      onError={(error) => console.error("Widget error:", error)}
    />
  );
}
```
