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 WKWebView to embed frames in native iOS applications. The WebView communicates with frames via JavaScript message handlers and script injection.
Read the manual integration overview for core concepts before you continue.

Setup

Dependencies

The examples below use swift-crypto for X25519 key exchange, but you can use any library that supports X25519 and AES-GCM.
// Package.swift or via Xcode's Package Dependencies
dependencies: [
    .package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"),
]

Key generation

import Crypto
import Foundation

struct MoonPayKeyPair {
    let privateKey: Curve25519.KeyAgreement.PrivateKey
    let publicKeyHex: String

    init() {
        self.privateKey = Curve25519.KeyAgreement.PrivateKey()
        self.publicKeyHex = privateKey.publicKey.rawRepresentation
            .map { String(format: "%02x", $0) }
            .joined()
    }
}

Decryption utility

import CryptoKit
import Foundation

struct MoonPayDecryptor {
    struct EncryptedPayload: Codable {
        let iv: String
        let ephemeralPublicKey: String
        let ciphertext: String
    }

    static func decrypt(_ encryptedValue: String, privateKey: Curve25519.KeyAgreement.PrivateKey) -> String? {
        // The frame returns the encrypted payload as a base64-encoded
        // JSON string. Decode the base64 first, then parse the JSON.
        guard let jsonData = Data(base64Encoded: encryptedValue),
              let encrypted = try? JSONDecoder().decode(EncryptedPayload.self, from: jsonData),
              let ephemeralPublicKeyData = Data(hexString: encrypted.ephemeralPublicKey),
              let ivData = Data(hexString: encrypted.iv),
              let ciphertextData = Data(hexString: encrypted.ciphertext),
              let ephemeralPublicKey = try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: ephemeralPublicKeyData),
              let sharedSecret = try? privateKey.sharedSecretFromKeyAgreement(with: ephemeralPublicKey) else {
            return nil
        }

        // Derive AES key using HKDF
        let symmetricKey = sharedSecret.hkdfDerivedSymmetricKey(
            using: SHA256.self,
            salt: Data(),
            sharedInfo: Data(),
            outputByteCount: 32
        )

        // AES-GCM: ciphertext includes the auth tag at the end (last 16 bytes)
        guard ciphertextData.count > 16,
              let sealedBox = try? AES.GCM.SealedBox(
                  nonce: AES.GCM.Nonce(data: ivData),
                  ciphertext: ciphertextData.dropLast(16),
                  tag: ciphertextData.suffix(16)
              ),
              let decryptedData = try? AES.GCM.open(sealedBox, using: symmetricKey) else {
            return nil
        }

        return String(data: decryptedData, encoding: .utf8)
    }
}

extension Data {
    init?(hexString: String) {
        let len = hexString.count / 2
        var data = Data(capacity: len)
        var index = hexString.startIndex

        for _ in 0..<len {
            let nextIndex = hexString.index(index, offsetBy: 2)
            guard let byte = UInt8(hexString[index..<nextIndex], radix: 16) else { return nil }
            data.append(byte)
            index = nextIndex
        }

        self = data
    }
}

Base frame controller

Create a reusable base view controller for frame communication:
import UIKit
import WebKit

protocol MoonPayFrameDelegate: AnyObject {
    func frameDidReceiveMessage(_ message: [String: Any])
    func frameDidCompleteHandshake()
}

class MoonPayFrameViewController: UIViewController {
    private(set) var webView: WKWebView!
    private(set) var channelId: String

    weak var delegate: MoonPayFrameDelegate?

    let frameOrigin = "https://blocks.moonpay.com"

    init(channelId: String = UUID().uuidString) {
        self.channelId = channelId
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) {
        self.channelId = UUID().uuidString
        super.init(coder: coder)
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        setupWebView()
    }

    private func setupWebView() {
        let config = WKWebViewConfiguration()
        let contentController = WKUserContentController()

        contentController.add(self, name: "MoonPayBridge")

        // Expose window.MoonPayBridge so the frame can call postMessage directly
        let bridgeScript = WKUserScript(
            source: """
            window.MoonPayBridge = {
                postMessage: function(message) {
                    window.webkit.messageHandlers.MoonPayBridge.postMessage(message);
                }
            };
            """,
            injectionTime: .atDocumentStart,
            forMainFrameOnly: false
        )
        contentController.addUserScript(bridgeScript)

        config.userContentController = contentController

        webView = WKWebView(frame: view.bounds, configuration: config)
        webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        // Required for the test-mode Apple Pay flow — see "Handle JavaScript
        // dialogs" below.
        webView.uiDelegate = self
        view.addSubview(webView)
    }

