> ## 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.

# Web

> Integrate MoonPay iframes directly in your application.

Use iframes and `postMessage` to embed frames directly in web applications without installing any MoonPay packages.

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

## Setup

### Encryption

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.

<CodeGroup>
  ```sh pnpm theme={null}
  pnpm i @noble/curves @noble/hashes @noble/ciphers
  ```

  ```sh bun theme={null}
  bun add @noble/curves @noble/hashes @noble/ciphers
  ```

  ```sh npm theme={null}
  npm i @noble/curves @noble/hashes @noble/ciphers
  ```
</CodeGroup>

### Message utilities

Create helper functions for sending and receiving frame messages. All messages follow the [frames protocol](/platform/frames/overview#frames-protocol).

```ts messageUtils.ts theme={null}
const FRAME_ORIGIN = "https://blocks.moonpay.com";

interface FrameMessage {
  version: number;
  meta: { channelId: string };
  kind: string;
  payload?: unknown;
}

function parseFrameMessage(
  event: MessageEvent,
  channelId: string,
): FrameMessage | null {
  if (event.origin !== FRAME_ORIGIN) return null;

  try {
    const data: FrameMessage =
      typeof event.data === "string" ? JSON.parse(event.data) : event.data;
    if (data.meta?.channelId !== channelId) return null;
    return data;
  } catch {
    return null;
  }
}

function sendFrameMessage(
  iframe: HTMLIFrameElement,
  channelId: string,
  kind: string,
  payload?: object,
) {
  const message: FrameMessage = {
    version: 2,
    meta: { channelId },
    kind,
    ...(payload && { payload }),
  };

  iframe.contentWindow?.postMessage(JSON.stringify(message), FRAME_ORIGIN);
}
```

***

## Encryption utility

The connect and check frames return encrypted credentials. Generate an X25519 keypair and provide the public key to the frame.

### Key generation

```ts crypto.ts theme={null}
import { x25519 } from "@noble/curves/ed25519";
import { bytesToHex } from "@noble/hashes/utils";

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

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

### Decryption

```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;
}

