UseDocumentation Index
Fetch the complete documentation index at: https://dev.moonpay.com/llms.txt
Use this file to discover all available pages before exploring further.
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 useswindow.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 achallenge 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:- A
clientTokenfrom a successful connect flow - 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 achallenge 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)
}
}