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 WebView to embed frames in native Android applications. The WebView communicates with frames via JavaScript interfaces and evaluateJavascript.
Read the manual integration overview for core concepts before you continue.

Setup

Dependencies

The examples below use Tink for cryptographic operations, but you can use any library that supports X25519 and AES-GCM.
// build.gradle.kts (app level)
dependencies {
    implementation("com.google.crypto.tink:tink-android:1.12.0")
}

Key generation

import com.google.crypto.tink.subtle.X25519

data class MoonPayKeyPair(
    val privateKey: ByteArray,
    val publicKeyHex: String
)

object MoonPayCrypto {
    fun generateKeyPair(): MoonPayKeyPair {
        val privateKey = X25519.generatePrivateKey()
        val publicKey = X25519.publicFromPrivate(privateKey)
        val publicKeyHex = publicKey.joinToString("") { "%02x".format(it) }

        return MoonPayKeyPair(privateKey, publicKeyHex)
    }
}

Decryption utility

import android.util.Base64
import com.google.crypto.tink.subtle.Hkdf
import com.google.crypto.tink.subtle.X25519
import org.json.JSONObject
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec

object MoonPayDecryptor {
    fun decrypt(encryptedValue: String, privateKey: ByteArray): String {
        // The frame returns the encrypted payload as a base64-encoded
        // JSON string. Decode the base64 first, then parse the JSON.
        val decodedJson = String(Base64.decode(encryptedValue, Base64.DEFAULT), Charsets.UTF_8)
        val encrypted = JSONObject(decodedJson)

        val iv = encrypted.getString("iv").hexToByteArray()
        val ephemeralPublicKey = encrypted.getString("ephemeralPublicKey").hexToByteArray()
        val ciphertext = encrypted.getString("ciphertext").hexToByteArray()

        // Derive shared secret using X25519
        val sharedSecret = X25519.computeSharedSecret(privateKey, ephemeralPublicKey)

        // Derive AES key using HKDF
        val aesKey = Hkdf.computeHkdf(
            "HMACSHA256",
            sharedSecret,
            ByteArray(0),
            ByteArray(0),
            32
        )

        // Decrypt using AES-GCM
        val cipher = Cipher.getInstance("AES/GCM/NoPadding")
        val keySpec = SecretKeySpec(aesKey, "AES")
        val gcmSpec = GCMParameterSpec(128, iv)

        cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec)
        val decrypted = cipher.doFinal(ciphertext)

        return String(decrypted, Charsets.UTF_8)
    }

    private fun String.hexToByteArray(): ByteArray {
        return chunked(2).map { it.toInt(16).toByte() }.toByteArray()
    }
}

Base frame fragment

Create a reusable base fragment for frame communication:
import android.annotation.SuppressLint
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.fragment.app.Fragment
import org.json.JSONObject
import java.util.UUID

abstract class MoonPayFrameFragment : Fragment() {
    protected lateinit var webView: WebView
    protected val channelId: String = UUID.randomUUID().toString()

    private val handler = Handler(Looper.getMainLooper())

    companion object {
        const val FRAME_ORIGIN = "https://blocks.moonpay.com"
    }

    @SuppressLint("SetJavaScriptEnabled")
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        webView = WebView(requireContext()).apply {
            settings.javaScriptEnabled = true
            settings.domStorageEnabled = true

            addJavascriptInterface(MoonPayBridge(), "MoonPayBridge")
            // Required for the test-mode Apple Pay flow — see "Handle
            // JavaScript dialogs" below.
            webChromeClient = MoonPayChromeClient(this@MoonPayFrameFragment)
        }