function decryptClientCredentials(
  encryptedBase64: string,
  privateKeyHex: string,
): ClientCredentials {
  const json = JSON.parse(atob(encryptedBase64)) as {
    ephemeralPublicKey: string;
    iv: string;
    ciphertext: string;
  };

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

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

  const aes = gcm(derivedKey, hexToBytes(json.iv));
  const decrypted = aes.decrypt(hexToBytes(json.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. Use it to skip the connect flow for returning customers. See [check frame reference](/platform/frames/check) for event details.

### Initialize the frame

```ts theme={null}
const FRAME_ORIGIN = "https://blocks.moonpay.com";

function initializeCheckFrame(sessionToken: string) {
  const iframe = document.getElementById("moonpay-frame") as HTMLIFrameElement;
  const channelId = crypto.randomUUID();
  const keyPair = generateKeyPair();

  const params = new URLSearchParams({
    sessionToken,
    publicKey: keyPair.publicKeyHex,
    channelId,
  });

  iframe.src = `${FRAME_ORIGIN}/platform/v1/check-connection?${params.toString()}`;

  return { iframe, channelId, keyPair };
}
```

### Handle events

```ts theme={null}
interface CheckCompletePayload {
  status:
    | "active"
    | "connectionRequired"
    | "pending"
    | "unavailable"
    | "failed";
  credentials?: string;
  expiresAt?: string;
  customer?: {
    id: string;
    country: string;
    administrativeArea?: string;
    area?: string;
  };
  capabilities?: {
    ramps: {
      requirements: {
        /** @deprecated Use customer.country, customer.administrativeArea, and customer.area instead */
        paymentDisclosures?: {
          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;
}

function setupCheckListener(channelId: string, privateKeyHex: string) {
  const handler = (event: MessageEvent) => {
    const data = parseFrameMessage(event, channelId);
    if (!data) return;

    const iframe = document.getElementById(
      "moonpay-frame",
    ) as HTMLIFrameElement;

    switch (data.kind) {
      case "handshake":
        sendFrameMessage(iframe, channelId, "ack");
        break;

      case "complete":
        handleCheckComplete(
          data.payload as CheckCompletePayload,
          privateKeyHex,
        );
        break;

      case "error":
        const error = data.payload as { code: string; message: string };
        console.error("Check error:", error.code, error.message);
        break;
    }
  };

  window.addEventListener("message", handler);

  return () => window.removeEventListener("message", handler);
}

function handleCheckComplete(
  payload: CheckCompletePayload,
  privateKeyHex: string,
) {
  switch (payload.status) {
    case "active":
      const credentials = decryptClientCredentials(
        payload.credentials!,
        privateKeyHex,
      );
      // Store credentials in memory for subsequent API calls and frames
      console.log("Already connected!");
      // Read payload.customer.country, payload.customer.administrativeArea,
      // and payload.customer.area to determine geo-based disclosure requirements.
      // payload.capabilities.ramps.requirements.paymentDisclosures is deprecated.
      break;

    case "connectionRequired":
      const anonymousCredentials = decryptClientCredentials(
        payload.credentials!,
        privateKeyHex,
      );
      // Store both tokens in memory, then pass clientToken to the connect frame
      console.log("No active connection — show connect frame");
      // payload.mismatch is true when the session's email and phone resolve to different customers — route through the connect flow first.
      break;

    case "pending":
      console.log("Connection pending — customer may need to complete KYC");
      break;

    case "unavailable":
      console.log("Connection unavailable — likely geo-restricted");
      break;

    case "failed":
      console.error("Check failed:", payload.reason);
      break;
  }
}
```

### Usage

```ts theme={null}
const { channelId, keyPair } = initializeCheckFrame("your-session-token");
const cleanup = setupCheckListener(channelId, keyPair.privateKeyHex);

// When done, clean up the listener
// cleanup();
```

***

## Connect frame

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

### Initialize the frame

```ts theme={null}
const FRAME_ORIGIN = "https://blocks.moonpay.com";

function initializeConnectFrame(clientToken: string) {
  const iframe = document.getElementById("moonpay-frame") as HTMLIFrameElement;
  const channelId = crypto.randomUUID();
  const keyPair = generateKeyPair();

  const params = new URLSearchParams({
    clientToken,
    publicKey: keyPair.publicKeyHex,
    channelId,
  });

  iframe.src = `${FRAME_ORIGIN}/platform/v1/connect?${params.toString()}`;

  return { iframe, channelId, keyPair };
}
```

### Handle events

```ts theme={null}
interface ConnectCompletePayload {
  status: "active" | "pending" | "unavailable" | "failed";
  credentials?: string;
  expiresAt?: string;
  customer?: {
    id: string;
    country: string;
    administrativeArea?: string;
    area?: string;
  };
  capabilities?: {
    ramps: {
      requirements: {
        /** @deprecated Use customer.country, customer.administrativeArea, and customer.area instead */
        paymentDisclosures?: {
          country: string;
          administrativeArea?: string;
          area?: string;
        };
      };
    };
  };
  reason?: string;
}

function setupConnectListener(channelId: string, privateKeyHex: string) {
  const handler = (event: MessageEvent) => {
    const data = parseFrameMessage(event, channelId);
    if (!data) return;

    const iframe = document.getElementById(
      "moonpay-frame",
    ) as HTMLIFrameElement;

    switch (data.kind) {
      case "handshake":
        sendFrameMessage(iframe, channelId, "ack");
        break;

      case "complete":
        handleConnectComplete(
          data.payload as ConnectCompletePayload,
          privateKeyHex,
        );
        break;

      case "error":
        const error = data.payload as { code: string; message: string };
        console.error("Connect error:", error.code, error.message);
        break;
    }
  };

  window.addEventListener("message", handler);

  return () => window.removeEventListener("message", handler);
}

function handleConnectComplete(
  payload: ConnectCompletePayload,
  privateKeyHex: string,
) {
  switch (payload.status) {
    case "active":
      const credentials = decryptClientCredentials(
        payload.credentials!,
        privateKeyHex,
      );
      // Store tokens in memory for subsequent API calls and frames
      console.log("Connected!");
      // Read payload.customer.country, payload.customer.administrativeArea,
      // and payload.customer.area to determine geo-based disclosure requirements.
      // payload.capabilities.ramps.requirements.paymentDisclosures is deprecated.
      break;

    case "pending":
      console.log("Connection pending — customer may need to complete KYC");
      break;

    case "unavailable":
      console.log("Connection unavailable — likely geo-restricted");
      break;

    case "failed":
      console.error("Connection failed:", payload.reason);
      break;
  }
}
```

### Usage

```ts theme={null}
// The clientToken is obtained from the check frame's `connectionRequired` response
const { channelId, keyPair } = initializeConnectFrame("your-client-token");
const cleanup = setupConnectListener(channelId, keyPair.privateKeyHex);

// When done, clean up the listener
// cleanup();
```

***

## 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.

<Callout icon="apple" iconType="brands">
  Apple Pay only works on Safari (macOS and iOS). Check availability before
  rendering.
</Callout>

### What you'll need

Before you initialize the Apple Pay 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

### Initialize the frame

```ts theme={null}
function initializeApplePayFrame(clientToken: string, quoteSignature: string) {
  const iframe = document.getElementById("moonpay-frame") as HTMLIFrameElement;
  const channelId = crypto.randomUUID();

  const params = new URLSearchParams({
    clientToken,
    channelId,
    signature: quoteSignature,
  });

  iframe.src = `${FRAME_ORIGIN}/platform/v1/apple-pay?${params.toString()}`;

  return { iframe, channelId };
}
```

### Handle events

```ts theme={null}
interface ApplePayCompletePayload {
  transaction:
    | { id: string; status: "complete" | "pending" }
    | { status: "failed"; failureReason: string };
}

function setupApplePayListener(channelId: string) {
  const iframe = document.getElementById("moonpay-frame") as HTMLIFrameElement;

  const handler = (event: MessageEvent) => {
    const data = parseFrameMessage(event, channelId);
    if (!data) return;

    switch (data.kind) {
      case "handshake":
        sendFrameMessage(iframe, channelId, "ack");
        break;

      case "ready":
        console.log("Apple Pay button ready");
        break;

      case "complete":
        const payload = data.payload as ApplePayCompletePayload;
        if (payload.transaction.status === "failed") {
          console.error(
            "Transaction failed:",
            payload.transaction.failureReason,
          );
        } else {
          console.log("Transaction initiated:", payload.transaction.id);
          // Poll for transaction status or wait for webhook
        }
        break;

      case "challenge":
        const challengePayload = data.payload as { kind: string; url: string };
        // Open the challenge URL in a new iframe — see "Challenge handling" below
        console.log("Challenge required:", challengePayload.url);
        break;

      case "error":
        const error = data.payload as { code: string; message: string };
        if (error.code === "quoteExpired") {
          // Fetch a new quote and send it to the frame
          console.log("Quote expired, fetching new quote...");
          // updateApplePayQuote(iframe, channelId, newQuoteSignature);
        } else {
          console.error("Apple Pay error:", error.code, error.message);
        }
        break;
    }
  };

  window.addEventListener("message", handler);

  return () => window.removeEventListener("message", handler);
}
```

### Update the quote

When the quote expires or changes, send a new quote to the frame:

```ts theme={null}
function updateApplePayQuote(
  iframe: HTMLIFrameElement,
  channelId: string,
  newQuoteSignature: string,
) {
  sendFrameMessage(iframe, channelId, "setQuote", {
    quote: { signature: newQuoteSignature },
  });
}
```

***

## 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.

<Callout icon="circle-info" iconType="regular">
  The Google Pay frame requires the `payment` [permission
  policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Permissions-Policy#iframes).
  When using a sandboxed iframe, include `allow-scripts`, `allow-popups`,
  `allow-same-origin`, and `allow-forms`. See [Google Pay inside sandboxed
  iframe](https://developers.googleblog.com/google-pay-inside-sandboxed-iframe-for-pci-dss-v4-compliance/)
  for details.
</Callout>

### What you'll need

Before you initialize the Google Pay 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

### Initialize the frame

```ts theme={null}
function initializeGooglePayFrame(clientToken: string, quoteSignature: string) {
  const iframe = document.getElementById("moonpay-frame") as HTMLIFrameElement;
  const channelId = crypto.randomUUID();

  // Required for Google Pay — enables the Payment Request API inside the iframe
  iframe.allow = "payment";

  const params = new URLSearchParams({
    clientToken,
    channelId,
    signature: quoteSignature,
  });

  iframe.src = `${FRAME_ORIGIN}/platform/v1/google-pay?${params.toString()}`;

  return { iframe, channelId };
}
```

### Handle events

```ts theme={null}
interface GooglePayCompletePayload {
  transaction:
    | { id: string; status: "complete" | "pending" }
    | { status: "failed"; failureReason: string };
}

function setupGooglePayListener(channelId: string) {
  const iframe = document.getElementById("moonpay-frame") as HTMLIFrameElement;

  const handler = (event: MessageEvent) => {
    const data = parseFrameMessage(event, channelId);
    if (!data) return;

    switch (data.kind) {
      case "handshake":
        sendFrameMessage(iframe, channelId, "ack");
        break;

      case "ready":
        console.log("Google Pay button ready");
        break;

      case "complete":
        const payload = data.payload as GooglePayCompletePayload;
        if (payload.transaction.status === "failed") {
          console.error(
            "Transaction failed:",
            payload.transaction.failureReason,
          );
        } else {
          console.log("Transaction initiated:", payload.transaction.id);
          // Poll for transaction status or wait for webhook
        }
        break;

      case "challenge":
        const challengePayload = data.payload as { kind: string; url: string };
        // Open the challenge URL in a new iframe — see "Challenge handling" below
        console.log("Challenge required:", challengePayload.url);
        break;

      case "error":
        const error = data.payload as { code: string; message: string };
        if (error.code === "quoteExpired") {
          // Fetch a new quote and send it to the frame
          console.log("Quote expired, fetching new quote...");
          // updateGooglePayQuote(iframe, channelId, newQuoteSignature);
        } else {
          console.error("Google Pay error:", error.code, error.message);
        }
        break;
    }
  };

  window.addEventListener("message", handler);

  return () => window.removeEventListener("message", handler);
}
```

### Update the quote

When the quote expires or changes, send a new quote to the frame:

```ts theme={null}
function updateGooglePayQuote(
  iframe: HTMLIFrameElement,
  channelId: string,
  newQuoteSignature: string,
) {
  sendFrameMessage(iframe, channelId, "setQuote", {
    quote: { signature: newQuoteSignature },
  });
}
```

***

## 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)

### Initialize the frame

```ts theme={null}
function initializeAddCardFrame(clientToken: string) {
  const iframe = document.getElementById("moonpay-frame") as HTMLIFrameElement;
  const channelId = crypto.randomUUID();

  const params = new URLSearchParams({
    clientToken,
    channelId,
  });

  iframe.src = `${FRAME_ORIGIN}/platform/v1/add-card?${params.toString()}`;

  return { iframe, channelId };
}
```

### Handle events

```ts theme={null}
interface AddCardCompletePayload {
  card: {
    id: string;
    brand: string;
    last4: string;
    cardType: string;
    expirationMonth: number;
    expirationYear: number;
    availability: { active: boolean };
  };
}

function setupAddCardListener(channelId: string) {
  const iframe = document.getElementById("moonpay-frame") as HTMLIFrameElement;

  const handler = (event: MessageEvent) => {
    const data = parseFrameMessage(event, channelId);
    if (!data) return;

    switch (data.kind) {
      case "handshake":
        sendFrameMessage(iframe, channelId, "ack");
        break;

      case "ready":
        console.log("Add card frame ready");
        break;

      case "complete":
        const payload = data.payload as AddCardCompletePayload;
        console.log("Card saved:", payload.card.id);
        // Poll for card status or proceed to payment
        break;

      case "error":
        const error = data.payload as { code: string; message: string };
        console.error("Add card error:", error.code, error.message);
        break;
    }
  };

  window.addEventListener("message", handler);

  return () => window.removeEventListener("message", handler);
}
```

### Usage

```ts theme={null}
const { channelId } = initializeAddCardFrame("your-client-token");
const cleanup = setupAddCardListener(channelId);

// When done, clean up the listener
// cleanup();
```

***

## 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

### Initialize the frame

```ts theme={null}
function initializeBuyFrame(
  clientToken: string,
  quoteSignature: string,
  externalTransactionId?: string,
) {
  const channelId = crypto.randomUUID();

  const params = new URLSearchParams({
    clientToken,
    channelId,
    signature: quoteSignature,
    ...(externalTransactionId && { externalTransactionId }),
  });

  const iframe = document.createElement("iframe");
  iframe.style.cssText =
    "position: absolute; width: 0; height: 0; border: none; overflow: hidden;";
  iframe.src = `${FRAME_ORIGIN}/platform/v1/buy?${params.toString()}`;
  document.body.appendChild(iframe);

  return { iframe, channelId };
}
```

### Handle events

```ts theme={null}
interface BuyCompletePayload {
  transaction: { id: string; status: string };
}

interface BuyChallengePayload {
  kind: string;
  url: string;
}

function setupBuyListener(
  channelId: string,
  iframe: HTMLIFrameElement,
  onComplete: (transaction: { id: string; status: string }) => void,
  onChallenge: (url: string) => void,
) {
  const handler = (event: MessageEvent) => {
    const data = parseFrameMessage(event, channelId);
    if (!data) return;

    switch (data.kind) {
      case "handshake":
        sendFrameMessage(iframe, channelId, "ack");
        break;

      case "ready":
        console.log("Buy frame ready");
        break;

      case "complete":
        const payload = data.payload as BuyCompletePayload;
        console.log("Transaction complete:", payload.transaction.id);
        onComplete(payload.transaction);
        break;

      case "challenge":
        const challenge = data.payload as BuyChallengePayload;
        onChallenge(challenge.url);
        break;

      case "error":
        const error = data.payload as { code: string; message: string };
        if (error.code === "quoteExpired") {
          // Fetch a new quote and send it to the frame
          console.log("Quote expired, fetching new quote...");
          // updateBuyQuote(iframe, channelId, newQuoteSignature);
        } else {
          console.error("Buy error:", error.code, error.message);
        }
        break;
    }
  };

  window.addEventListener("message", handler);

  return () => window.removeEventListener("message", handler);
}
```

### Update the quote

When the quote expires or changes, send a new quote to the frame:

```ts theme={null}
function updateBuyQuote(
  iframe: HTMLIFrameElement,
  channelId: string,
  newQuoteSignature: string,
) {
  sendFrameMessage(iframe, channelId, "setQuote", {
    quote: { signature: newQuoteSignature },
  });
}
```

### Challenge handling

When the buy frame emits a `challenge` event, open the challenge URL in a new iframe inside a modal. The challenge frame is self-driving after the handshake:

```ts theme={null}
interface ChallengeCompletePayload {
  flow: "buy";
  transaction: { id: string; status: string };
}

function setupChallengeListener(
  challengeChannelId: string,
  challengeIframe: HTMLIFrameElement,
  buyIframe: HTMLIFrameElement,
  buyCleanup: () => void,
  onResult: (transaction: { id: string; status: string } | null) => void,
) {
  const handler = (event: MessageEvent) => {
    const data = parseFrameMessage(event, challengeChannelId);
    if (!data) return;

    switch (data.kind) {
      case "handshake":
        sendFrameMessage(challengeIframe, challengeChannelId, "ack");
        break;

      case "ready":
        console.log("Challenge frame ready");
        break;

      case "complete":
        const payload = data.payload as ChallengeCompletePayload;
        cleanup();
        onResult(payload.transaction);
        break;

      case "cancelled":
        cleanup();
        onResult(null);
        break;

      case "error":
        const error = data.payload as { code: string; message: string };
        console.error("Challenge error:", error.code, error.message);
        cleanup();
        onResult(null);
        break;
    }
  };

  function cleanup() {
    window.removeEventListener("message", handler);
    buyCleanup();
    challengeIframe.remove();
    buyIframe.remove();
  }

  window.addEventListener("message", handler);

  return cleanup;
}

function openChallengeFrame(
  challengeUrl: string,
  buyIframe: HTMLIFrameElement,
  buyCleanup: () => void,
  onResult: (transaction: { id: string; status: string } | null) => void,
) {
  const modal = document.getElementById("challengeModal") as HTMLElement;
  const challengeChannelId = crypto.randomUUID();

  const urlWithChannel = new URL(challengeUrl);
  urlWithChannel.searchParams.set("channelId", challengeChannelId);

  const challengeIframe = document.createElement("iframe");
  challengeIframe.src = urlWithChannel.toString();
  modal.appendChild(challengeIframe);

  return setupChallengeListener(
    challengeChannelId,
    challengeIframe,
    buyIframe,
    buyCleanup,
    onResult,
  );
}
```

### Usage

```ts theme={null}
const { iframe: buyIframe, channelId } = initializeBuyFrame(
  "your-client-token",
  "your-quote-signature",
);

const buyCleanup = setupBuyListener(
  channelId,
  buyIframe,
  (transaction) => {
    console.log("Transaction initiated:", transaction.id);
    // Navigate to transaction status screen or poll for updates
  },
  (challengeUrl) => {
    openChallengeFrame(challengeUrl, buyIframe, buyCleanup, (transaction) => {
      if (transaction) {
        console.log("Challenge complete:", transaction.id);
      } else {
        console.log("Challenge cancelled or failed");
      }
    });
  },
);

// When done, clean up the listener
// buyCleanup();
```

***

## 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 an iframe, including payment-method selection and transaction confirmation. See [pay with widget](/platform/guides/pay-with-widget) for a full walkthrough.

### What you'll need

Before you initialize the widget 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

### Initialize the frame

<Callout icon="circle-info" iconType="regular">
  The widget iframe requires the `payment` [permission
  policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Permissions-Policy#iframes)
  to process payments.
</Callout>

```ts theme={null}
function initializeWidgetFrame(clientToken: string, quoteSignature: string) {
  const iframe = document.getElementById("moonpay-frame") as HTMLIFrameElement;
  const channelId = crypto.randomUUID();

  // The widget requires the "payment" permission policy
  iframe.allow = "payment";

  const params = new URLSearchParams({
    flow: "buy",
    clientToken,
    quoteSignature,
    channelId,
  });

  iframe.src = `${FRAME_ORIGIN}/platform/v1/widget?${params.toString()}`;

  return { iframe, channelId };
}
```

### Handle events

```ts theme={null}
interface WidgetCompletePayload {
  transaction:
    | { id: string; status: "complete" | "pending" }
    | { status: "failed"; failureReason: string };
}

function setupWidgetListener(channelId: string) {
  const iframe = document.getElementById("moonpay-frame") as HTMLIFrameElement;

  const handler = (event: MessageEvent) => {
    const data = parseFrameMessage(event, channelId);
    if (!data) return;

    switch (data.kind) {
      case "handshake":
        sendFrameMessage(iframe, channelId, "ack");
        break;

      case "ready":
        console.log("Widget loaded and visible");
        break;

      case "transactionCreated":
        const created = data.payload as {
          transaction: { id: string; status: string };
        };
        console.log("Transaction created:", created.transaction.id);
        // Customer may still need to complete 3-D Secure
        break;

      case "complete":
        const payload = data.payload as WidgetCompletePayload;
        if (payload.transaction.status === "failed") {
          console.error(
            "Transaction failed:",
            payload.transaction.failureReason,
          );
        } else {
          console.log("Transaction initiated:", payload.transaction.id);
          // Poll for transaction status or wait for webhook
        }
        break;

      case "error":
        const error = data.payload as { code: string; message: string };
        console.error("Widget error:", error.code, error.message);
        break;
    }
  };

  window.addEventListener("message", handler);

  return () => window.removeEventListener("message", handler);
}
```

### Usage

```ts theme={null}
const { channelId } = initializeWidgetFrame(
  "your-client-token",
  "your-quote-signature",
);
const cleanup = setupWidgetListener(channelId);

// When done, clean up the listener
// cleanup();
```
