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

# iOS

> Manual frame integration for iOS using WKWebView and Swift.

Use `WKWebView` to embed frames in native iOS applications. The WebView communicates with frames via JavaScript message handlers and script injection.

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

## Setup

### Dependencies

The examples below use [swift-crypto](https://github.com/apple/swift-crypto) for X25519 key exchange, but you can use any library that supports X25519 and AES-GCM.

```swift theme={null}
// Package.swift or via Xcode's Package Dependencies
dependencies: [
    .package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"),
]
```

### Key generation

```swift theme={null}
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

```swift theme={null}
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:

```swift theme={null}
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](/overview/test-mode#apple-pay), 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"`.

This WKWebView requirement is captured on the [Apple Pay frame reference](/platform/frames/apple-pay#wkwebview), which is the source of truth.

To surface the simulated payment sheet, conform your base view controller to `WKUIDelegate` and present the confirm panel as a `UIAlertController`:

```swift theme={null}
// 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"`).

<Callout icon="info" iconType="regular">
  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.
</Callout>

***

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

### Check controller

```swift theme={null}
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
            }

            let customer = payload["customer"] as? [String: Any]
            let country = customer?["country"] as? String
            let administrativeArea = customer?["administrativeArea"] as? String
            let area = customer?["area"] as? String
            // payload["capabilities"]["ramps"]["requirements"]["paymentDisclosures"] is deprecated.
            checkDelegate?.checkDidFindActiveConnection(accessToken: accessToken, clientToken: clientToken, expiresAt: expiresAt)

        case "connectionRequired":
            // payload["mismatch"] is true when the session's email and phone resolve to different customers — route through the connect flow first.
            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

```swift theme={null}
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](/platform/frames/connect) for event details.

### Connect controller

```swift theme={null}
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
            }

            let customer = payload["customer"] as? [String: Any]
            let country = customer?["country"] as? String
            let administrativeArea = customer?["administrativeArea"] as? String
            let area = customer?["area"] as? String
            // payload["capabilities"]["ramps"]["requirements"]["paymentDisclosures"] is deprecated.
            connectDelegate?.connectDidComplete(accessToken: accessToken, clientToken: clientToken, expiresAt: expiresAt)

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

        default:
            break
        }
    }
}
```

### Usage

```swift theme={null}
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](/platform/frames/apple-pay) for event details.

### Apple Pay controller

```swift theme={null}
import UIKit

protocol ApplePayFrameDelegate: AnyObject {
    func applePayDidComplete(transactionId: String, status: String)
    func applePayDidFail(reason: String)
    func applePayDidChallenge(url: 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 "challenge":
            let payload = message["payload"] as? [String: Any] ?? [:]
            let url = payload["url"] as? String ?? ""
            applePayDelegate?.applePayDidChallenge(url: url)

        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

```swift theme={null}
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 applePayDidChallenge(url: String) {
        // Render the challenge frame at the provided URL
        // See "Challenge handling" in the Buy frame section
        let challengeVC = MoonPayChallengeViewController()
        challengeVC.configure(challengeUrl: url)
        challengeVC.challengeDelegate = self
        challengeVC.modalPresentationStyle = .fullScreen
        present(challengeVC, animated: true)
    }

    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](/platform/frames/add-card) for event details.

### Add Card controller

```swift theme={null}
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

```swift theme={null}
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](/platform/frames/buy) for event details.

### What you'll need

Before you initialize the buy frame, you need:

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

### Buy controller

```swift theme={null}
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:

```swift theme={null}
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

```swift theme={null}
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](/platform/frames/widget). It renders the full MoonPay buy experience inside a WKWebView, including payment-method selection and transaction confirmation. See [pay with widget](/platform/guides/pay-with-widget) for a full walkthrough.

### Widget controller

```swift theme={null}
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

```swift theme={null}
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)
    }
}
```