        return webView
    }

    protected fun loadFrame(path: String, params: Map<String, String>) {
        val queryString = params.entries.joinToString("&") { "${it.key}=${it.value}" }
        val url = "$FRAME_ORIGIN$path?$queryString"

        webView.loadUrl(url)
    }

    protected fun sendMessage(kind: String, payload: JSONObject? = null) {
        val message = JSONObject().apply {
            put("version", 2)
            put("meta", JSONObject().put("channelId", channelId))
            put("kind", kind)
            payload?.let { put("payload", it) }
        }

        // 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.
        val stringLiteral = JSONObject.quote(message.toString())

        val script = "window.postMessage($stringLiteral, '*');"
        webView.evaluateJavascript(script, null)
    }

    private fun handleMessage(data: JSONObject) {
        val meta = data.optJSONObject("meta") ?: return
        if (meta.optString("channelId") != channelId) return

        val kind = data.optString("kind")

        if (kind == "handshake") {
            sendMessage("ack")
            onFrameHandshakeComplete()
        }

        onFrameMessage(kind, data.optJSONObject("payload"))
    }

    // Abstract methods for subclasses
    protected abstract fun onFrameMessage(kind: String, payload: JSONObject?)
    protected abstract fun onFrameHandshakeComplete()

    override fun onDestroyView() {
        super.onDestroyView()
        webView.destroy()
    }

    inner class MoonPayBridge {
        @JavascriptInterface
        fun postMessage(data: String) {
            handler.post {
                try {
                    val json = JSONObject(data)
                    handleMessage(json)
                } catch (e: Exception) {
                    // Ignore malformed messages
                }
            }
        }
    }
}

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. Android’s WebView returns false for window.confirm, alert, and prompt unless you attach a WebChromeClient that handles them — so without this, every test transaction silently comes back with status: "failed". Surface the simulated payment sheet with an AlertDialog:
import android.app.AlertDialog
import android.webkit.JsResult
import android.webkit.WebChromeClient
import android.webkit.WebView
import androidx.fragment.app.Fragment

