Skip to main content

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.

Use iframes and postMessage to embed frames directly in web applications without installing any MoonPay packages.
Read the manual integration overview for core concepts before you continue.

Setup

Encryption

The connect and check frames require X25519 key exchange to encrypt client credentials. The examples below use @noble/curves, but you can use any library that supports X25519 and AES-GCM.
pnpm i @noble/curves @noble/hashes @noble/ciphers

Message utilities

Create helper functions for sending and receiving frame messages. All messages follow the frames protocol.
messageUtils.ts
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

crypto.ts
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

decrypt.ts
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 for event details.

Initialize the frame

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

interface CheckCompletePayload {
  status:
    | "active"
    | "connectionRequired"
    | "pending"
    | "unavailable"
    | "failed";
  credentials?: string;
  expiresAt?: string;
  capabilities?: {
    ramps: {
      requirements: {
        paymentDisclosures?: {
          country: string;
          administrativeArea?: string;
          area?: string;
        };
      };
    };
  };
  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!");
      // Check payload.capabilities.ramps.requirements.paymentDisclosures
      // to determine if payment disclosures are required before transacting
      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");
      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

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 for event details.

Initialize the frame

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

interface ConnectCompletePayload {
  status: "active" | "pending" | "unavailable" | "failed";
  credentials?: string;
  expiresAt?: string;
  capabilities?: {
    ramps: {
      requirements: {
        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!");
      // Check payload.capabilities.ramps.requirements.paymentDisclosures
      // to determine if payment disclosures are required before transacting
      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

// 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 for event details.
Apple Pay only works on Safari (macOS and iOS). Check availability before rendering.

What you’ll need

Before you initialize the Apple Pay frame, you need:
  1. A clientToken from a successful connect flow
  2. A valid quote signature for the transaction

Initialize the frame

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

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 "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:
function updateApplePayQuote(
  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 for event details.

What you’ll need

Before you initialize the add card frame, you need:
  1. A clientToken from a successful connect flow

Initialize the frame

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

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

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 for event details.

What you’ll need

Before you initialize the buy frame, you need:
  1. A clientToken from a successful connect flow
  2. A valid quote signature for the transaction

Initialize the frame

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

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

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. It renders the full MoonPay buy experience inside an iframe, including payment-method selection and transaction confirmation. See 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
  2. A valid quote signature for the transaction

Initialize the frame

The widget iframe requires the payment permission policy to process payments.
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

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

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

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