    func loadFrame(path: String, params: [String: String]) {
        var components = URLComponents(string: "\(frameOrigin)\(path)")!
        components.queryItems = params.map { URLQueryItem(name: $0.key, value: $0.value) }

        guard let url = components.url else { return }
        webView.load(URLRequest(url: url))
    }

    func sendMessage(kind: String, payload: [String: Any]? = nil) {
        var message: [String: Any] = [
            "version": 2,
            "meta": ["channelId": channelId],
            "kind": kind,
        ]
        if let payload = payload {
            message["payload"] = payload
        }

        guard let jsonData = try? JSONSerialization.data(withJSONObject: message),
              let jsonString = String(data: jsonData, encoding: .utf8) else { return }

        // Re-encode the JSON as a string literal. The frame's bridge
        // listens for `MessageEvent`s whose `data` is a string and ignores
        // anything else, so we must post a string — not an object literal.
        guard let stringLiteralData = try? JSONEncoder().encode(jsonString),
              let jsStringLiteral = String(data: stringLiteralData, encoding: .utf8) else { return }

        let script = "window.postMessage(\(jsStringLiteral), '*');"
        webView.evaluateJavaScript(script, completionHandler: nil)
    }

}

// MARK: - WKScriptMessageHandler

extension MoonPayFrameViewController: WKScriptMessageHandler {
    func userContentController(_ userContentController: WKUserContentController,
                               didReceive message: WKScriptMessage) {
        guard message.name == "MoonPayBridge",
              let body = message.body as? String,
              let data = body.data(using: .utf8),
              let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              let meta = json["meta"] as? [String: Any],
              let messageChannelId = meta["channelId"] as? String,
              messageChannelId == channelId else { return }

        if let kind = json["kind"] as? String, kind == "handshake" {
            sendMessage(kind: "ack")
            delegate?.frameDidCompleteHandshake()
        }

        delegate?.frameDidReceiveMessage(json)
    }
}

Handle JavaScript dialogs

In test mode, the Apple Pay frame renders a mock button and uses window.confirm to simulate the Apple Pay payment sheet. By default, WKWebView silently dismisses window.confirm, alert, and prompt — the JavaScript call returns false with no UI shown — and the frame interprets that as the customer cancelling, so every test transaction comes back with status: "failed". To surface the simulated payment sheet, conform your base view controller to WKUIDelegate and present the confirm panel as a UIAlertController:
// MARK: - WKUIDelegate

extension MoonPayFrameViewController: WKUIDelegate {
    func webView(_ webView: WKWebView,
                 runJavaScriptConfirmPanelWithMessage message: String,
                 initiatedByFrame frame: WKFrameInfo,
                 completionHandler: @escaping (Bool) -> Void) {
        let alert = UIAlertController(
            title: "Test Mode",
            message: message.isEmpty ? "Simulate Apple Pay?" : message,
            preferredStyle: .alert
        )
        alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in
            completionHandler(false)
        })
        alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in
            completionHandler(true)
        })
        present(alert, animated: true)
    }
}
OK simulates a successful test transaction (the frame emits complete with a non-failed status); Cancel simulates a failed transaction (the frame emits complete with status: "failed").
Wire the UI delegate even if you only plan to ship live mode. The WKWebView default behaviour applies to any window.confirm, alert, or prompt the frame might surface, and it makes test-mode debugging impossible without it.

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.

Check controller

import UIKit

protocol CheckFrameDelegate: AnyObject {
    func checkDidFindActiveConnection(accessToken: String, clientToken: String, expiresAt: String)
    func checkDidRequireConnection(accessToken: String, clientToken: String)
    func checkDidFail(status: String, reason: String?)
    func checkDidError(code: String, message: String)
}

class MoonPayCheckViewController: MoonPayFrameViewController {
    private var keyPair: MoonPayKeyPair!
    private var sessionToken: String!

    weak var checkDelegate: CheckFrameDelegate?