class MoonPayChromeClient(private val fragment: Fragment) : WebChromeClient() {
    override fun onJsConfirm(
        view: WebView?,
        url: String?,
        message: String?,
        result: JsResult
    ): Boolean {
        val context = fragment.context ?: return false
        AlertDialog.Builder(context)
            .setTitle("Test Mode")
            .setMessage(message?.takeIf { it.isNotEmpty() } ?: "Simulate Apple Pay?")
            .setPositiveButton("OK") { _, _ -> result.confirm() }
            .setNegativeButton("Cancel") { _, _ -> result.cancel() }
            .setOnCancelListener { result.cancel() }
            .show()
        return 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").
Attach the WebChromeClient even if you only plan to ship live mode. The default WebView behaviour applies to any window.confirm, alert, or prompt the frame might surface, and 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 fragment

import org.json.JSONObject

interface CheckFrameListener {
    fun onCheckActive(accessToken: String, clientToken: String, expiresAt: String)
    fun onCheckConnectionRequired(accessToken: String, clientToken: String)
    fun onCheckFailed(status: String, reason: String?)
    fun onCheckError(code: String, message: String)
}

class MoonPayCheckFragment : MoonPayFrameFragment() {
    private lateinit var keyPair: MoonPayKeyPair
    private var sessionToken: String? = null
    var listener: CheckFrameListener? = null

    companion object {
        private const val ARG_SESSION_TOKEN = "sessionToken"

        fun newInstance(sessionToken: String): MoonPayCheckFragment {
            return MoonPayCheckFragment().apply {
                arguments = Bundle().apply {
                    putString(ARG_SESSION_TOKEN, sessionToken)
                }
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        sessionToken = arguments?.getString(ARG_SESSION_TOKEN)
        keyPair = MoonPayCrypto.generateKeyPair()
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        loadFrame("/platform/v1/check-connection", mapOf(
            "sessionToken" to sessionToken!!,
            "publicKey" to keyPair.publicKeyHex,
            "channelId" to channelId
        ))
    }

    override fun onFrameMessage(kind: String, payload: JSONObject?) {
        when (kind) {
            "complete" -> handleComplete(payload)
            "error" -> {
                listener?.onCheckError(
                    payload?.optString("code") ?: "unknown",
                    payload?.optString("message") ?: "Unknown error"
                )
            }
        }
    }

    override fun onFrameHandshakeComplete() {
        // Handshake complete, checking connection status
    }

    private fun handleComplete(payload: JSONObject?) {
        val status = payload?.optString("status") ?: return

        when (status) {
            "active" -> {
                val credentials = payload.optString("credentials")
                val expiresAt = payload.optString("expiresAt")
                // Check payload.capabilities.ramps.requirements.paymentDisclosures
                // to determine if payment disclosures are required before transacting

                try {
                    val decryptedPayload = MoonPayDecryptor.decrypt(credentials, keyPair.privateKey)
                    val credentials = JSONObject(decryptedPayload)
                    val accessToken = credentials.getString("accessToken")
                    val clientToken = credentials.getString("clientToken")
                    listener?.onCheckActive(accessToken, clientToken, expiresAt)
                } catch (e: Exception) {
                    listener?.onCheckError("decryption", "Failed to decrypt credentials")
                }
            }
            "connectionRequired" -> {
                val encryptedCredentials = payload.optString("credentials")

                try {
                    val decryptedPayload = MoonPayDecryptor.decrypt(encryptedCredentials, keyPair.privateKey)
                    val anonymousCredentials = JSONObject(decryptedPayload)
                    val accessToken = anonymousCredentials.getString("accessToken")
                    val clientToken = anonymousCredentials.getString("clientToken")
                    listener?.onCheckConnectionRequired(accessToken, clientToken)
                } catch (e: Exception) {
                    listener?.onCheckError("decryption", "Failed to decrypt anonymous credentials")
                }
            }
            "pending", "unavailable", "failed" -> {
                listener?.onCheckFailed(status, payload.optString("reason"))
            }
        }
    }
}

Usage

class SplashActivity : AppCompatActivity(), CheckFrameListener {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_frame_container)

        val fragment = MoonPayCheckFragment.newInstance("your-session-token")
        fragment.listener = this

        supportFragmentManager.beginTransaction()
            .replace(R.id.frame_container, fragment)
            .commit()
    }

    override fun onCheckActive(accessToken: String, clientToken: String, expiresAt: String) {
        // Customer is already connected — skip to payment
        CredentialsManager.accessToken = accessToken
        CredentialsManager.clientToken = clientToken

        startActivity(Intent(this, PaymentActivity::class.java))
        finish()
    }

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

        val intent = Intent(this, ConnectActivity::class.java).apply {
            putExtra("clientToken", clientToken)
        }
        startActivity(intent)
        finish()
    }

    override fun onCheckFailed(status: String, reason: String?) {
        // Handle terminal statuses (pending, unavailable, failed)
    }

    override fun onCheckError(code: String, message: String) {
        // Handle check errors
    }
}

Connect frame

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

Connect fragment

import org.json.JSONObject

interface ConnectFrameListener {
    fun onConnectComplete(accessToken: String, clientToken: String, expiresAt: String)
    fun onConnectFailed(status: String, reason: String?)
    fun onConnectError(code: String, message: String)
}

class MoonPayConnectFragment : MoonPayFrameFragment() {
    private lateinit var keyPair: MoonPayKeyPair
    private var clientToken: String? = null
    var listener: ConnectFrameListener? = null

    companion object {
        private const val ARG_CLIENT_TOKEN = "clientToken"

        fun newInstance(clientToken: String): MoonPayConnectFragment {
            return MoonPayConnectFragment().apply {
                arguments = Bundle().apply {
                    putString(ARG_CLIENT_TOKEN, clientToken)
                }
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        clientToken = arguments?.getString(ARG_CLIENT_TOKEN)
        keyPair = MoonPayCrypto.generateKeyPair()
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        loadFrame("/platform/v1/connect", mapOf(
            "clientToken" to clientToken!!,
            "publicKey" to keyPair.publicKeyHex,
            "channelId" to channelId
        ))
    }

    override fun onFrameMessage(kind: String, payload: JSONObject?) {
        when (kind) {
            "complete" -> handleComplete(payload)
            "error" -> {
                listener?.onConnectError(
                    payload?.optString("code") ?: "unknown",
                    payload?.optString("message") ?: "Unknown error"
                )
            }
        }
    }

    override fun onFrameHandshakeComplete() {
        // Handshake complete, waiting for customer interaction
    }

    private fun handleComplete(payload: JSONObject?) {
        val status = payload?.optString("status") ?: return

        when (status) {
            "active" -> {
                val credentials = payload.optString("credentials")
                val expiresAt = payload.optString("expiresAt")
                // Check payload.capabilities.ramps.requirements.paymentDisclosures
                // to determine if payment disclosures are required before transacting

                try {
                    val decryptedPayload = MoonPayDecryptor.decrypt(credentials, keyPair.privateKey)
                    val credentials = JSONObject(decryptedPayload)
                    val accessToken = credentials.getString("accessToken")
                    val clientToken = credentials.getString("clientToken")
                    listener?.onConnectComplete(accessToken, clientToken, expiresAt)
                } catch (e: Exception) {
                    listener?.onConnectError("decryption", "Failed to decrypt credentials")
                }
            }
            "pending", "unavailable", "failed" -> {
                listener?.onConnectFailed(status, payload.optString("reason"))
            }
        }
    }
}

Usage with Activity

class ConnectActivity : AppCompatActivity(), ConnectFrameListener {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_frame_container)

        // The clientToken is the anonymous token passed from the check frame's
        // `connectionRequired` response.
        val clientToken = intent.getStringExtra("clientToken")!!
        val fragment = MoonPayConnectFragment.newInstance(clientToken)
        fragment.listener = this

        supportFragmentManager.beginTransaction()
            .replace(R.id.frame_container, fragment)
            .commit()
    }

    // ConnectFrameListener implementation

    override fun onConnectComplete(accessToken: String, clientToken: String, expiresAt: String) {
        // Store credentials in memory
        CredentialsManager.accessToken = accessToken
        CredentialsManager.clientToken = clientToken

        Toast.makeText(this, "Connected!", Toast.LENGTH_SHORT).show()

        // Navigate to next screen
        startActivity(Intent(this, PaymentActivity::class.java))
        finish()
    }

    override fun onConnectFailed(status: String, reason: String?) {
        AlertDialog.Builder(this)
            .setTitle("Connection $status")
            .setMessage(reason ?: "Please try again later.")
            .setPositiveButton("OK") { _, _ -> finish() }
            .show()
    }

    override fun onConnectError(code: String, message: String) {
        AlertDialog.Builder(this)
            .setTitle("Error")
            .setMessage(message)
            .setPositiveButton("OK") { _, _ -> finish() }
            .show()
    }
}

Add Card frame

The add card frame lets a customer save a new card to their account. See add card frame reference for event details.

What you’ll need

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

Add Card fragment

import org.json.JSONObject

interface AddCardFrameListener {
    fun onAddCardComplete(cardId: String, brand: String, last4: String)
    fun onAddCardError(code: String, message: String)
}

class MoonPayAddCardFragment : MoonPayFrameFragment() {
    private var clientToken: String? = null
    var listener: AddCardFrameListener? = null