    func configure(sessionToken: String) {
        self.sessionToken = sessionToken
        self.keyPair = MoonPayKeyPair()
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        delegate = self

        guard sessionToken != nil else {
            fatalError("Call configure(sessionToken:) before presenting")
        }

        loadFrame(path: "/platform/v1/check-connection", params: [
            "sessionToken": sessionToken,
            "publicKey": keyPair.publicKeyHex,
            "channelId": channelId,
        ])
    }
}

// MARK: - MoonPayFrameDelegate

extension MoonPayCheckViewController: MoonPayFrameDelegate {
    func frameDidReceiveMessage(_ message: [String: Any]) {
        guard let kind = message["kind"] as? String else { return }

        switch kind {
        case "complete":
            handleComplete(message["payload"] as? [String: Any] ?? [:])

        case "error":
            let payload = message["payload"] as? [String: Any] ?? [:]
            checkDelegate?.checkDidError(
                code: payload["code"] as? String ?? "unknown",
                message: payload["message"] as? String ?? "Unknown error"
            )

        default:
            break
        }
    }

    func frameDidCompleteHandshake() {
        // Handshake complete — checking connection status
    }

    private func handleComplete(_ payload: [String: Any]) {
        guard let status = payload["status"] as? String else { return }

        switch status {
        case "active":
            guard let credentials = payload["credentials"] as? String,
                  let expiresAt = payload["expiresAt"] as? String,
                  let decryptedPayload = MoonPayDecryptor.decrypt(credentials, privateKey: keyPair.privateKey),
                  let credentialsData = decryptedPayload.data(using: .utf8),
                  let credentials = try? JSONSerialization.jsonObject(with: credentialsData) as? [String: String],
                  let accessToken = credentials["accessToken"],
                  let clientToken = credentials["clientToken"] else {
                checkDelegate?.checkDidError(code: "decryption", message: "Failed to decrypt credentials")
                return
            }

            // Check payload["capabilities"]["ramps"]["requirements"]["paymentDisclosures"]
            // to determine if payment disclosures are required before transacting
            checkDelegate?.checkDidFindActiveConnection(accessToken: accessToken, clientToken: clientToken, expiresAt: expiresAt)

        case "connectionRequired":
            guard let credentials = payload["credentials"] as? String,
                  let decryptedPayload = MoonPayDecryptor.decrypt(credentials, privateKey: keyPair.privateKey),
                  let credentialsData = decryptedPayload.data(using: .utf8),
                  let anonymousCredentials = try? JSONSerialization.jsonObject(with: credentialsData) as? [String: String],
                  let accessToken = anonymousCredentials["accessToken"],
                  let clientToken = anonymousCredentials["clientToken"] else {
                checkDelegate?.checkDidError(code: "decryption", message: "Failed to decrypt anonymous credentials")
                return
            }
            checkDelegate?.checkDidRequireConnection(accessToken: accessToken, clientToken: clientToken)

        case "pending", "unavailable", "failed":
            checkDelegate?.checkDidFail(status: status, reason: payload["reason"] as? String)

        default:
            break
        }
    }
}

Usage

class SplashViewController: UIViewController, CheckFrameDelegate {
    func checkConnection() {
        let checkVC = MoonPayCheckViewController()
        checkVC.configure(sessionToken: "your-session-token")
        checkVC.checkDelegate = self

        // The check frame is headless — add as a child without visible UI
        addChild(checkVC)
        checkVC.view.frame = .zero
        view.addSubview(checkVC.view)
        checkVC.didMove(toParent: self)
    }

    // MARK: - CheckFrameDelegate

    func checkDidFindActiveConnection(accessToken: String, clientToken: String, expiresAt: String) {
        // Customer is already connected — skip to payment
        CredentialsManager.shared.accessToken = accessToken
        CredentialsManager.shared.clientToken = clientToken

        let paymentVC = PaymentViewController()
        navigationController?.pushViewController(paymentVC, animated: true)
    }

    func checkDidRequireConnection(accessToken: String, clientToken: String) {
        // Store both tokens in memory, then show connect frame with the anonymous clientToken
        CredentialsManager.shared.accessToken = accessToken
        CredentialsManager.shared.clientToken = clientToken

        let connectVC = MoonPayConnectViewController()
        connectVC.configure(clientToken: clientToken)
        navigationController?.pushViewController(connectVC, animated: true)
    }