    companion object {
        private const val ARG_CLIENT_TOKEN = "clientToken"

        fun newInstance(clientToken: String): MoonPayAddCardFragment {
            return MoonPayAddCardFragment().apply {
                arguments = Bundle().apply {
                    putString(ARG_CLIENT_TOKEN, clientToken)
                }
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        clientToken = arguments?.getString(ARG_CLIENT_TOKEN)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        loadFrame("/platform/v1/add-card", mapOf(
            "clientToken" to clientToken!!,
            "channelId" to channelId
        ))
    }

    override fun onFrameMessage(kind: String, payload: JSONObject?) {
        when (kind) {
            "complete" -> {
                val card = payload?.optJSONObject("card")
                listener?.onAddCardComplete(
                    card?.optString("id") ?: "",
                    card?.optString("brand") ?: "",
                    card?.optString("last4") ?: ""
                )
            }
            "error" -> {
                listener?.onAddCardError(
                    payload?.optString("code") ?: "unknown",
                    payload?.optString("message") ?: "Unknown error"
                )
            }
        }
    }

    override fun onFrameHandshakeComplete() {
        // Handshake complete
    }
}

Usage with Activity

class SaveCardActivity : AppCompatActivity(), AddCardFrameListener {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_frame_container)

        val fragment = MoonPayAddCardFragment.newInstance(CredentialsManager.clientToken!!)
        fragment.listener = this

        supportFragmentManager.beginTransaction()
            .replace(R.id.frame_container, fragment)
            .commit()
    }

    override fun onAddCardComplete(cardId: String, brand: String, last4: String) {
        Toast.makeText(this, "Card saved: $brand •••• $last4", Toast.LENGTH_SHORT).show()
        // Proceed to payment with the saved card
        finish()
    }

    override fun onAddCardError(code: String, message: String) {
        AlertDialog.Builder(this)
            .setTitle("Error")
            .setMessage(message)
            .setPositiveButton("OK") { _, _ -> finish() }
            .show()
    }
}

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 fragment

import org.json.JSONObject

interface BuyFrameListener {
    fun onBuyComplete(transactionId: String, status: String)
    fun onBuyChallenge(url: String)
    fun onBuyError(code: String, message: String)
}

class MoonPayBuyFragment : MoonPayFrameFragment() {
    private var clientToken: String? = null
    private var quoteSignature: String? = null
    var listener: BuyFrameListener? = null