    func checkDidFail(status: String, reason: String?) {
        print("Check \(status): \(reason ?? "No reason provided")")
    }

    func checkDidError(code: String, message: String) {
        print("Check error: \(code)\(message)")
    }
}

Connect frame

The connect frame establishes a customer connection to your application. See connect frame reference for event details.

Connect controller

import UIKit

protocol ConnectFrameDelegate: AnyObject {
    func connectDidComplete(accessToken: String, clientToken: String, expiresAt: String)
    func connectDidFail(status: String, reason: String?)
    func connectDidError(code: String, message: String)
}

class MoonPayConnectViewController: MoonPayFrameViewController {
    private var keyPair: MoonPayKeyPair!
    private var clientToken: String!

    weak var connectDelegate: ConnectFrameDelegate?

    func configure(clientToken: String) {
        self.clientToken = clientToken
        self.keyPair = MoonPayKeyPair()
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        delegate = self

        guard clientToken != nil else {
            fatalError("Call configure(clientToken:) before presenting")
        }

        loadFrame(path: "/platform/v1/connect", params: [
            "clientToken": clientToken,
            "publicKey": keyPair.publicKeyHex,
            "channelId": channelId,
        ])
    }
}

// MARK: - MoonPayFrameDelegate

extension MoonPayConnectViewController: MoonPayFrameDelegate {
    func frameDidReceiveMessage(_ message: [String: Any]) {
        guard let kind = message["kind"] as? String else { return }

        switch kind {
        case "complete":
            handleComplete(message["payload"] as? [String: Any] ?? [:])

        case "error":
            let payload = message["payload"] as? [String: Any] ?? [:]
            connectDelegate?.connectDidError(
                code: payload["code"] as? String ?? "unknown",
                message: payload["message"] as? String ?? "Unknown error"
            )

        default:
            break
        }
    }

    func frameDidCompleteHandshake() {
        // Handshake complete — waiting for customer interaction
    }

    private func handleComplete(_ payload: [String: Any]) {
        guard let status = payload["status"] as? String else { return }

        switch status {
        case "active":
            guard let credentials = payload["credentials"] as? String,
                  let expiresAt = payload["expiresAt"] as? String,
                  let decryptedPayload = MoonPayDecryptor.decrypt(credentials, privateKey: keyPair.privateKey),
                  let credentialsData = decryptedPayload.data(using: .utf8),
                  let credentials = try? JSONSerialization.jsonObject(with: credentialsData) as? [String: String],
                  let accessToken = credentials["accessToken"],
                  let clientToken = credentials["clientToken"] else {
                connectDelegate?.connectDidError(code: "decryption", message: "Failed to decrypt credentials")
                return
            }

            // Check payload["capabilities"]["ramps"]["requirements"]["paymentDisclosures"]
            // to determine if payment disclosures are required before transacting
            connectDelegate?.connectDidComplete(accessToken: accessToken, clientToken: clientToken, expiresAt: expiresAt)

        case "pending", "unavailable", "failed":
            connectDelegate?.connectDidFail(status: status, reason: payload["reason"] as? String)

        default:
            break
        }
    }
}

Usage

class MyViewController: UIViewController, ConnectFrameDelegate {
    // The clientToken is the anonymous token obtained from the check frame's
    // `connectionRequired` response.
    func showConnect(clientToken: String) {
        let connectVC = MoonPayConnectViewController()
        connectVC.configure(clientToken: clientToken)
        connectVC.connectDelegate = self
        connectVC.modalPresentationStyle = .fullScreen

        present(connectVC, animated: true)
    }

    // MARK: - ConnectFrameDelegate

    func connectDidComplete(accessToken: String, clientToken: String, expiresAt: String) {
        dismiss(animated: true)

        // Store credentials in memory
        CredentialsManager.shared.accessToken = accessToken
        CredentialsManager.shared.clientToken = clientToken

        print("Connected! Expires: \(expiresAt)")
    }

    func connectDidFail(status: String, reason: String?) {
        dismiss(animated: true)
        showAlert(title: "Connection \(status)", message: reason ?? "Please try again later.")
    }

    func connectDidError(code: String, message: String) {
        dismiss(animated: true)
        showAlert(title: "Error", message: message)
    }
}

Apple Pay frame

The Apple Pay frame renders a native Apple Pay button and handles the payment flow. See Apple Pay frame reference for event details.