    companion object {
        private const val ARG_CLIENT_TOKEN = "clientToken"
        private const val ARG_QUOTE_SIGNATURE = "quoteSignature"

        fun newInstance(clientToken: String, quoteSignature: String): MoonPayBuyFragment {
            return MoonPayBuyFragment().apply {
                arguments = Bundle().apply {
                    putString(ARG_CLIENT_TOKEN, clientToken)
                    putString(ARG_QUOTE_SIGNATURE, quoteSignature)
                }
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        clientToken = arguments?.getString(ARG_CLIENT_TOKEN)
        quoteSignature = arguments?.getString(ARG_QUOTE_SIGNATURE)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        loadFrame("/platform/v1/buy", mapOf(
            "clientToken" to clientToken!!,
            "channelId" to channelId,
            "signature" to quoteSignature!!
        ))
    }

    fun updateQuote(signature: String) {
        quoteSignature = signature
        sendMessage("setQuote", JSONObject().put("quote", JSONObject().put("signature", signature)))
    }

    override fun onFrameMessage(kind: String, payload: JSONObject?) {
        when (kind) {
            "complete" -> {
                val transaction = payload?.optJSONObject("transaction")
                listener?.onBuyComplete(
                    transaction?.optString("id") ?: "",
                    transaction?.optString("status") ?: ""
                )
            }
            "challenge" -> {
                val url = payload?.optString("url") ?: ""
                listener?.onBuyChallenge(url)
            }
            "error" -> {
                listener?.onBuyError(
                    payload?.optString("code") ?: "unknown",
                    payload?.optString("message") ?: "Unknown error"
                )
            }
        }
    }

    override fun onFrameHandshakeComplete() {
        // Handshake complete
    }
}

Challenge handling

When the buy fragment receives a challenge URL, add a MoonPayChallengeFragment to present the 3-D Secure flow. On completion, cancellation, or error, remove both the challenge and buy fragments:
import org.json.JSONObject

interface ChallengeFrameListener {
    fun onChallengeComplete(transactionId: String, status: String)
    fun onChallengeCancelled()
    fun onChallengeError(code: String, message: String)
}

class MoonPayChallengeFragment : MoonPayFrameFragment() {
    private var challengeUrl: String? = null
    var listener: ChallengeFrameListener? = null

    companion object {
        private const val ARG_CHALLENGE_URL = "challengeUrl"

        fun newInstance(challengeUrl: String): MoonPayChallengeFragment {
            return MoonPayChallengeFragment().apply {
                arguments = Bundle().apply {
                    putString(ARG_CHALLENGE_URL, challengeUrl)
                }
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        challengeUrl = arguments?.getString(ARG_CHALLENGE_URL)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val urlWithChannel = Uri.parse(challengeUrl)
            .buildUpon()
            .appendQueryParameter("channelId", channelId)
            .toString()
        webView.loadUrl(urlWithChannel)
    }

    override fun onFrameMessage(kind: String, payload: JSONObject?) {
        when (kind) {
            "complete" -> {
                val transaction = payload?.optJSONObject("transaction")
                listener?.onChallengeComplete(
                    transaction?.optString("id") ?: "",
                    transaction?.optString("status") ?: ""
                )
            }
            "cancelled" -> {
                listener?.onChallengeCancelled()
            }
            "error" -> {
                listener?.onChallengeError(
                    payload?.optString("code") ?: "unknown",
                    payload?.optString("message") ?: "Unknown error"
                )
            }
        }
    }

    override fun onFrameHandshakeComplete() {
        // Handshake complete
    }
}

Usage with Activity

class BuyPaymentActivity : AppCompatActivity(), BuyFrameListener, ChallengeFrameListener {
    private var buyFragment: MoonPayBuyFragment? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_frame_container)

        val fragment = MoonPayBuyFragment.newInstance(
            clientToken = CredentialsManager.clientToken!!,
            quoteSignature = currentQuote.signature
        )
        fragment.listener = this

        supportFragmentManager.beginTransaction()
            .add(R.id.frame_container, fragment, "buy")
            .commit()

        buyFragment = fragment
    }

    // BuyFrameListener implementation

    override fun onBuyComplete(transactionId: String, status: String) {
        Toast.makeText(this, "Transaction $transactionId: $status", Toast.LENGTH_SHORT).show()
        // Navigate to transaction status screen or poll for updates
    }

    override fun onBuyChallenge(url: String) {
        val challengeFragment = MoonPayChallengeFragment.newInstance(url)
        challengeFragment.listener = this

        supportFragmentManager.beginTransaction()
            .add(R.id.frame_container, challengeFragment, "challenge")
            .commit()
    }

    override fun onBuyError(code: String, message: String) {
        AlertDialog.Builder(this)
            .setTitle("Error")
            .setMessage(message)
            .setPositiveButton("OK", null)
            .show()
    }

    // ChallengeFrameListener implementation

    override fun onChallengeComplete(transactionId: String, status: String) {
        removeFragments()
        Toast.makeText(this, "Transaction $transactionId: $status", Toast.LENGTH_SHORT).show()
        // Navigate to transaction status screen or poll for updates
    }

    override fun onChallengeCancelled() {
        removeFragments()
    }

    override fun onChallengeError(code: String, message: String) {
        removeFragments()
        AlertDialog.Builder(this)
            .setTitle("Error")
            .setMessage(message)
            .setPositiveButton("OK", null)
            .show()
    }

    private fun removeFragments() {
        val transaction = supportFragmentManager.beginTransaction()
        supportFragmentManager.findFragmentByTag("challenge")?.let { transaction.remove(it) }
        supportFragmentManager.findFragmentByTag("buy")?.let { transaction.remove(it) }
        transaction.commit()
    }
}

Widget frame

Apple Pay is not available on Android. Use the widget frame instead to render the full MoonPay buy experience — including payment-method selection (credit/debit card, Google Pay, bank transfers, and more) and transaction confirmation. See pay with widget for a full walkthrough.

What you’ll need

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

Widget fragment

import org.json.JSONObject

interface WidgetFrameListener {
    fun onWidgetReady()
    fun onWidgetTransactionCreated(transactionId: String, status: String)
    fun onWidgetComplete(transactionId: String, status: String)
    fun onWidgetFailed(failureReason: String)
    fun onWidgetError(code: String, message: String)
}

class MoonPayWidgetFragment : MoonPayFrameFragment() {
    private var clientToken: String? = null
    private var quoteSignature: String? = null
    var listener: WidgetFrameListener? = null

    companion object {
        private const val ARG_CLIENT_TOKEN = "clientToken"
        private const val ARG_QUOTE_SIGNATURE = "quoteSignature"

        fun newInstance(clientToken: String, quoteSignature: String): MoonPayWidgetFragment {
            return MoonPayWidgetFragment().apply {
                arguments = Bundle().apply {
                    putString(ARG_CLIENT_TOKEN, clientToken)
                    putString(ARG_QUOTE_SIGNATURE, quoteSignature)
                }
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        clientToken = arguments?.getString(ARG_CLIENT_TOKEN)
        quoteSignature = arguments?.getString(ARG_QUOTE_SIGNATURE)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        loadFrame("/platform/v1/widget", mapOf(
            "flow" to "buy",
            "clientToken" to clientToken!!,
            "quoteSignature" to quoteSignature!!,
            "channelId" to channelId
        ))
    }

    override fun onFrameMessage(kind: String, payload: JSONObject?) {
        when (kind) {
            "ready" -> {
                listener?.onWidgetReady()
            }
            "transactionCreated" -> {
                val transaction = payload?.optJSONObject("transaction")
                listener?.onWidgetTransactionCreated(
                    transaction?.optString("id") ?: "",
                    transaction?.optString("status") ?: ""
                )
            }
            "complete" -> handleComplete(payload)
            "error" -> {
                listener?.onWidgetError(
                    payload?.optString("code") ?: "unknown",
                    payload?.optString("message") ?: "Unknown error"
                )
            }
        }
    }

    override fun onFrameHandshakeComplete() {
        // Handshake complete, widget loading
    }

    private fun handleComplete(payload: JSONObject?) {
        val transaction = payload?.optJSONObject("transaction") ?: return
        val status = transaction.optString("status")

        if (status == "failed") {
            val reason = transaction.optString("failureReason", "Transaction failed")
            listener?.onWidgetFailed(reason)
        } else {
            val transactionId = transaction.optString("id")
            listener?.onWidgetComplete(transactionId, status)
        }
    }
}

Usage with Activity

class PaymentActivity : AppCompatActivity(), WidgetFrameListener {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_frame_container)

        val fragment = MoonPayWidgetFragment.newInstance(
            clientToken = CredentialsManager.clientToken!!,
            quoteSignature = currentQuote.signature
        )
        fragment.listener = this

        supportFragmentManager.beginTransaction()
            .replace(R.id.frame_container, fragment)
            .commit()
    }

    // WidgetFrameListener implementation

    override fun onWidgetReady() {
        // Widget is loaded and visible
    }

    override fun onWidgetTransactionCreated(transactionId: String, status: String) {
        // Transaction initiated — customer may need to complete 3-D Secure
        Toast.makeText(this, "Transaction created: $transactionId", Toast.LENGTH_SHORT).show()
    }

    override fun onWidgetComplete(transactionId: String, status: String) {
        Toast.makeText(this, "Transaction $transactionId: $status", Toast.LENGTH_SHORT).show()
        // Navigate to transaction status screen or poll for updates
    }

    override fun onWidgetFailed(failureReason: String) {
        AlertDialog.Builder(this)
            .setTitle("Payment Failed")
            .setMessage(failureReason)
            .setPositiveButton("OK", null)
            .show()
    }

    override fun onWidgetError(code: String, message: String) {
        AlertDialog.Builder(this)
            .setTitle("Error")
            .setMessage(message)
            .setPositiveButton("OK", null)
            .show()
    }
}