Apple Pay controller

import UIKit

protocol ApplePayFrameDelegate: AnyObject {
    func applePayDidComplete(transactionId: String, status: String)
    func applePayDidFail(reason: String)
    func applePayDidError(code: String, message: String)
    func applePayDidBecomeReady()
}

class MoonPayApplePayViewController: MoonPayFrameViewController {
    private var clientToken: String!
    private var quoteSignature: String!

    weak var applePayDelegate: ApplePayFrameDelegate?

    func configure(clientToken: String, quoteSignature: String) {
        self.clientToken = clientToken
        self.quoteSignature = quoteSignature
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        delegate = self

        guard clientToken != nil, quoteSignature != nil else {
            fatalError("Call configure(clientToken:quoteSignature:) before presenting")
        }

        loadFrame(path: "/platform/v1/apple-pay", params: [
            "clientToken": clientToken,
            "channelId": channelId,
            "signature": quoteSignature,
        ])
    }

    func updateQuote(signature: String) {
        quoteSignature = signature
        sendMessage(kind: "setQuote", payload: [
            "quote": ["signature": signature]
        ])
    }
}

// MARK: - MoonPayFrameDelegate

extension MoonPayApplePayViewController: MoonPayFrameDelegate {
    func frameDidReceiveMessage(_ message: [String: Any]) {
        guard let kind = message["kind"] as? String else { return }

        switch kind {
        case "ready":
            applePayDelegate?.applePayDidBecomeReady()

        case "complete":
            handleComplete(message["payload"] as? [String: Any] ?? [:])

        case "error":
            let payload = message["payload"] as? [String: Any] ?? [:]
            let code = payload["code"] as? String ?? "unknown"
            let message = payload["message"] as? String ?? "Unknown error"
            applePayDelegate?.applePayDidError(code: code, message: message)

        default:
            break
        }
    }

    func frameDidCompleteHandshake() {
        // Handshake complete
    }

    private func handleComplete(_ payload: [String: Any]) {
        guard let transaction = payload["transaction"] as? [String: Any],
              let status = transaction["status"] as? String else { return }

        if status == "failed" {
            let reason = transaction["failureReason"] as? String ?? "Transaction failed"
            applePayDelegate?.applePayDidFail(reason: reason)
        } else if let transactionId = transaction["id"] as? String {
            applePayDelegate?.applePayDidComplete(transactionId: transactionId, status: status)
        }
    }
}

Usage

class PaymentViewController: UIViewController, ApplePayFrameDelegate {
    private var applePayContainer: UIView!
    private var applePayVC: MoonPayApplePayViewController?

    override func viewDidLoad() {
        super.viewDidLoad()
        setupApplePayFrame()
    }

    private func setupApplePayFrame() {
        applePayContainer = UIView(frame: CGRect(x: 20, y: 200, width: view.bounds.width - 40, height: 50))
        view.addSubview(applePayContainer)

        let applePayVC = MoonPayApplePayViewController()
        applePayVC.configure(
            clientToken: CredentialsManager.shared.clientToken!,
            quoteSignature: currentQuote.signature
        )
        applePayVC.applePayDelegate = self

        addChild(applePayVC)
        applePayVC.view.frame = applePayContainer.bounds
        applePayContainer.addSubview(applePayVC.view)
        applePayVC.didMove(toParent: self)

        self.applePayVC = applePayVC
    }

    // MARK: - ApplePayFrameDelegate

    func applePayDidComplete(transactionId: String, status: String) {
        print("Transaction \(transactionId) status: \(status)")
        // Navigate to transaction status screen
    }

    func applePayDidFail(reason: String) {
        showAlert(title: "Payment Failed", message: reason)
    }

    func applePayDidError(code: String, message: String) {
        if code == "quoteExpired" {
            // Fetch new quote and update
            Task {
                let newQuote = await fetchNewQuote()
                applePayVC?.updateQuote(signature: newQuote.signature)
            }
            return
        }
        showAlert(title: "Error", message: message)
    }

    func applePayDidBecomeReady() {
        print("Apple Pay button is ready")
    }
}

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.

Add Card controller

import UIKit

protocol AddCardFrameDelegate: AnyObject {
    func addCardDidComplete(card: [String: Any])
    func addCardDidError(code: String, message: String)
}

class MoonPayAddCardViewController: MoonPayFrameViewController {
    private var clientToken: String!

    weak var addCardDelegate: AddCardFrameDelegate?

    func configure(clientToken: String) {
        self.clientToken = clientToken
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        delegate = self

        guard clientToken != nil else {
            fatalError("Call configure(clientToken:) before presenting")
        }

        loadFrame(path: "/platform/v1/add-card", params: [
            "clientToken": clientToken,
            "channelId": channelId,
        ])
    }
}

// MARK: - MoonPayFrameDelegate

extension MoonPayAddCardViewController: MoonPayFrameDelegate {
    func frameDidReceiveMessage(_ message: [String: Any]) {
        guard let kind = message["kind"] as? String else { return }

        switch kind {
        case "ready":
            break

        case "complete":
            let payload = message["payload"] as? [String: Any] ?? [:]
            let card = payload["card"] as? [String: Any] ?? [:]
            addCardDelegate?.addCardDidComplete(card: card)

        case "error":
            let payload = message["payload"] as? [String: Any] ?? [:]
            addCardDelegate?.addCardDidError(
                code: payload["code"] as? String ?? "unknown",
                message: payload["message"] as? String ?? "Unknown error"
            )

        default:
            break
        }
    }

    func frameDidCompleteHandshake() {
        // Handshake complete
    }
}

Usage

class SaveCardViewController: UIViewController, AddCardFrameDelegate {
    private var addCardVC: MoonPayAddCardViewController?

    override func viewDidLoad() {
        super.viewDidLoad()
        setupAddCardFrame()
    }

    private func setupAddCardFrame() {
        let addCardVC = MoonPayAddCardViewController()
        addCardVC.configure(clientToken: CredentialsManager.shared.clientToken!)
        addCardVC.addCardDelegate = self

        addChild(addCardVC)
        addCardVC.view.frame = view.bounds
        addCardVC.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        view.addSubview(addCardVC.view)
        addCardVC.didMove(toParent: self)

        self.addCardVC = addCardVC
    }

    // MARK: - AddCardFrameDelegate

    func addCardDidComplete(card: [String: Any]) {
        print("Card saved: \(card["id"] ?? "")")
        // Proceed to payment with the saved card
    }

    func addCardDidError(code: String, message: String) {
        showAlert(title: "Error", message: message)
    }
}

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

Buy controller

import UIKit

protocol BuyFrameDelegate: AnyObject {
    func buyDidComplete(transactionId: String, status: String)
    func buyDidChallenge(url: String)
    func buyDidError(code: String, message: String)
}

class MoonPayBuyViewController: MoonPayFrameViewController {
    private var clientToken: String!
    private var quoteSignature: String!

    weak var buyDelegate: BuyFrameDelegate?

    func configure(clientToken: String, quoteSignature: String) {
        self.clientToken = clientToken
        self.quoteSignature = quoteSignature
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        delegate = self

        guard clientToken != nil, quoteSignature != nil else {
            fatalError("Call configure(clientToken:quoteSignature:) before presenting")
        }

        loadFrame(path: "/platform/v1/buy", params: [
            "clientToken": clientToken,
            "channelId": channelId,
            "signature": quoteSignature,
        ])
    }

    func updateQuote(signature: String) {
        quoteSignature = signature
        sendMessage(kind: "setQuote", payload: [
            "quote": ["signature": signature]
        ])
    }

    func dispose() {
        view.removeFromSuperview()
        removeFromParent()
    }
}

// MARK: - MoonPayFrameDelegate

extension MoonPayBuyViewController: MoonPayFrameDelegate {
    func frameDidReceiveMessage(_ message: [String: Any]) {
        guard let kind = message["kind"] as? String else { return }

        switch kind {
        case "ready":
            break

        case "complete":
            let payload = message["payload"] as? [String: Any] ?? [:]
            let transaction = payload["transaction"] as? [String: Any] ?? [:]
            let transactionId = transaction["id"] as? String ?? ""
            let status = transaction["status"] as? String ?? ""
            buyDelegate?.buyDidComplete(transactionId: transactionId, status: status)

        case "challenge":
            let payload = message["payload"] as? [String: Any] ?? [:]
            let url = payload["url"] as? String ?? ""
            buyDelegate?.buyDidChallenge(url: url)

        case "error":
            let payload = message["payload"] as? [String: Any] ?? [:]
            buyDelegate?.buyDidError(
                code: payload["code"] as? String ?? "unknown",
                message: payload["message"] as? String ?? "Unknown error"
            )

        default:
            break
        }
    }

    func frameDidCompleteHandshake() {
        // Handshake complete
    }
}

Challenge handling

When the buy frame emits a challenge event, present a MoonPayChallengeViewController with the challenge URL. On completion, cancellation, or error, call dispose() on the buy view controller:
protocol ChallengeFrameDelegate: AnyObject {
    func challengeDidComplete(transactionId: String, status: String)
    func challengeDidCancel()
    func challengeDidError(code: String, message: String)
}

class MoonPayChallengeViewController: MoonPayFrameViewController {
    private var challengeUrl: String!

    weak var challengeDelegate: ChallengeFrameDelegate?

    func configure(challengeUrl: String) {
        self.challengeUrl = challengeUrl
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        delegate = self

        guard let rawUrl = challengeUrl,
              var components = URLComponents(string: rawUrl) else {
            fatalError("Call configure(challengeUrl:) before presenting")
        }

        var queryItems = components.queryItems ?? []
        queryItems.append(URLQueryItem(name: "channelId", value: channelId))
        components.queryItems = queryItems

        guard let url = components.url else { return }
        webView.load(URLRequest(url: url))
    }
}

// MARK: - MoonPayFrameDelegate

extension MoonPayChallengeViewController: MoonPayFrameDelegate {
    func frameDidReceiveMessage(_ message: [String: Any]) {
        guard let kind = message["kind"] as? String else { return }

        switch kind {
        case "ready":
            break

        case "complete":
            let payload = message["payload"] as? [String: Any] ?? [:]
            let transaction = payload["transaction"] as? [String: Any] ?? [:]
            let transactionId = transaction["id"] as? String ?? ""
            let status = transaction["status"] as? String ?? ""
            challengeDelegate?.challengeDidComplete(transactionId: transactionId, status: status)

        case "cancelled":
            challengeDelegate?.challengeDidCancel()

        case "error":
            let payload = message["payload"] as? [String: Any] ?? [:]
            challengeDelegate?.challengeDidError(
                code: payload["code"] as? String ?? "unknown",
                message: payload["message"] as? String ?? "Unknown error"
            )

        default:
            break
        }
    }

    func frameDidCompleteHandshake() {
        // Handshake complete
    }
}

Usage

class BuyPaymentViewController: UIViewController, BuyFrameDelegate, ChallengeFrameDelegate {
    private var buyVC: MoonPayBuyViewController?

    override func viewDidLoad() {
        super.viewDidLoad()
        setupBuyFrame()
    }

    private func setupBuyFrame() {
        let buyVC = MoonPayBuyViewController()
        buyVC.configure(
            clientToken: CredentialsManager.shared.clientToken!,
            quoteSignature: currentQuote.signature
        )
        buyVC.buyDelegate = self

        // The buy frame is headless — add as a zero-size child
        addChild(buyVC)
        buyVC.view.frame = .zero
        view.addSubview(buyVC.view)
        buyVC.didMove(toParent: self)

        self.buyVC = buyVC
    }

    // MARK: - BuyFrameDelegate

    func buyDidComplete(transactionId: String, status: String) {
        print("Transaction \(transactionId) status: \(status)")
        // Navigate to transaction status screen
    }

    func buyDidChallenge(url: String) {
        let challengeVC = MoonPayChallengeViewController()
        challengeVC.configure(challengeUrl: url)
        challengeVC.challengeDelegate = self
        challengeVC.modalPresentationStyle = .fullScreen
        present(challengeVC, animated: true)
    }

    func buyDidError(code: String, message: String) {
        if code == "quoteExpired" {
            Task {
                let newQuote = await fetchNewQuote()
                buyVC?.updateQuote(signature: newQuote.signature)
            }
            return
        }
        showAlert(title: "Error", message: message)
    }

    // MARK: - ChallengeFrameDelegate

    func challengeDidComplete(transactionId: String, status: String) {
        dismiss(animated: true)
        buyVC?.dispose()
        print("Transaction \(transactionId) status: \(status)")
        // Navigate to transaction status screen
    }

    func challengeDidCancel() {
        dismiss(animated: true)
        buyVC?.dispose()
    }

    func challengeDidError(code: String, message: String) {
        dismiss(animated: true)
        buyVC?.dispose()
        showAlert(title: "Error", message: message)
    }
}

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 a WKWebView, including payment-method selection and transaction confirmation. See pay with widget for a full walkthrough.

Widget controller

import UIKit

protocol WidgetFrameDelegate: AnyObject {
    func widgetDidBecomeReady()
    func widgetDidCreateTransaction(transactionId: String, status: String)
    func widgetDidComplete(transactionId: String, status: String)
    func widgetDidFail(reason: String)
    func widgetDidError(code: String, message: String)
}

class MoonPayWidgetViewController: MoonPayFrameViewController {
    private var clientToken: String!
    private var quoteSignature: String!

    weak var widgetDelegate: WidgetFrameDelegate?

    func configure(clientToken: String, quoteSignature: String) {
        self.clientToken = clientToken
        self.quoteSignature = quoteSignature
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        delegate = self

        guard clientToken != nil, quoteSignature != nil else {
            fatalError("Call configure(clientToken:quoteSignature:) before presenting")
        }

        loadFrame(path: "/platform/v1/widget", params: [
            "flow": "buy",
            "clientToken": clientToken,
            "quoteSignature": quoteSignature,
            "channelId": channelId,
        ])
    }
}

// MARK: - MoonPayFrameDelegate

extension MoonPayWidgetViewController: MoonPayFrameDelegate {
    func frameDidReceiveMessage(_ message: [String: Any]) {
        guard let kind = message["kind"] as? String else { return }

        switch kind {
        case "ready":
            widgetDelegate?.widgetDidBecomeReady()

        case "transactionCreated":
            let payload = message["payload"] as? [String: Any] ?? [:]
            let transaction = payload["transaction"] as? [String: Any] ?? [:]
            widgetDelegate?.widgetDidCreateTransaction(
                transactionId: transaction["id"] as? String ?? "",
                status: transaction["status"] as? String ?? ""
            )

        case "complete":
            handleComplete(message["payload"] as? [String: Any] ?? [:])

        case "error":
            let payload = message["payload"] as? [String: Any] ?? [:]
            widgetDelegate?.widgetDidError(
                code: payload["code"] as? String ?? "unknown",
                message: payload["message"] as? String ?? "Unknown error"
            )

        default:
            break
        }
    }

    func frameDidCompleteHandshake() {
        // Handshake complete — widget loading
    }

    private func handleComplete(_ payload: [String: Any]) {
        guard let transaction = payload["transaction"] as? [String: Any],
              let status = transaction["status"] as? String else { return }

        if status == "failed" {
            let reason = transaction["failureReason"] as? String ?? "Transaction failed"
            widgetDelegate?.widgetDidFail(reason: reason)
        } else if let transactionId = transaction["id"] as? String {
            widgetDelegate?.widgetDidComplete(transactionId: transactionId, status: status)
        }
    }
}

Usage

class WidgetPaymentViewController: UIViewController, WidgetFrameDelegate {
    private var widgetVC: MoonPayWidgetViewController?

    override func viewDidLoad() {
        super.viewDidLoad()
        setupWidget()
    }

    private func setupWidget() {
        let widgetVC = MoonPayWidgetViewController()
        widgetVC.configure(
            clientToken: CredentialsManager.shared.clientToken!,
            quoteSignature: currentQuote.signature
        )
        widgetVC.widgetDelegate = self

        addChild(widgetVC)
        widgetVC.view.frame = view.bounds
        widgetVC.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        view.addSubview(widgetVC.view)
        widgetVC.didMove(toParent: self)

        self.widgetVC = widgetVC
    }

    // MARK: - WidgetFrameDelegate

    func widgetDidBecomeReady() {
        print("Widget loaded and visible")
    }

    func widgetDidCreateTransaction(transactionId: String, status: String) {
        print("Transaction created: \(transactionId) (\(status))")
        // Customer may still need to complete 3-D Secure
    }

    func widgetDidComplete(transactionId: String, status: String) {
        print("Transaction \(transactionId) status: \(status)")
        // Navigate to transaction status screen
    }

    func widgetDidFail(reason: String) {
        showAlert(title: "Payment Failed", message: reason)
    }

    func widgetDidError(code: String, message: String) {
        showAlert(title: "Error", message: message)
    }
}