# Using the Platform API
Source: https://dev.moonpay.com/api-reference/platform/documentation/using-the-api
Get started with the MoonPay Platform API
Looking for the widget API? Take a look at the [reference
here](/api-reference/widget).
## Base URL
```bash theme={null}
https://api.moonpay.com
```
Test mode vs live mode is determined by the API key you use, not the URL. Use
test API key (`sk_test_...`) for test mode and live API keys (`sk_live_...`)
for production.
## Authentication
Authentication depends on whether you’re calling an endpoint from your server or from the client.
### Server-side Authentication
For server-side requests, send your [secret key](/platform/guides/api-and-sdk-credentials#secret-key) in the `Authorization` header.
```ts fetch theme={null}
const URL = "https://api.moonpay.com/platform/v1/sessions";
const res = await fetch(URL, {
headers: {
"Content-Type": "application/json",
Authorization: "Api-Key sk_test_123",
},
method: "POST",
body: JSON.stringify({
externalCustomerId: "your_user_id",
deviceIp: "203.0.113.1",
}),
});
```
```sh curl theme={null}
curl -X POST "https://api.moonpay.com/platform/v1/sessions" \
-H "Content-Type: application/json" \
-H "Authorization: Api-Key sk_test_123" \
-d '{
"externalCustomerId": "customer1",
"deviceIp": "203.0.113.1"
}'
```
### Client-side Authentication
For client-side API requests, use the [`accessToken`](/platform/guides/api-and-sdk-credentials#access-token) returned from a connection as a [Bearer token](https://swagger.io/docs/specification/v3_0/authentication/bearer-authentication/).
```ts fetch theme={null}
await fetch("https://api.moonpay.com/platform/v1/quotes", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
// ... request body ...
}),
});
```
## Response Format
Responses are returned as `JSON` with the `content-type: application/json` header.
## Pagination
Some requests, like [listing transactions](/api-reference/platform/endpoints/transactions/list), return paginated results using cursor-based pagination. Each response includes a cursor string that you pass to the next request to fetch the next page.
The response includes a `pageInfo` object. If `pageInfo.nextCursor` is `null`, there are no more pages. For example:
```json theme={null}
{
"data": [],
"pageInfo": {
"nextCursor": "tr_123e4567-e89b-12d3-a456-426614174000"
}
}
```
## Rate limits
Currently, requests for this integration are limited to 30 per second.
## Debugging
Each API response includes a request ID header. Use this ID when working with support:
```bash Example theme={null}
X-Request-Id: some-value
```
## Error Handling
When a request fails (4xx), the API returns an error object with details:
```json Example error response theme={null}
{
"code": 400,
"type": "Invalid request",
"message": "Invalid request. sourceAmount must be greater than 0."
}
```
## OpenAPI
The API follows the [OpenAPI 3.1](https://swagger.io/specification/) specification. You can use the spec to generate typed clients for any language.
See the [OpenAPI Spec](/platform/guides/openapi-codegen) page for the full specification and code generation instructions.
# Delete a payment method
Source: https://dev.moonpay.com/api-reference/platform/endpoints/payment-methods/delete
DELETE /platform/v1/payment-methods/{paymentMethodId}
Remove a stored payment method for a customer
# List payment methods
Source: https://dev.moonpay.com/api-reference/platform/endpoints/payment-methods/list
GET /platform/v1/payment-methods
Get available payment method configurations for a user
# Get a quote
Source: https://dev.moonpay.com/api-reference/platform/endpoints/quotes/get
POST /platform/v1/quotes/buy
Build quotes for fiat->crypto transactions
# Create a session
Source: https://dev.moonpay.com/api-reference/platform/endpoints/sessions/create
POST /platform/v1/sessions
Create a session token to initialize a connection
# Revoke a session
Source: https://dev.moonpay.com/api-reference/platform/endpoints/sessions/revoke
DELETE /platform/v1/sessions
Revoke an active session token
# Get a transaction
Source: https://dev.moonpay.com/api-reference/platform/endpoints/transactions/get
GET /platform/v1/transactions/{id}
Get details for a single transaction by ID
# List transactions
Source: https://dev.moonpay.com/api-reference/platform/endpoints/transactions/list
GET /platform/v1/transactions
List transactions for the connected user with optional date filtering and pagination
# Asset
Source: https://dev.moonpay.com/api-reference/platform/objects-and-types/asset
A fiat currency or crypto token
## Properties
| Property | Type | Required | Description |
| ----------- | ------- | -------- | ------------------------------------------------------------- |
| `code` | string | Yes | The currency code or token symbol (e.g., `USD`, `ETH`, `BTC`) |
| `name` | string | No | The human-readable name (e.g., `US Dollar`, `Ethereum`) |
| `precision` | integer | No | The number of supported decimal places |
## Example
```json theme={null}
{
"code": "ETH",
"name": "Ethereum",
"precision": 18
}
```
## Common Assets
### Fiat Currencies
| Code | Name | Precision |
| ----- | ------------- | --------- |
| `USD` | US Dollar | 2 |
| `EUR` | Euro | 2 |
| `GBP` | British Pound | 2 |
### Cryptocurrencies
| Code | Name | Precision |
| ------ | -------- | --------- |
| `BTC` | Bitcoin | 8 |
| `ETH` | Ethereum | 18 |
| `USDC` | USD Coin | 6 |
# Fees
Source: https://dev.moonpay.com/api-reference/platform/objects-and-types/fees
Fee breakdown for an operation
## Properties
| Property | Type | Required | Description |
| ----------- | ------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------- |
| `network` | [MonetaryAmount](/api-reference/platform/objects-and-types/fees#monetaryamount) | No | The network fee (e.g., gas fee for blockchain transactions) |
| `moonpay` | [MonetaryAmount](/api-reference/platform/objects-and-types/fees#monetaryamount) | No | The MoonPay processing fee |
| `ecosystem` | [MonetaryAmount](/api-reference/platform/objects-and-types/fees#monetaryamount) | No | The ecosystem fee, if applicable |
| `partner` | [MonetaryAmount](/api-reference/platform/objects-and-types/fees#monetaryamount) | No | **Deprecated.** Use `ecosystem` instead. This field will be removed in a future release. The partner's fee, if applicable |
## MonetaryAmount
Each fee is represented as a monetary amount:
| Property | Type | Required | Description |
| -------------- | ------ | -------- | ---------------------------------------------------- |
| `amount` | string | Yes | The numeric amount as a string to preserve precision |
| `currencyCode` | string | Yes | The currency code (e.g., `USD`) |
## Example
```json theme={null}
{
"network": {
"amount": "2.50",
"currencyCode": "USD"
},
"moonpay": {
"amount": "3.99",
"currencyCode": "USD"
},
"ecosystem": {
"amount": "1.00",
"currencyCode": "USD"
},
"partner": {
"amount": "1.00",
"currencyCode": "USD"
}
}
```
All fee amounts are strings to preserve decimal precision. Parse them
appropriately in your application.
# Payment Method
Source: https://dev.moonpay.com/api-reference/platform/objects-and-types/payment-method
A payment method configuration with its capabilities and availability
## Properties
| Property | Type | Required | Description |
| -------------- | ------ | -------- | -------------------------------------------------- |
| `type` | string | Yes | Payment method type (e.g., `apple_pay`) |
| `capabilities` | object | Yes | What this payment method supports |
| `availability` | object | Yes | Whether this payment method is currently available |
## Capabilities
| Property | Type | Required | Description |
| --------------------------- | --------- | -------- | ----------------------------------------------------- |
| `supportedCurrencies` | string\[] | Yes | Currencies supported (e.g., `["USD", "EUR"]`) |
| `supportedTransactionTypes` | string\[] | Yes | Transaction types supported (e.g., `["buy", "sell"]`) |
## Availability
| Property | Type | Required | Description |
| ------------------- | ------- | -------- | ------------------------------------------------- |
| `active` | boolean | Yes | Whether the payment method is currently available |
| `unavailableReason` | string | No | Reason for unavailability, if applicable |
## Payment Method Types
| Type | Description |
| ----------- | ----------- |
| `apple_pay` | Apple Pay |
## Example
```json theme={null}
{
"type": "apple_pay",
"capabilities": {
"supportedCurrencies": ["USD", "EUR", "GBP"],
"supportedTransactionTypes": ["buy"]
},
"availability": {
"active": true
}
}
```
## Checking Availability
Always check `availability.active` before offering a payment method to users. If `active` is `false`, check `unavailableReason` for details:
```json theme={null}
{
"type": "apple_pay",
"capabilities": {
"supportedCurrencies": ["USD"],
"supportedTransactionTypes": ["buy"]
},
"availability": {
"active": false,
"unavailableReason": "Payment method is in maintenance"
}
}
```
# Quote
Source: https://dev.moonpay.com/api-reference/platform/objects-and-types/quote
A quote for a buy transaction with locked rate, fees, and expiry
## Properties
| Property | Type | Required | Description |
| --------------- | ------------------------------------------------------------------ | -------- | ------------------------------------------------------------------- |
| `source` | object | Yes | Source amount and asset (fiat currency) you send |
| `destination` | object | Yes | Destination amount and asset (cryptocurrency) the customer receives |
| `fees` | [Fees](/api-reference/platform/objects-and-types/fees) | Yes | Breakdown of network, MoonPay, and ecosystem fees |
| `wallet` | [Wallet](/api-reference/platform/objects-and-types/wallet) \| null | Yes | Wallet address where crypto will be sent. Null if not yet provided |
| `paymentMethod` | object \| null | Yes | Payment method used for this quote. Null if not specified |
| `expiresAt` | string (date-time) | Yes | ISO 8601 datetime when the quote expires |
| `executable` | boolean | Yes | Whether the quote can be executed |
| `signature` | string | Yes | Signature for mounting a payment frame |
## Source / Destination
Both `source` and `destination` have the same structure:
| Property | Type | Required | Description |
| -------- | -------------------------------------------------------- | -------- | -------------------------------------------- |
| `amount` | string | Yes | The amount as a string to preserve precision |
| `asset` | [Asset](/api-reference/platform/objects-and-types/asset) | Yes | The currency or token |
## Using the Quote
Pass the `signature` to the payment frame when you mount it. The frame uses this signature along with the quote data to execute the payment and create the transaction.
Quotes expire. Check `expiresAt` and create the transaction before this time.
If `executable` is `false`, the customer must provide additional information
before you can use this quote.
## Example
```json theme={null}
{
"source": {
"amount": "100.00",
"asset": {
"code": "USD",
"name": "US Dollar",
"precision": 2
}
},
"destination": {
"amount": "0.0025",
"asset": {
"code": "ETH",
"name": "Ethereum",
"precision": 18
}
},
"fees": {
"network": {
"amount": "2.50",
"currencyCode": "USD"
},
"moonpay": {
"amount": "3.99",
"currencyCode": "USD"
}
},
"wallet": {
"address": "0x1234...abcd"
},
"paymentMethod": {
"type": "apple_pay"
},
"expiresAt": "2026-01-29T14:35:50.000Z",
"executable": true,
"signature": "eyJhbGciOiJIUzI1NiIs..."
}
```
# Transaction
Source: https://dev.moonpay.com/api-reference/platform/objects-and-types/transaction
A transaction representing a crypto purchase or sale
## Properties
| Property | Type | Required | Description |
| --------------- | ---------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------- |
| `id` | string | Yes | The MoonPay ID of the transaction |
| `createdAt` | string (date-time) | Yes | [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) timestamp of when the transaction was created |
| `updatedAt` | string (date-time) | Yes | [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) timestamp of when the transaction was last updated |
| `status` | string | Yes | The current status: `completed`, `failed`, or `pending` |
| `source` | object | Yes | The source amount and asset (fiat currency) |
| `destination` | object | Yes | The destination amount and asset (cryptocurrency) |
| `fees` | [Fees](/api-reference/platform/objects-and-types/fees) | Yes | Fee breakdown for the transaction |
| `wallet` | [Wallet](/api-reference/platform/objects-and-types/wallet) | Yes | The wallet where crypto was delivered |
| `customer` | object | Yes | The customer who made the transaction |
| `paymentMethod` | object | No | The payment method used |
| `stages` | array | No | The stages of the transaction lifecycle |
## Source / Destination
Both `source` and `destination` have the same structure:
| Property | Type | Required | Description |
| -------- | -------------------------------------------------------- | -------- | -------------------------------------------- |
| `amount` | string | Yes | The amount as a string to preserve precision |
| `asset` | [Asset](/api-reference/platform/objects-and-types/asset) | Yes | The currency or token |
## Customer
| Property | Type | Required | Description |
| -------- | ------ | -------- | ------------------------------ |
| `id` | string | Yes | The MoonPay ID of the customer |
## Transaction Status
| Value | Description |
| ----------- | ---------------------------------- |
| `pending` | Transaction is in progress |
| `completed` | Transaction completed successfully |
| `failed` | Transaction failed |
## Example
```json theme={null}
{
"id": "tr_abc123",
"createdAt": "2026-01-29T14:30:50.000Z",
"updatedAt": "2026-01-29T15:30:50.000Z",
"status": "completed",
"source": {
"amount": "100.00",
"asset": {
"code": "USD"
}
},
"destination": {
"amount": "0.0025",
"asset": {
"code": "ETH"
}
},
"fees": {
"network": {
"amount": "2.50",
"currencyCode": "USD"
},
"moonpay": {
"amount": "3.99",
"currencyCode": "USD"
}
},
"wallet": {
"address": "0x1234...abcd"
},
"customer": {
"id": "cust_xyz789"
},
"paymentMethod": {
"type": "apple_pay"
}
}
```
# Wallet
Source: https://dev.moonpay.com/api-reference/platform/objects-and-types/wallet
A blockchain wallet address
## Properties
| Property | Type | Required | Description |
| --------- | ------ | -------- | ---------------------------------------------------------------------------- |
| `address` | string | Yes | The wallet address |
| `tag` | string | No | An optional memo or destination tag (used by some blockchains like XRP, XLM) |
## Example
### Standard Wallet
```json theme={null}
{
"address": "0x1234567890abcdef1234567890abcdef12345678"
}
```
### Wallet with Memo/Tag
Some blockchains like XRP and XLM require a destination tag or memo to route funds to the correct account:
```json theme={null}
{
"address": "rN7n3473SaZBCG4dFL83w7a1RXtXtbk2D9",
"tag": "12345678"
}
```
For blockchains that require tags/memos (XRP, XLM, etc.), always include the
`tag` field when provided. Missing tags can result in lost funds.
# Widget API Reference
Source: https://dev.moonpay.com/api-reference/widget
API reference for the MoonPay widget integration.
The MoonPay widget integration is powered by two APIs:
* **Ramps & Swaps** — buy, sell, and swap quotes; transaction lookups;
supported countries, currencies, and payment methods.
* **Virtual Accounts** — programmatic on-ramp and off-ramp via virtual
bank accounts, plus the associated transaction history.
Buy quotes, currency limits, network fees, and transaction lookups.
Sell quotes and sell transaction lookups.
Swap pairs, quotes, execution, and transaction lookups.
Supported countries, currencies, payment methods, and IP-address checks.
Token data and token lists for DeFi assets.
Programmatic on-ramp and off-ramp via virtual bank accounts.
Receive asynchronous notifications when transaction status changes.
## Authentication
Most endpoints require an API key. Pass it as the `apiKey` query parameter,
or use the `Authorization: Api-Key ` header where indicated.
## Base URL
```
https://api.moonpay.com
```
# Cancel Sell transaction
Source: https://dev.moonpay.com/api-reference/widget/cancelselltransaction
DELETE /v3/sell_transactions/{transactionId})
# Execute Swap quote
Source: https://dev.moonpay.com/api-reference/widget/executeswapquote
POST /v4/swap/execute_quote
Executes a given swap quote. Refer to our Recipes section for more detailed examples.
# Get Real-time Buy quote
Source: https://dev.moonpay.com/api-reference/widget/getbuyquote
GET /v3/currencies/{currencyCode}/buy_quote
Get detailed real-time quote based on the provided currency code, base amount, your extra fee percentage, payment method, and the inclusion of the fees.
# Get Buy transaction
Source: https://dev.moonpay.com/api-reference/widget/getbuytransaction
GET /v1/transactions/{transactionId}
Retrieve a transaction by id. This call will return an error if no transaction with the supplied identifier exists.
# Get Buy transaction by External identifier
Source: https://dev.moonpay.com/api-reference/widget/getbuytransactionbyexternalid
GET /v1/transactions/ext/{externalTransactionId}
Retrieve a transaction by its externalTransactionId. This is the identifier you assigned the transaction when creating it. This endpoint returns an array of objects because we cannot ensure the uniqueness of externalTransactionId.
# List Buy transactions
Source: https://dev.moonpay.com/api-reference/widget/getbuytransactions
GET /v1/transactions
Returns an array of successful Buy transactions which fulfill criteria supplied in the query parameters. Each entry in the array is a separate transaction object. Transactions will be listed from newest to oldest.
# List supported countries
Source: https://dev.moonpay.com/api-reference/widget/getcountries
GET /v3/countries
Returns the list of countries currently supported by MoonPay.
# List supported currencies
Source: https://dev.moonpay.com/api-reference/widget/getcurrencies
GET /v3/currencies
Returns the list of currencies supported by MoonPay.
# Get Crypto Currency limits
Source: https://dev.moonpay.com/api-reference/widget/getcurrencylimits
GET /v3/currencies/{currencyCode}/limits
Returns an object containing minimum and maximum buy amounts including or excluding fees for base and quote currencies.
It takes into account the payment method if it's provided, **otherwise it defaults to the payment method with the lowest fees.**
# Get customer
Source: https://dev.moonpay.com/api-reference/widget/getcustomer
GET /v1/customers/{customerId}
Returns very basic information about a customer based on their MoonPay ID. For you to be able to retrieve a customer, they must have at least one session initiated with your `Api-Key`.
# Get customer by externalId
Source: https://dev.moonpay.com/api-reference/widget/getcustomerbyexternalid
GET /v1/customers/ext/{customerId}
Returns very basic information about a customer based on their external customer ID. For you to be able to retrieve a customer, they must have at least one session initiated with your `API-Key`. Please note that this endpoint returns an array of objects because we cannot ensure the uniqueness of the external customer ID.
# Get DeFi token
Source: https://dev.moonpay.com/api-reference/widget/getdefitoken
GET /v1/defi/token
Retrieve defi token for a specific contractAddress and network code
# List DeFi tokens
Source: https://dev.moonpay.com/api-reference/widget/getdefitokens
GET /v1/defi/tokens
Search and retrieve a paginated list of defi tokens
# Check Customer's IP address
Source: https://dev.moonpay.com/api-reference/widget/getipaddress
GET /v3/ip_address
Returns information about an IP address. If the `isAllowed` flag is set to false, it means that MoonPay accepts citizens of this country but not residents.
# Get Crypto network fees
Source: https://dev.moonpay.com/api-reference/widget/getnetworkfees
GET /v3/currencies/network_fees
Returns a set of key-value pairs representing the current network fees of cryptocurrencies against fiat currencies.
Supply the codes of the crypto and fiat currencies you are interested in, and MoonPay will return the relevant network fees.
# Get off ramp transaction
Source: https://dev.moonpay.com/api-reference/widget/getofframptransaction
GET /v1/virtual-accounts/transactions/offramp/{transactionId}
# Get off ramp transactions
Source: https://dev.moonpay.com/api-reference/widget/getofframptransactions
GET /v1/virtual-accounts/transactions/offramp
# Get on ramp transaction
Source: https://dev.moonpay.com/api-reference/widget/getonramptransaction
GET /v1/virtual-accounts/transactions/onramp/{transactionId}
# Get on ramp transactions
Source: https://dev.moonpay.com/api-reference/widget/getonramptransactions
GET /v1/virtual-accounts/transactions/onramp
# Get Sell quote
Source: https://dev.moonpay.com/api-reference/widget/getsellquote
GET /v3/currencies/{currencyCode}/sell_quote
Returns a set of key-value pairs representing a real-time sell quote for a currency. Supply the currency code, the base amount, your extra fee percentage, the payment method and whether the base amount is inclusive of fees, and MoonPay will return a detailed sell quote.
# Get Sell transaction
Source: https://dev.moonpay.com/api-reference/widget/getselltransaction
GET /v3/sell_transactions/{transactionId}
# Get Sell transaction by External identifier
Source: https://dev.moonpay.com/api-reference/widget/getselltransactionbyexternalid
GET /v3/sell_transactions/ext/{externalTransactionId}
Retrieve a transaction by its externalTransactionId. This is the identifier you assigned the transaction when creating it. This endpoint returns an array of objects because we cannot ensure the uniqueness of externalTransactionId.
# List Sell transactions
Source: https://dev.moonpay.com/api-reference/widget/getselltransactions
GET /v1/sell_transactions
# Get Swap pairs
Source: https://dev.moonpay.com/api-reference/widget/getswappairs
GET /v4/swap/pairs
Returns the list of swap pairs available to the account.
# Get Swap quote
Source: https://dev.moonpay.com/api-reference/widget/getswapquote
GET /v4/swap/{PAIR}/quote
Returns the swap quote for a specific swap pair.
# Get Swap requote
Source: https://dev.moonpay.com/api-reference/widget/getswaprequote
GET /v4/swap/transaction/{transactionId}/requote
Returns a swap transaction re quote by id. Refer to our Recipes section for more detailed examples.
# Get Swap transaction
Source: https://dev.moonpay.com/api-reference/widget/getswaptransaction
GET /v4/swap/transaction/{transactionId}
Returns a swap transaction by id. Refer to our Recipes section for more detailed examples.
# Get virtual accounts
Source: https://dev.moonpay.com/api-reference/widget/getvirtualaccounts
GET /v1/virtual-accounts
# List available payment methods
Source: https://dev.moonpay.com/api-reference/widget/listpaymentmethods
GET /payments/v1/payment-method-config
# Reject Swap requote
Source: https://dev.moonpay.com/api-reference/widget/rejectswaprequote
POST /v4/swap/reject_requote
Rejects a given swap requote. Refer to our Recipes section for more detailed examples.
# Update on ramp virtual account
Source: https://dev.moonpay.com/api-reference/widget/updateonrampvirtualaccount
PATCH /v1/virtual-accounts/onramp/{id}
# Buy
Source: https://dev.moonpay.com/api-reference/widget/webhooks/buy
Currently supported Buy events
## Supported events
`transaction_created` `transaction_failed` `transaction_updated`
Please note that duplicate events MAY occur and in some occasions, may arrive out of order. We ask partners to de-dupe these events. This is a process of removing identical webhook responses.
A complete list of transaction object parameters and values can be found [here](/api-reference/widget/getbuytransaction).
## Examples
```json transaction_created theme={null}
{
"data": {
"isFromQuote": true,
"isRecurring": false,
"isTicketPayment": false,
"id": "bda09e91-559f-4e7a-807a-cdec1a903d9d",
"createdAt": "2022-08-31T10:00:03.640Z",
"updatedAt": "2022-08-31T10:00:31.251Z",
"baseCurrencyAmount": 295.45,
"quoteCurrencyAmount": 0.1819,
"feeAmount": 3.99,
"extraFeeAmount": 0,
"networkFeeAmount": 0.56,
"areFeesIncluded": true,
"flow": "principal",
"status": "completed",
"walletAddress": "0xc216eD2D6c295579718dbd4a797845CdA70B3C36",
"walletAddressTag": null,
"cryptoTransactionId": "0x6751c8fce2e0fb5d57bb4801b31b35a7160fa362e0c5703d44cfd508317ee2f8",
"failureReason": null,
"redirectUrl": "",
"returnUrl": "",
"widgetRedirectUrl": null,
"bankTransferReference": null,
"baseCurrencyId": "71435a8d-211c-4664-a59e-2a5361a6c5a7",
"currencyId": "8d305f63-1fd7-4e01-a220-8445e591aec4",
"customerId": "2fcbcea3-6b62-49f1-b9d1-026d9bfd00b6",
"cardId": "de063279-203f-4e31-83c3-e51aa844d8c4",
"bankAccountId": null,
"eurRate": 1,
"usdRate": 0.99812,
"gbpRate": 0.85823,
"bankDepositInformation": null,
"externalTransactionId": null,
"feeAmountDiscount": null,
"paymentMethod": "credit_debit_card",
"baseCurrency": {
"id": "71435a8d-211c-4664-a59e-2a5361a6c5a7",
"createdAt": "2019-04-22T15:12:07.861Z",
"updatedAt": "2022-08-16T13:42:26.618Z",
"type": "fiat",
"name": "Euro",
"code": "eur",
"precision": 2,
"maxAmount": 10000,
"minAmount": 30,
"minBuyAmount": 30,
"maxBuyAmount": 10000
},
"currency": {
"id": "8d305f63-1fd7-4e01-a220-8445e591aec4",
"createdAt": "2018-09-28T10:47:49.801Z",
"updatedAt": "2022-08-25T16:57:25.823Z",
"type": "crypto",
"name": "Ethereum",
"code": "eth",
"precision": 4,
"maxAmount": 3,
"minAmount": 0.01,
"minBuyAmount": 0.01078,
"maxBuyAmount": null,
"addressRegex": "^[0x](0-9A-Fa-f){40}$",
"testnetAddressRegex": "^[0x](0-9A-Fa-f){40}$",
"supportsAddressTag": false,
"addressTagRegex": null,
"supportsTestMode": true,
"supportsLiveMode": true,
"isSuspended": false,
"isSupportedInUS": true,
"notAllowedUSStates": ["HI", "NY", "VI"],
"notAllowedCountries": [],
"isSellSupported": true,
"confirmationsRequired": 12,
"minSellAmount": 0.025,
"maxSellAmount": 8.5,
"metadata": {
"contractAddress": "0x0000000000000000000000000000000000000000",
"chainId": "1",
"networkCode": "ethereum"
}
},
"nftTransaction": null,
"stages": [
{
"stage": "stage_one_ordering",
"status": "success",
"actions": [],
"failureReason": null
},
{
"stage": "stage_two_verification",
"status": "success",
"actions": [],
"failureReason": null
},
{
"stage": "stage_three_processing",
"status": "failed",
"actions": [],
"failureReason": "error"
},
{
"stage": "stage_four_delivery",
"status": "not_started",
"actions": [],
"failureReason": null
}
],
"country": "USA",
"state": "NJ",
"cardType": "card",
"cardPaymentType": "debit",
"externalCustomerId": "27346528354888",
"nftToken": null
},
"type": "transaction_created",
"externalCustomerId": "27346528354888"
}
```
```json transaction_updated theme={null}
{
"data": {
"isFromQuote": true,
"isRecurring": false,
"isTicketPayment": false,
"id": "bda09e91-559f-4e7a-807a-cdec1a903d9d",
"createdAt": "2022-08-31T10:00:03.640Z",
"updatedAt": "2022-08-31T10:00:31.251Z",
"baseCurrencyAmount": 295.45,
"quoteCurrencyAmount": 0.1819,
"feeAmount": 3.99,
"extraFeeAmount": 0,
"networkFeeAmount": 0.56,
"areFeesIncluded": true,
"flow": "principal",
"status": "completed",
"walletAddress": "0xc216eD2D6c295579718dbd4a797845CdA70B3C36",
"walletAddressTag": null,
"cryptoTransactionId": "0x6751c8fce2e0fb5d57bb4801b31b35a7160fa362e0c5703d44cfd508317ee2f8",
"failureReason": null,
"redirectUrl": "https://api.moonpay.io/v3/three_d_secure?transactionId=bda09e91-559f-4e7a-807a-cdec1a903d9d&sid=6b3652b0-088c-444f-b706-affcdc1ca4db&iframe=true",
"returnUrl": "https://buy-sandbox.moonpay.com/transaction_receipt",
"widgetRedirectUrl": null,
"bankTransferReference": null,
"baseCurrencyId": "71435a8d-211c-4664-a59e-2a5361a6c5a7",
"currencyId": "8d305f63-1fd7-4e01-a220-8445e591aec4",
"customerId": "2fcbcea3-6b62-49f1-b9d1-026d9bfd00b6",
"cardId": "de063279-203f-4e31-83c3-e51aa844d8c4",
"bankAccountId": null,
"eurRate": 1,
"usdRate": 0.99812,
"gbpRate": 0.85823,
"bankDepositInformation": null,
"externalTransactionId": null,
"feeAmountDiscount": null,
"paymentMethod": "credit_debit_card",
"baseCurrency": {
"id": "71435a8d-211c-4664-a59e-2a5361a6c5a7",
"createdAt": "2019-04-22T15:12:07.861Z",
"updatedAt": "2022-08-16T13:42:26.618Z",
"type": "fiat",
"name": "Euro",
"code": "eur",
"precision": 2,
"maxAmount": 10000,
"minAmount": 30,
"minBuyAmount": 30,
"maxBuyAmount": 10000
},
"currency": {
"id": "8d305f63-1fd7-4e01-a220-8445e591aec4",
"createdAt": "2018-09-28T10:47:49.801Z",
"updatedAt": "2022-08-25T16:57:25.823Z",
"type": "crypto",
"name": "Ethereum",
"code": "eth",
"precision": 4,
"maxAmount": 3,
"minAmount": 0.01,
"minBuyAmount": 0.01078,
"maxBuyAmount": null,
"addressRegex": "^(0x)[0-9A-Fa-f]{40}$",
"testnetAddressRegex": "^(0x)[0-9A-Fa-f]{40}$",
"supportsAddressTag": false,
"addressTagRegex": null,
"supportsTestMode": true,
"supportsLiveMode": true,
"isSuspended": false,
"isSupportedInUS": true,
"notAllowedUSStates": ["HI", "NY", "VI"],
"notAllowedCountries": [],
"isSellSupported": true,
"confirmationsRequired": 12,
"minSellAmount": 0.025,
"maxSellAmount": 8.5,
"metadata": {
"contractAddress": "0x0000000000000000000000000000000000000000",
"chainId": "1",
"networkCode": "ethereum"
}
},
"nftTransaction": null,
"country": "USA",
"state": "NJ",
"externalCustomerId": "27346528354888",
"nftToken": null
},
"type": "transaction_updated",
"externalCustomerId": "27346528354888"
}
```
```json transaction_failed theme={null}
{
"data": {
"isFromQuote": true,
"isRecurring": false,
"isTicketPayment": false,
"id": "621d21ce-13cc-4e95-af0d-771ae156f92a",
"createdAt": "2022-09-13T10:23:26.751Z",
"updatedAt": "2022-09-13T10:23:37.505Z",
"baseCurrencyAmount": 25.74,
"quoteCurrencyAmount": 0.0144,
"feeAmount": 3.99,
"extraFeeAmount": 0,
"networkFeeAmount": 0.27,
"areFeesIncluded": true,
"flow": "principal",
"status": "failed",
"walletAddress": "0x00BDBFC6B0584771c28B9092c16AEB31Ad677283",
"walletAddressTag": null,
"cryptoTransactionId": null,
"failureReason": "Failed testnet withdrawal",
"redirectUrl": "https://api.moonpay.io/v3/payment?transactionId=621d21ce-13cc-4e95-af0d-771ae156f92a&sid=4ed76ab0-65e1-4624-b0d9-9fce62e41f0b",
"returnUrl": "https://buy-sandbox.moonpay.com/transaction_receipt",
"widgetRedirectUrl": "https://www.myurl.com",
"bankTransferReference": null,
"baseCurrencyId": "edd81f1f-f735-4692-b410-6def107f17d2",
"currencyId": "8d305f63-1fd7-4e01-a220-8445e591aec4",
"customerId": "2fcbcea3-6b62-49f1-b9d1-026d9bfd00b6",
"cardId": "de063279-203f-4e31-83c3-e51aa844d8c4",
"bankAccountId": null,
"eurRate": 0.98363,
"usdRate": 1,
"gbpRate": 0.8542,
"bankDepositInformation": null,
"externalTransactionId": null,
"feeAmountDiscount": null,
"paymentMethod": "credit_debit_card",
"baseCurrency": {
"id": "edd81f1f-f735-4692-b410-6def107f17d2",
"createdAt": "2019-04-29T16:55:28.647Z",
"updatedAt": "2022-08-16T13:42:26.618Z",
"type": "fiat",
"name": "US Dollar",
"code": "usd",
"precision": 2,
"maxAmount": 12000,
"minAmount": 30,
"minBuyAmount": 30,
"maxBuyAmount": 12000
},
"currency": {
"id": "8d305f63-1fd7-4e01-a220-8445e591aec4",
"createdAt": "2018-09-28T10:47:49.801Z",
"updatedAt": "2022-08-25T16:57:25.823Z",
"type": "crypto",
"name": "Ethereum",
"code": "eth",
"precision": 4,
"maxAmount": 3,
"minAmount": 0.01,
"minBuyAmount": 0.01078,
"maxBuyAmount": null,
"addressRegex": "^(0x)[0-9A-Fa-f]{40}$",
"testnetAddressRegex": "^(0x)[0-9A-Fa-f]{40}$",
"supportsAddressTag": false,
"addressTagRegex": null,
"supportsTestMode": true,
"supportsLiveMode": true,
"isSuspended": false,
"isSupportedInUS": true,
"notAllowedUSStates": ["HI", "NY", "RI", "VI"],
"notAllowedCountries": [],
"isSellSupported": true,
"confirmationsRequired": 12,
"minSellAmount": 0.025,
"maxSellAmount": 8.5,
"metadata": {
"contractAddress": "0x0000000000000000000000000000000000000000",
"chainId": "1",
"networkCode": "ethereum"
}
},
"nftTransaction": null,
"stages": [
{
"stage": "stage_one_ordering",
"status": "success",
"actions": [],
"failureReason": null
},
{
"stage": "stage_two_verification",
"status": "success",
"actions": [],
"failureReason": null
},
{
"stage": "stage_three_processing",
"status": "failed",
"actions": [],
"failureReason": "error"
},
{
"stage": "stage_four_delivery",
"status": "not_started",
"actions": [],
"failureReason": null
}
],
"country": "USA",
"state": "NJ",
"cardType": "card",
"cardPaymentType": "debit",
"externalCustomerId": "27346528354888",
"nftToken": null
},
"type": "transaction_failed",
"externalCustomerId": "27346528354888"
}
```
## Properties
```json theme={null}
{
"event": {
"method": "POST",
"headers": {
"moonpay-signature": "t=1663064622,s=cdd18ef9d85c004638f0e9f770231909d24053b503a8f281991e33280a7a9ba9",
"moonpay-signature-v2": "t=1663064622,s=44d8318c5ad1720959799062280f5f0030658776c13804d9edd04fd2c4012f14"
}
}
}
```
# Cancel Sell transaction by externalId
Source: https://dev.moonpay.com/api-reference/widget/webhooks/cancelselltransactionbyexternalid
DELETE /v3/sell_transactions/ext/{transactionId})
# Overview
Source: https://dev.moonpay.com/api-reference/widget/webhooks/overview
## When to utilize Webhooks
Webhooks are essential for managing behind-the-scenes transactions. They allow you to receive alerts for asynchronous updates to transaction statuses. MoonPay can send webhook events that notify your application whenever an activity occurs on your account. This feature is particularly valuable for tracking changes like transaction status updates, that are not triggered by a direct API request.
These notifications are delivered through HTTP POST requests to any endpoint URLs you've specified in your account's [Webhooks settings](https://dashboard.moonpay.com/developers/#webhooks). MoonPay is capable of sending a single event to multiple webhook endpoints.
## Configuring your Webhook Settings
Webhooks are configured in your MoonPay dashboard's [Webhook settings](https://dashboard.moonpay.com/developers/#webhooks). Click Add Endpoint to reveal a form where you can add a new URL for receiving webhooks.
You can enter any URL as the destination for events. However, this should be a dedicated page on your server that is set up to receive webhook notifications. You can choose to be notified of all event types, or only specific ones.
Using test or live API keys determines whether test events or live events are sent to your configured URL. If you want to send both live and test events to the same URL, you need to create two separate settings. You can add as many URLs as you like.
## Receiving a Webhook Notification
Setting up an endpoint to receive webhook HTTP POST requests in the JSON request body can vary based on your backend stack and hosting environment. Below are some methods to achieve this using different technologies:
### Python with Flask
Flask is a lightweight WSGI web application framework in Python.
```python theme={null}
from flask import Flask, request, jsonify
app = Flask(**name**)
@app.route('/webhook', methods=['POST'])
def webhook(): # Flask automatically parses JSON if the Content-Type is application/json
data = request.json
print(f"Received data: {data}")
return jsonify({"status": "success"}), 200
if **name** == '**main**':
app.run(port=5000)
```
### Node.js with Express
Node.js is widely used for server-side development, and Express is one of the most popular frameworks for Node.js.
```typescript theme={null}
const express = require("express");
const bodyParser = require("body-parser");
const app = express();
// Middleware to parse JSON payload from incoming POST request
app.use(bodyParser.json());
app.post("/webhook", (req, res) => {
// req.body contains the parsed JSON payload
const data = req.body;
console.log(`Received data: ${JSON.stringify(data)}`);
res.status(200).json({ status: "success" });
});
app.listen(3000, () => {
console.log("Server started on ");
});
```
### Ruby with Sinatra
Sinatra is a DSL (Domain Specific Language) for quickly creating web applications in Ruby with minimal effort.
```ruby theme={null}
require 'sinatra'
require 'json'
post '/webhook' do # Reading and parsing the JSON payload from the request body
data = JSON.parse(request.body.read)
puts "Received data: #{data}"
[200, { 'Content-Type' => 'application/json' }, { status: 'success' }.to_json]
end
```
### Deployment
After setting up your webhook endpoint, you'll need to deploy it. You can use cloud services like AWS, Google Cloud, Heroku, or any VPS provider for this purpose.
### Secure your Webhook
You may validate incoming requests to ensure they are coming from a trusted source. Visit our [webhooks signature](/api-reference/widget/webhooks/signature) for more information. This is a recommended best practice.
### Test your Webhook
After deployment, you can test your webhook endpoint using Postman or curl to send a simulated POST request.
# Sell
Source: https://dev.moonpay.com/api-reference/widget/webhooks/sell
Currently supported Sell events
## Supported events
* `sell_transaction_created`
* `sell_transaction_updated`
* `sell_transaction_failed`
Complete list of sell transaction object parameters and descriptions can be found [here](https://moonpay.readme.io/reference/getselltransaction).
## Examples
```json sell_transaction_created theme={null}
{
"data": {
"id": "b8606f16-5518-4425-8076-87067a291ddf",
"createdAt": "2023-05-12T17:30:50.390Z",
"updatedAt": "2023-05-12T17:30:50.390Z",
"baseCurrencyAmount": 500,
"feeAmount": 3.99,
"extraFeeAmount": 0,
"quoteCurrencyAmount": 38.79,
"flow": "principal",
"status": "waitingForDeposit",
"accountId": "7c565d78-10d9-47d8-aa4b-ed28aa1177dc",
"customerId": "66eed1c8-e12e-4d77-85f6-008b7b3eeeab",
"quoteCurrencyId": "edd81f1f-f735-4692-b410-6def107f17d2",
"baseCurrencyId": "c8b1ef20-3703-4a66-9ea1-c13ce0d893bf",
"eurRate": 0.9211,
"usdRate": 1,
"gbpRate": 0.8027,
"depositWalletId": null,
"bankAccountId": "eb5e7e71-087d-40ec-b508-33c6fab38175",
"refundWalletAddress": "",
"refundWalletAddressRequestedAt": null,
"externalTransactionId": null,
"failureReason": null,
"depositHash": null,
"refundHash": null,
"widgetRedirectUrl": null,
"confirmations": null,
"quoteExpiresAt": "2023-05-12T17:50:50.389Z",
"quoteExpiredEmailSentAt": null,
"refundApprovalStatus": null,
"cancelledById": null,
"blockedById": null,
"depositMatchedManuallyById": null,
"createdById": null,
"incomingCustomerCryptoDepositId": null,
"payoutMethod": "ach_bank_transfer",
"integratedSellDepositInfo": null,
"baseCurrency": {
"id": "c8b1ef20-3703-4a66-9ea1-c13ce0d893bf",
"createdAt": "2019-04-10T15:58:46.394Z",
"updatedAt": "2023-03-08T14:38:22.686Z",
"type": "crypto",
"name": "Stellar",
"code": "xlm",
"precision": 0,
"maxAmount": 2000,
"minAmount": 20,
"minBuyAmount": 44,
"maxBuyAmount": null,
"isSellSupported": true,
"addressRegex": "^G[A-D]{1}[A-Z2-7]{54}$",
"testnetAddressRegex": "^G[A-D]{1}[A-Z2-7]{54}$",
"supportsAddressTag": true,
"addressTagRegex": "^[0-9A-Za-z]{1,28}$",
"supportsTestMode": true,
"supportsLiveMode": true,
"isSuspended": false,
"isSupportedInUS": true,
"notAllowedUSStates": ["HI", "NY", "RI", "VI"],
"notAllowedCountries": [],
"confirmationsRequired": 1,
"minSellAmount": 200,
"maxSellAmount": 100000,
"metadata": {
"contractAddress": null,
"coinType": "148",
"chainId": null,
"networkCode": "stellar"
}
},
"depositWallet": null,
"quoteCurrency": {
"id": "edd81f1f-f735-4692-b410-6def107f17d2",
"createdAt": "2019-04-29T16:55:28.647Z",
"updatedAt": "2023-03-06T10:11:49.284Z",
"type": "fiat",
"name": "US Dollar",
"code": "usd",
"precision": 2,
"maxAmount": 12000,
"minAmount": 30,
"minBuyAmount": 30,
"maxBuyAmount": 12000,
"isSellSupported": true
},
"country": "USA",
"state": "NJ",
"externalCustomerId": null
},
"type": "sell_transaction_created"
}
```
```json sell_transaction_updated theme={null}
{
"data": {
"id": "b8606f16-5518-4425-8076-87067a291ddf",
"createdAt": "2023-05-12T17:30:50.390Z",
"updatedAt": "2023-05-12T17:31:04.590Z",
"baseCurrencyAmount": 500,
"feeAmount": 3.99,
"extraFeeAmount": 0,
"quoteCurrencyAmount": 38.79,
"flow": "principal",
"status": "waitingForDeposit",
"accountId": "7c565d78-10d9-47d8-aa4b-ed28aa1177dc",
"customerId": "66eed1c8-e12e-4d77-85f6-008b7b3eeeab",
"quoteCurrencyId": "edd81f1f-f735-4692-b410-6def107f17d2",
"baseCurrencyId": "c8b1ef20-3703-4a66-9ea1-c13ce0d893bf",
"eurRate": 0.9211,
"usdRate": 1,
"gbpRate": 0.8027,
"depositWalletId": "226f77bf-d061-4d88-9830-8a58c1094ab1",
"bankAccountId": "eb5e7e71-087d-40ec-b508-33c6fab38175",
"refundWalletAddress": "",
"refundWalletAddressRequestedAt": null,
"externalTransactionId": null,
"failureReason": null,
"depositHash": null,
"refundHash": null,
"widgetRedirectUrl": null,
"confirmations": null,
"quoteExpiresAt": "2023-05-12T17:50:50.389Z",
"quoteExpiredEmailSentAt": null,
"refundApprovalStatus": null,
"cancelledById": null,
"blockedById": null,
"depositMatchedManuallyById": null,
"createdById": null,
"incomingCustomerCryptoDepositId": null,
"payoutMethod": "ach_bank_transfer",
"integratedSellDepositInfo": null,
"baseCurrency": {
"id": "c8b1ef20-3703-4a66-9ea1-c13ce0d893bf",
"createdAt": "2019-04-10T15:58:46.394Z",
"updatedAt": "2023-03-08T14:38:22.686Z",
"type": "crypto",
"name": "Stellar",
"code": "xlm",
"precision": 0,
"maxAmount": 2000,
"minAmount": 20,
"minBuyAmount": 44,
"maxBuyAmount": null,
"isSellSupported": true,
"addressRegex": "^G[A-D]{1}[A-Z2-7]{54}$",
"testnetAddressRegex": "^G[A-D]{1}[A-Z2-7]{54}$",
"supportsAddressTag": true,
"addressTagRegex": "^[0-9A-Za-z]{1,28}$",
"supportsTestMode": true,
"supportsLiveMode": true,
"isSuspended": false,
"isSupportedInUS": true,
"notAllowedUSStates": ["HI", "NY", "RI", "VI"],
"notAllowedCountries": [],
"confirmationsRequired": 1,
"minSellAmount": 200,
"maxSellAmount": 100000,
"metadata": {
"contractAddress": null,
"coinType": "148",
"chainId": null,
"networkCode": "stellar"
}
},
"depositWallet": {
"id": "226f77bf-d061-4d88-9830-8a58c1094ab1",
"createdAt": "2023-05-12T17:31:04.527Z",
"updatedAt": "2023-05-12T17:31:04.527Z",
"walletAddress": "GDPVBFETVZRQRVFUIDN7I55X5HDXS2NVZ5S62DKFUSNKJ5XWUOU2Q3TM",
"walletAddressTag": null,
"customerId": "66eed1c8-e12e-4d77-85f6-008b7b3eeeab",
"currencyId": "c8b1ef20-3703-4a66-9ea1-c13ce0d893bf",
"btcLegacyAddress": null,
"type": "Fireblocks",
"encryptedPrivateKey": null
},
"quoteCurrency": {
"id": "edd81f1f-f735-4692-b410-6def107f17d2",
"createdAt": "2019-04-29T16:55:28.647Z",
"updatedAt": "2023-03-06T10:11:49.284Z",
"type": "fiat",
"name": "US Dollar",
"code": "usd",
"precision": 2,
"maxAmount": 12000,
"minAmount": 30,
"minBuyAmount": 30,
"maxBuyAmount": 12000,
"isSellSupported": true
},
"country": "USA",
"state": "NJ",
"externalCustomerId": null
},
"type": "sell_transaction_updated"
}
```
```json sell_transaction_failed theme={null}
{
"data": {
"id": "b8606f16-5518-4425-8076-87067a291ddf",
"createdAt": "2023-05-12T17:30:50.390Z",
"updatedAt": "2023-05-19T17:31:00.042Z",
"baseCurrencyAmount": 500,
"feeAmount": 3.99,
"extraFeeAmount": 0,
"quoteCurrencyAmount": 38.79,
"flow": "principal",
"status": "failed",
"accountId": "7c565d78-10d9-47d8-aa4b-ed28aa1177dc",
"customerId": "66eed1c8-e12e-4d77-85f6-008b7b3eeeab",
"quoteCurrencyId": "edd81f1f-f735-4692-b410-6def107f17d2",
"baseCurrencyId": "c8b1ef20-3703-4a66-9ea1-c13ce0d893bf",
"eurRate": 0.9211,
"usdRate": 1,
"gbpRate": 0.8027,
"depositWalletId": "226f77bf-d061-4d88-9830-8a58c1094ab1",
"bankAccountId": "eb5e7e71-087d-40ec-b508-33c6fab38175",
"refundWalletAddress": "",
"refundWalletAddressRequestedAt": null,
"externalTransactionId": null,
"failureReason": "Deposit timeout",
"depositHash": null,
"refundHash": null,
"widgetRedirectUrl": null,
"confirmations": null,
"quoteExpiresAt": "2023-05-12T17:50:50.389Z",
"quoteExpiredEmailSentAt": "2023-05-12T17:51:00.044Z",
"refundApprovalStatus": null,
"cancelledById": null,
"blockedById": null,
"depositMatchedManuallyById": null,
"createdById": null,
"incomingCustomerCryptoDepositId": null,
"payoutMethod": "ach_bank_transfer",
"integratedSellDepositInfo": null,
"baseCurrency": {
"id": "c8b1ef20-3703-4a66-9ea1-c13ce0d893bf",
"createdAt": "2019-04-10T15:58:46.394Z",
"updatedAt": "2023-03-08T14:38:22.686Z",
"type": "crypto",
"name": "Stellar",
"code": "xlm",
"precision": 0,
"maxAmount": 2000,
"minAmount": 20,
"minBuyAmount": 44,
"maxBuyAmount": null,
"isSellSupported": true,
"addressRegex": "^G[A-D]{1}[A-Z2-7]{54}$",
"testnetAddressRegex": "^G[A-D]{1}[A-Z2-7]{54}$",
"supportsAddressTag": true,
"addressTagRegex": "^[0-9A-Za-z]{1,28}$",
"supportsTestMode": true,
"supportsLiveMode": true,
"isSuspended": false,
"isSupportedInUS": true,
"notAllowedUSStates": ["HI", "NY", "RI", "VI"],
"notAllowedCountries": [],
"confirmationsRequired": 1,
"minSellAmount": 200,
"maxSellAmount": 100000,
"metadata": {
"contractAddress": null,
"coinType": "148",
"chainId": null,
"networkCode": "stellar"
}
},
"depositWallet": {
"id": "226f77bf-d061-4d88-9830-8a58c1094ab1",
"createdAt": "2023-05-12T17:31:04.527Z",
"updatedAt": "2023-05-12T17:31:04.527Z",
"walletAddress": "GDPVBFETVZRQRVFUIDN7I55X5HDXS2NVZ5S62DKFUSNKJ5XWUOU2Q3TM",
"walletAddressTag": null,
"customerId": "66eed1c8-e12e-4d77-85f6-008b7b3eeeab",
"currencyId": "c8b1ef20-3703-4a66-9ea1-c13ce0d893bf",
"btcLegacyAddress": null,
"type": "Fireblocks",
"encryptedPrivateKey": null
},
"quoteCurrency": {
"id": "edd81f1f-f735-4692-b410-6def107f17d2",
"createdAt": "2019-04-29T16:55:28.647Z",
"updatedAt": "2023-03-06T10:11:49.284Z",
"type": "fiat",
"name": "US Dollar",
"code": "usd",
"precision": 2,
"maxAmount": 12000,
"minAmount": 30,
"minBuyAmount": 30,
"maxBuyAmount": 12000,
"isSellSupported": true
},
"country": "USA",
"state": "NJ",
"externalCustomerId": null
},
"type": "sell_transaction_failed"
}
```
## Properties
```json theme={null}
{
"event": {
"method": "POST",
"headers": {
"moonpay-signature": "t=1663064622,s=cdd18ef9d85c004638f0e9f770231909d24053b503a8f281991e33280a7a9ba9",
"moonpay-signature-v2": "t=1663064622,s=44d8318c5ad1720959799062280f5f0030658776c13804d9edd04fd2c4012f14"
}
}
}
```
# Request signing
Source: https://dev.moonpay.com/api-reference/widget/webhooks/signature
## Checking a Webhook Signature
MoonPay signs the webhook events and requests we send to your endpoints. We do so by including a signature in each event’s `Moonpay-Signature-V2` header. This allows you to validate that the events and requests were sent by MoonPay, not by a third party.
Before you can verify `Moonpay-Signature-V2` signatures for webhook events, you need to retrieve your webhook API key from the [Developers page](https://dashboard.moonpay.com/developers/) on the MoonPay dashboard.
The `Moonpay-Signature-V2` header contains a timestamp and one signature. The timestamp is prefixed by t=, and the signature is prefixed by s=.
```bash bash Moonpay-Signature-V2: theme={null}
t=1492774577,s=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```
MoonPay generates signatures using a hash-based message authentication code ([HMAC](https://en.wikipedia.org/wiki/HMAC)) with [SHA-256](https://en.wikipedia.org/wiki/SHA-2).
Split the header, using the , character as the separator, to get a list of elements. Then split each element, using the = character as the separator, to get a prefix and value pair.
The value for the prefix `t` corresponds to the timestamp, and `s` corresponds to the signature.
You achieve this by concatenating:
* The timestamp (as a string)
* The character . and
* For a `POST` request, the actual JSON payload (i.e., the request's body). For a `GET` request, the search string (e.g., ?externalCustomerId=adbb317d-cde9-4ebb-93a3-1b271812de06).
Compute a HMAC with the SHA-256 hash function. Use your account's webhook API key as the key, and use the `signed_payload` string as the message in both cases.
Compare the signature in the header to the expected signature.
# Swap
Source: https://dev.moonpay.com/api-reference/widget/webhooks/swap
Currently supported Swap events
## Supported events
* `swap_asset_delivery_initiated`
* `swap_transaction_created` `swap_quote_expired`
* `swap_quote_invalid`
* `swap_deposit_wallet_created`
* `swap_deposit_received`
* `swap_transaction_completed`
* `swap_transaction_failed`
* `swap_refund_asset_delivery_initiated`
* `swap_refund_completed`
# Virtual Accounts
Source: https://dev.moonpay.com/api-reference/widget/webhooks/virtual-accounts
Currently supported Virtual Accounts events
## Events
### `virtual_account_status_updated`
Triggered when the status of a virtual account changes.
```json Example payload theme={null}
{
"virtualAccountId": "9bc86a06-8300-41c8-8cef-d2eaa852164f",
"externalCustomerId": "external_customer_id_123",
"status": "completed",
"timestamp": 1678901234567
}
```
| Field | Type | Description |
| -------------------- | ------- | ------------------------------------------------------------------------ |
| `virtualAccountId` | string | Unique identifier (uuid) of the virtual account |
| `externalCustomerId` | string | External identifier of the customer |
| `status` | string | Current status of the virtual account (`pending`, `completed`, `failed`) |
| `timestamp` | integer | Unix timestamp (milliseconds) when the event was triggered |
### `virtual_account_transaction_status_updated`
Triggered when the status of a transaction within a virtual account changes.
```json Example payload theme={null}
{
"virtualAccountId": "9bc86a06-8300-41c8-8cef-d2eaa852164f",
"externalCustomerId": "external_customer_id_123",
"transactionId": "7a2cbc6f-ddef-4071-9628-a6559cb4ad89",
"status": "Completed",
"timestamp": 1678901234567
}
```
| Field | Type | Description |
| -------------------- | ------- | -------------------------------------------------------------------- |
| `virtualAccountId` | string | Unique identifier (uuid) of the virtual account |
| `externalCustomerId` | string | External identifier provided for the customer |
| `transactionId` | string | Unique identifier (uuid) of the transaction |
| `status` | string | Current status of the transaction (`Pending`, `Completed`, `Failed`) |
| `timestamp` | integer | Unix timestamp (milliseconds) when the event was triggered |
# Changelog
Source: https://dev.moonpay.com/platform/changelog
Updates to the MoonPay developer platform
**Preview removed** — the Developer Platform is now generally available. The
"currently in preview" notice has been removed from all docs pages.
**Card payments** — new [Pay with card](/platform/guides/pay-with-card) guide
and frame references for [Add Card](/platform/frames/add-card),
[Buy](/platform/frames/buy), and [Challenge](/platform/frames/challenge).
Covers the full integration: listing and managing stored cards, getting a card
quote, executing transactions via the headless buy frame, and handling
verification challenges (SCA, 3DS, CVC re-entry, KYC). Also adds the [Delete
payment method](/api-reference/platform/endpoints/payment-methods/delete) API
endpoint.
**Reset frame** — new headless frame at `/platform/v1/reset` that lets you log
a customer out by clearing their authentication state on MoonPay's domain.
Reports completion via postMessage. See [Reset](/platform/frames/reset).
**Unified docs site** — Platform and Widget docs now live under a single
Mintlify site with separate top-level tabs. Legacy `/overview/*`, `/guides/*`,
`/frames/*`, `/sdk-reference/*`, and `/api-reference/*` URLs redirect to their
new `/platform/*` paths.
**Manual integration fixes** — corrected the WebView samples to post the
payload as a string, base64-decode credentials, and handle JS dialogs. Affects
[web](/platform/guides/manual-integration/web),
[iOS](/platform/guides/manual-integration/ios),
[Android](/platform/guides/manual-integration/android), [React
Native](/platform/guides/manual-integration/react-native), and
[Flutter](/platform/guides/manual-integration/flutter) guides.
**Fee language** updated across guides to clarify partner vs. ecosystem fees.
**Revoke session** — `DELETE /platform/v1/sessions` invalidates an active
session token. See [Revoke a
session](/api-reference/platform/endpoints/sessions/revoke).
**Sessions endpoint renamed** — `POST /platform/v1/session` is now `POST
/platform/v1/sessions` (plural). The old path continues to work; new
integrations should use the plural form.
**Apple Pay going-live guide** — added production-readiness details for the
Apple Pay frame, including merchant verification and domain registration
steps. See [Pay with Apple Pay](/platform/guides/pay-with-apple-pay).
**`customerId` in connect/check payload** — the `complete` postMessage event
now documents `customer.id`, and the session-create request documents the
`customerId` field for returning users (skip the connect flow when you already
have one).
**Manual integration credentials** — the manual integration guides now use
`clientToken` (not `sessionToken`) to initialize frames, matching the
early-credential-issuance flow.
**Payment-disclosure rails narrowed** — the `paymentDisclosures` capability is
now documented as scoped to NY and WA only. Customers in other US states will
not receive a `paymentDisclosures` requirement.
**HKDF examples** — manual integration code samples consistently pass
`undefined` for the `info` parameter of `hkdf()`.
**Acceptance criteria page** — new compliance checklist outlining the
requirements for going live. See [Going live](/platform/overview/going-live).
**Manual integration moved** — the per-platform manual integration pages now
live under [Guides → Manual
Integration](/platform/guides/manual-integration/overview).
**Using agents** — new page covering MCP client setup for Claude Code and
Codex, so you can wire your agent to MoonPay's developer docs. See [Using
agents](/platform/overview/using-agents).
**Manual integration docs** — initial documented integrations for web, iOS,
Android, React Native, and Flutter (graduated from hidden drafts).
**Early credential issuance** — the credentials-flow guide now reflects that
`clientToken` and `accessToken` are issued before authentication completes, so
partners can initialize sensitive frames sooner.
**Connect-flow low-friction callout** — added guidance to the connect-flow
guide about minimizing handoffs back to MoonPay-hosted UI.
**Apple Pay frame height** — corrected the documented frame height for the
Apple Pay frame.
Quote API: `fees.partner` field renamed to `fees.ecosystem`.
Apple Pay: documented test-mode and frame sandbox requirements, plus a
corrected frame size.
Customer capabilities and payment-disclosure requirements expanded.
New widget-fallback frame and integration guide.
Frames protocol: documented `version: 2` and added the versioning section.
Frame URLs migrated from `/v2/*` to `/platform/*` across all docs.
OpenAPI now served live from `https://api.moonpay.com/platform/openapi.json`
rather than checked into the repo.
Removed historical `pk_test` / `pk_live` references in favor of the new
credential model.
Initial Apple Pay frame size and frame sandbox requirements published.
Mintlify upgrade and a content-style-guide pass across guides and frames docs.
# Add Card
Source: https://dev.moonpay.com/platform/frames/add-card
Details on working with the Add Card frame used in the [Pay with card](/platform/guides/pay-with-card) flow.
## URL
```
https://blocks.moonpay.com/platform/v1/add-card
```
## Requirements
### Size
Render the frame in a modal or sheet. Width and height are flexible — size the
container to fit your UI.
The Add Card frame uses MoonPay's existing card input UI. A redesign is
planned to streamline the experience and explore hosted fields for full design
customization.
## Initialization parameters
| Property | Type | Required | Description |
| ------------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `clientToken` | `string` | ✅ | The [client token](/platform/guides/api-and-sdk-credentials#client-token) returned from the [connect flow](/platform/guides/connect-a-customer). |
| `channelId` | `string` | ✅ | A unique identifier for the frame generated on your client. This value is attached to each `postMessage` payload to help identify messages.
The format of this string is up to you. |
## Events
All events are dispatched using the message pattern described in the [frames
protocol](/platform/frames/overview#frames-protocol#messages). Below are the
event payloads specific to the Add Card frame.
### Outbound events
frame->parent
These events are sent from this frame to the parent window.
#### `handshake`
The frame requests that you open a message channel.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "handshake"
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type HandshakeEvent = Message<{
kind: "handshake";
}>;
```
#### `ready`
The frame finished loading and the card input UI is fully rendered.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "ready"
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type ReadyEvent = Message<{
kind: "ready";
}>;
```
#### `complete`
The card was added successfully. Use `card.id` to get a quote without
re-fetching payment methods.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "complete",
"payload": {
"card": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "card",
"cardType": "credit",
"brand": "visa",
"last4": "4242",
"expirationMonth": "12",
"expirationYear": "2027",
"availability": { "active": true }
}
}
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type CardResponse = {
id: string;
type: "card";
cardType: "credit" | "debit" | "unknown";
brand: "visa" | "mastercard" | "maestro" | "american_express" | "other";
last4: string;
expirationMonth: string;
expirationYear: string;
availability: { active: boolean };
};
type AddCardCompleteEvent = Message<{
kind: "complete";
payload: {
card: CardResponse;
};
}>;
```
#### `error`
An error occurred during card addition.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "error",
"payload": {
"code": "generic",
"message": "Card creation failed."
}
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type AddCardErrorEvent = Message<{
kind: "error";
payload: {
code: "configurationError" | "generic";
/** A developer-facing error message. Not intended to be rendered in UI. */
message: string;
};
}>;
```
| Code | Description |
| -------------------- | -------------------------------- |
| `configurationError` | Missing or invalid `clientToken` |
| `generic` | Card creation failed |
### Inbound events
parent->frame
These events are sent from the parent window to this frame.
#### `ack`
Acknowledge the [handshake](#handshake).
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "ack"
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type AckEvent = Message<{
kind: "ack";
}>;
```
# Apple Pay
Source: https://dev.moonpay.com/platform/frames/apple-pay
Details on working with the [Apple Pay](/platform/guides/pay-with-apple-pay) frame.
## URL
```html theme={null}
https://blocks.moonpay.com/platform/v1/apple-pay
```
## Requirements
### Size
The frame container **height must be 44px**. Width is flexible; the Apple Pay button inside the frame uses 100% of the container width.
### Permissions
The `payment` [permission policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Permissions-Policy#iframes) is required.
In [test mode](/platform/overview/test-mode#apple-pay), the frame uses `window.confirm` to simulate the Apple Pay payment. If your iframe uses the [`sandbox`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/iframe#sandbox) attribute, you will need to include `allow-modals`.
```tsx Example theme={null}
```
## Initialization parameters
| Property | Type | Required | Description |
| ------------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `clientToken` | `string` | ✅ | The [client token](/platform/guides/api-and-sdk-credentials#client-token) returned from the [connect flow](/platform/guides/connect-a-customer). |
| `channelId` | `string` | ✅ | A unique identifier for the frame generated on your client. This value is attached to each `postMessage` payload to help identify messages.
The format of this string is up to you. |
| `signature` | `string` | ✅ | The quote `signature` from the quote endpoint. Pass `signature` as returned. |
## Events
All events are dispatched using the message pattern described in the [frames protocol](/platform/frames/overview#frames-protocol#messages). Below are the event payloads specific to the Apple Pay frame.
### Outbound events
frame->parent
These events are sent from this frame to the parent window.
#### `handshake`
The frame requests that you open a message channel.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "handshake"
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type HandshakeEvent = Message<{
kind: "handshake";
}>;
```
#### `ready`
The frame finished loading and the UI is fully rendered. You can use this to coordinate UI transitions if needed.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "ready"
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type ReadyEvent = Message<{
kind: "ready";
}>;
```
#### `complete`
The transaction is complete. Use the transaction ID to track status updates (for example, by polling or via webhooks).
```json Example (success) theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "complete",
"payload": {
"transaction": {
"id": "txn_01",
"status": "pending"
}
}
}
```
```json Example (fail) theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "complete",
"payload": {
"transaction": {
"status": "failed",
"failureReason": "The payment could not be completed with the selected card."
}
}
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
enum TransactionStatus {
/** The transaction has successfully completed. The payment has been made and the crypto has been transferred. **/
complete = "complete",
/** The payment has been completed and the crypto transfer is underway. **/
pending = "pending",
/** The transaction has failed. No payment was applied and the crypto was not transferred. **/
failed = "failed",
}
type Transaction =
| {
/** The MoonPay identifier for this transaction. **/
id: string;
/** The status of the transaction. **/
status: TransactionStatus.complete | TransactionStatus.pending;
}
| {
status: TransactionStatus.failed;
/** A developer-friendly error message detailing the reason for the transaction failure. **/
failureReason: string;
};
type ApplePayCompleteEvent = Message<{
kind: "complete";
payload: {
transaction: Transaction;
};
}>;
```
#### `error`
This event dispatches errors that occur in the flow and, if available, provides steps for recovery.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "error",
"payload": {
"code": "ipAddressMismatch",
"message": "The client IP address does not match the session."
}
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type ApplePayError = Message<{
kind: "error";
payload: {
code: "quoteExpired" | "invalidQuote" | "applePayUnavailable" | "generic";
/** A developer-facing error message with details on recovery or documentation. This message is not intended to be rendered in UI. */
message: string;
};
}>;
```
### Inbound events
parent->frame
These events are sent from the parent window to this frame.
#### `ack`
Acknowledge the [handshake](#handshake).
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "ack"
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type AckEvent = Message<{
kind: "ack";
}>;
```
#### `setQuote`
Provide a new quote to the frame. Pass `signature` as returned by the quote endpoint. Upon receiving this event, the frame disables the Apple Pay button until the quote is revalidated.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "setQuote",
"payload": {
"quote": {
"signature": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9..."
}
}
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type SetQuoteEvent = Message<{
kind: "setQuote";
payload: {
quote: {
/** The signature from a valid quote. **/
signature: string;
};
};
}>;
```
# Buy
Source: https://dev.moonpay.com/platform/frames/buy
Details on working with the headless Buy frame used in the [Pay with card](/platform/guides/pay-with-card) flow.
The Buy frame is a headless frame. It evaluates transaction requirements, creates
the transaction, and completes post-transaction processing — all without
rendering any UI. Your confirmation screen, loading states, and purchase button
remain fully under your control.
## URL
```
https://blocks.moonpay.com/platform/v1/buy
```
## Requirements
### Size
The frame is headless. Mount it with zero dimensions so it does not affect your
layout:
```tsx Example theme={null}
```
## Initialization parameters
| Property | Type | Required | Description |
| ----------------------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `clientToken` | `string` | ✅ | The [client token](/platform/guides/api-and-sdk-credentials#client-token) returned from the [connect flow](/platform/guides/connect-a-customer). |
| `channelId` | `string` | ✅ | A unique identifier for the frame generated on your client. This value is attached to each `postMessage` payload to help identify messages.
The format of this string is up to you. |
| `signature` | `string` | ✅ | The quote `signature` from the [quote endpoint](/api-reference/platform/endpoints/quotes/get). Pass `signature` as returned. |
| `externalTransactionId` | `string` | | Your own identifier for the transaction. Stored and associated with the MoonPay transaction for correlation. |
## Events
All events are dispatched using the message pattern described in the [frames
protocol](/platform/frames/overview#frames-protocol#messages). Below are the
event payloads specific to the Buy frame.
### Outbound events
frame->parent
These events are sent from this frame to the parent window.
#### `handshake`
The frame requests that you open a message channel.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "handshake"
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type HandshakeEvent = Message<{
kind: "handshake";
}>;
```
#### `ready`
The buy pipeline is starting. Use this to show a loading indicator in your UI.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "ready"
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type ReadyEvent = Message<{
kind: "ready";
}>;
```
#### `complete`
The transaction is complete. Use the transaction ID to track final status via
polling.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "complete",
"payload": {
"transaction": {
"id": "txn_01",
"status": "pending"
}
}
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type BuyCompleteEvent = Message<{
kind: "complete";
payload: {
transaction: {
id: string;
status: "pending" | "completed" | "failed";
};
};
}>;
```
#### `challenge`
Verification is required before the transaction can proceed. Render the
[challenge frame](/platform/frames/challenge) at the provided URL. Do not
construct the URL yourself — use it as-is.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "challenge",
"payload": {
"kind": "frame",
"url": "https://blocks.moonpay.com/platform/v1/challenge?challengeToken=..."
}
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type BuyChallengeEvent = Message<{
kind: "challenge";
payload: {
kind: string;
/** Fully-formed URL to pass directly as the challenge frame src. */
url: string;
};
}>;
```
#### `error`
A terminal error occurred. Remove the frame and surface the message to the
developer.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "error",
"payload": {
"code": "invalidQuote",
"message": "Unable to decode the quote signature."
}
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type BuyErrorEvent = Message<{
kind: "error";
payload: {
code: "configurationError" | "invalidQuote" | "generic";
/** A developer-facing error message. Not intended to be rendered in UI. */
message: string;
};
}>;
```
| Code | Description |
| -------------------- | ---------------------------------------- |
| `configurationError` | Missing or invalid `signature` parameter |
| `invalidQuote` | Unable to decode the quote signature |
| `generic` | Unspecified error |
### Inbound events
parent->frame
These events are sent from the parent window to this frame.
#### `ack`
Acknowledge the [handshake](#handshake).
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "ack"
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type AckEvent = Message<{
kind: "ack";
}>;
```
#### `setQuote`
Provide a new quote to the frame. Send this when the current quote expires
before the customer completes the purchase. Pass `signature` as returned by the
quote endpoint.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "setQuote",
"payload": {
"quote": {
"signature": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9..."
}
}
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type SetQuoteEvent = Message<{
kind: "setQuote";
payload: {
quote: {
/** The signature from a valid quote. */
signature: string;
};
};
}>;
```
# Challenge
Source: https://dev.moonpay.com/platform/frames/challenge
Details on working with the Challenge frame used in the [Pay with card](/platform/guides/pay-with-card) flow.
The Challenge frame handles all verification steps required to complete a card
transaction. The URL is provided by the [buy frame's](/platform/frames/buy)
`challenge` event — do not construct it yourself.
The frame is self-driving: after the handshake, there are no further
parent-to-child messages. It sequences through all required verification steps,
creates the transaction, and emits `complete` when the pipeline finishes.
## URL
Provided by the buy frame's `challenge` event payload. Do not modify it.
## Requirements
### Size
Render the frame in a modal or full sheet so the customer can complete
verification. The frame adapts to any size, but a full-screen or large modal
works best for flows like 3D Secure that load bank-hosted pages.
## Initialization parameters
| Property | Type | Required | Description |
| ---------------- | -------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
| `clientToken` | `string` | ✅ | Included automatically in the challenge URL for CSP compliance. Do not remove it. |
| `channelId` | `string` | ✅ | Generate a fresh channel ID on your side and append it to the challenge URL before setting it as the frame `src`. |
| `challengeToken` | `string` | ✅ | Opaque token. Do not modify or parse it. |
## Events
All events are dispatched using the message pattern described in the [frames
protocol](/platform/frames/overview#frames-protocol#messages). Below are the
event payloads specific to the Challenge frame.
### Outbound events
frame->parent
These events are sent from this frame to the parent window.
#### `handshake`
The frame requests that you open a message channel.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_challenge_1" },
"kind": "handshake"
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type HandshakeEvent = Message<{
kind: "handshake";
}>;
```
#### `ready`
The challenge UI is rendered and visible to the customer.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_challenge_1" },
"kind": "ready"
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type ReadyEvent = Message<{
kind: "ready";
}>;
```
#### `complete`
All verification steps resolved and the transaction pipeline finished. Remove
the challenge frame **and** the buy frame, then navigate to the confirmation
screen.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_challenge_1" },
"kind": "complete",
"payload": {
"flow": "buy",
"transaction": {
"id": "txn_01",
"status": "pending"
}
}
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type ChallengeCompleteEvent = Message<{
kind: "complete";
payload: {
flow: "buy";
transaction: {
id: string;
status: "pending" | "completed" | "failed";
};
};
}>;
```
#### `cancelled`
The customer dismissed the challenge. Remove the challenge frame and buy frame,
then offer a retry path.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_challenge_1" },
"kind": "cancelled",
"payload": {
"flow": "buy"
}
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type ChallengeCancelledEvent = Message<{
kind: "cancelled";
payload: {
flow: "buy";
transactionId?: string;
challengeToken?: string;
};
}>;
```
#### `error`
The challenge failed. Remove the challenge frame and buy frame, then surface the
message to the developer.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_challenge_1" },
"kind": "error",
"payload": {
"code": "generic",
"message": "Challenge failed."
}
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type ChallengeErrorEvent = Message<{
kind: "error";
payload: {
code: "configurationError" | "invalidToken" | "generic";
/** A developer-facing error message. Not intended to be rendered in UI. */
message: string;
};
}>;
```
| Code | Description |
| -------------------- | ------------------------------------- |
| `configurationError` | Frame initialization failed |
| `invalidToken` | Challenge token is invalid or expired |
| `generic` | Unspecified error |
### Inbound events
parent->frame
#### `ack`
Acknowledge the [handshake](#handshake). This is the only message you send to
the challenge frame — it is self-driving after the handshake.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_challenge_1" },
"kind": "ack"
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type AckEvent = Message<{
kind: "ack";
}>;
```
# Check
Source: https://dev.moonpay.com/platform/frames/check
Check if a customer has an active connection.
The check frame is a lightweight, headless page hosted on a MoonPay domain. Use it to check whether the customer already has an active connection.
The frame always returns encrypted credentials. If the customer is connected, these are authenticated credentials. If the customer is not connected (or their connection has expired), these are anonymous credentials — store them and use the `clientToken` to initialize the connect flow.
## URL
```html theme={null}
https://blocks.moonpay.com/platform/v1/check-connection
```
## Requirements
### Key exchange
Credentials returned from the frame are encrypted to protect their content since they are sent over `postMessage`. You need to generate an [X25519](https://datatracker.ietf.org/doc/html/rfc7748#section-5) keypair and pass the public key into the frame. The frame uses your public key to encrypt the payload, ensuring only you can read it with your private key.
Never persist the private key to disk or storage. Hold it in memory only for
the duration of the session.
The frame uses the [@noble/curves](https://github.com/paulmillr/noble-curves) library internally. On web and React Native, you can use this same library to generate your keypair and handle decryption. For native platforms, use a compatible utility like [CryptoKit](https://developer.apple.com/documentation/cryptokit/curve25519) on iOS or [KeyPairGenerator](https://developer.android.com/reference/java/security/KeyPairGenerator) on Android.
The following example shows how to generate a keypair and decrypt credentials using `@noble/curves`. You'll want to add your own error handling and input validation for production use.
```sh pnpm theme={null}
pnpm i @noble/curves @noble/hashes @noble/ciphers
```
```sh bun theme={null}
bun add @noble/curves @noble/hashes @noble/ciphers
```
```sh npm theme={null}
npm i @noble/curves @noble/hashes @noble/ciphers
```
```ts crypto.ts theme={null}
// An example module for generating keypairs and decrypting client credentials.
// This should not be used as-is in production!
import { gcm } from "@noble/ciphers/aes.js";
import { x25519 } from "@noble/curves/ed25519.js";
import { hkdf } from "@noble/hashes/hkdf.js";
import { sha256 } from "@noble/hashes/sha2.js";
/** The credentials returned from the connect flow. */
export type ClientCredentials = {
/** A JWT used to authenticate client requests to the Moonpay API. */
accessToken: string;
/** A JWT used to initialize authenticated frames such as Apple Pay. */
clientToken: string;
/** An [ISO 8601 timestamp](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString) representing the expiration time of the tokens. */
expiresAt: string;
};
/** X25519 `privateKey` and `publicKey`as hex strings. */
export type KeyPair = Record<"privateKey" | "publicKey", string>;
export type DecryptClientCredentialsResult =
| { ok: true; value: ClientCredentials }
| { ok: false; error: string };
const hexToBytes = (hex: string): Uint8Array => {
if (hex.length % 2 !== 0) {
throw new Error("Invalid hex string");
}
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return bytes;
};
const bytesToHex = (bytes: Uint8Array): string => {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
};
/** Decrypts an encrypted `ClientCredentials`. */
export const decryptClientCredentials = (
/** A base64-encoded string representing the encrypted JSON payload. **/
encryptedCredentials: string,
/** The recipient's X25519 private key as a hex string. **/
privateKeyHex: string,
): DecryptClientCredentialsResult => {
// Base64 decode the encrypted credentials
const payload = atob(encryptedCredentials);
// Guard and validate this deserialization
const parsedPayload = JSON.parse(payload);
// Convert the private key from a hex string to a `Uint8Array`
const privateKey = hexToBytes(privateKeyHex);
// Convert the ephemeral public key from a hex string to a `Uint8Array`
const publicKey = hexToBytes(parsedPayload.ephemeralPublicKey);
const ivBytes = hexToBytes(parsedPayload.iv);
const ciphertextBytes = hexToBytes(parsedPayload.ciphertext);
const sharedSecret = x25519.getSharedSecret(privateKey, publicKey);
const encryptionKey = hkdf(sha256, sharedSecret, undefined, undefined, 32);
const cipher = gcm(encryptionKey, ivBytes);
const plainTextBytes = cipher.decrypt(ciphertextBytes);
const plaintext = new TextDecoder().decode(plainTextBytes);
let parsed: unknown;
try {
parsed = JSON.parse(plaintext);
} catch {
return { ok: false, error: "Failed to parse decrypted payload as JSON" };
}
// Validate the decrypted payload
if (
typeof parsed !== "object" ||
parsed === null ||
typeof (parsed as Record).accessToken !== "string" ||
typeof (parsed as Record).clientToken !== "string" ||
typeof (parsed as Record).expiresAt !== "string"
) {
return { ok: false, error: "Decrypted payload missing required fields" };
}
return { ok: true, value: parsed as ClientCredentials };
};
/** Generates a new X25519 key pair encryption. */
export const generateKeyPair = (): KeyPair => {
const { secretKey: privateKey, publicKey } = x25519.keygen();
return {
privateKey: bytesToHex(privateKey),
publicKey: bytesToHex(publicKey),
};
};
```
The following example shows how to generate a keypair and decrypt credentials using `@noble/curves`. You'll want to add your own error handling and input validation for production use.
In React Native, yuo will need a [polyfill for `getRandomValues`](https://github.com/LinusU/react-native-get-random-values) ([MDN](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues)) which is only available in browsers.
```sh pnpm theme={null}
pnpm i react-native-get-random-values @noble/curves @noble/hashes @noble/ciphers
```
```sh bun theme={null}
bun add react-native-get-random-values @noble/curves @noble/hashes @noble/ciphers
```
```sh npm theme={null}
npm i react-native-get-random-values @noble/curves @noble/hashes @noble/ciphers
```
```ts crypto.ts theme={null}
// An example module for generating keypairs and decrypting client credentials.
// This should not be used as-is in production!
import { gcm } from "@noble/ciphers/aes.js";
import { x25519 } from "@noble/curves/ed25519.js";
import { hkdf } from "@noble/hashes/hkdf.js";
import { sha256 } from "@noble/hashes/sha2.js";
// React Native polyfill for getRandomValues
import "react-native-get-random-values";
/** The credentials returned from the connect flow. */
export type ClientCredentials = {
/** A JWT used to authenticate client requests to the Moonpay API. */
accessToken: string;
/** A JWT used to initialize authenticated frames such as Apple Pay. */
clientToken: string;
/** An [ISO 8601 timestamp](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString) representing the expiration time of the tokens. */
expiresAt: string;
};
/** X25519 `privateKey` and `publicKey`as hex strings. */
export type KeyPair = Record<"privateKey" | "publicKey", string>;
export type DecryptClientCredentialsResult =
| { ok: true; value: ClientCredentials }
| { ok: false; error: string };
const hexToBytes = (hex: string): Uint8Array => {
if (hex.length % 2 !== 0) {
throw new Error("Invalid hex string");
}
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return bytes;
};
const bytesToHex = (bytes: Uint8Array): string => {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
};
/** Decrypts an encrypted `ClientCredentials`. */
export const decryptClientCredentials = (
/** A base64-encoded string representing the encrypted JSON payload. **/
encryptedCredentials: string,
/** The recipient's X25519 private key as a hex string. **/
privateKeyHex: string,
): DecryptClientCredentialsResult => {
// Base64 decode the encrypted credentials
const payload = atob(encryptedCredentials);
// Guard and validate this deserialization
const parsedPayload = JSON.parse(payload);
// Convert the private key from a hex string to a `Uint8Array`
const privateKey = hexToBytes(privateKeyHex);
// Convert the ephemeral public key from a hex string to a `Uint8Array`
const publicKey = hexToBytes(parsedPayload.ephemeralPublicKey);
const ivBytes = hexToBytes(parsedPayload.iv);
const ciphertextBytes = hexToBytes(parsedPayload.ciphertext);
const sharedSecret = x25519.getSharedSecret(privateKey, publicKey);
const encryptionKey = hkdf(sha256, sharedSecret, undefined, undefined, 32);
const cipher = gcm(encryptionKey, ivBytes);
const plainTextBytes = cipher.decrypt(ciphertextBytes);
const plaintext = new TextDecoder().decode(plainTextBytes);
let parsed: unknown;
try {
parsed = JSON.parse(plaintext);
} catch {
return { ok: false, error: "Failed to parse decrypted payload as JSON" };
}
// Validate the decrypted payload
if (
typeof parsed !== "object" ||
parsed === null ||
typeof (parsed as Record).accessToken !== "string" ||
typeof (parsed as Record).clientToken !== "string" ||
typeof (parsed as Record).expiresAt !== "string"
) {
return { ok: false, error: "Decrypted payload missing required fields" };
}
return { ok: true, value: parsed as ClientCredentials };
};
/** Generates a new X25519 key pair encryption. */
export const generateKeyPair = (): KeyPair => {
const { secretKey: privateKey, publicKey } = x25519.keygen();
return {
privateKey: bytesToHex(privateKey),
publicKey: bytesToHex(publicKey),
};
};
```
## Initialization parameters
| Property | Type | Required | Description |
| -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sessionToken` | `string` | ✅ | The [session token](/platform/guides/api-and-sdk-credentials#session-token) obtained from your server when [creating a session](/platform/guides/connect-a-customer#create-a-session). |
| `publicKey` | `string` | ✅ | An ephemeral public key generated on the client. See [requirements](#requirements) for details.
The frame uses this key to encrypt the [client credentials](/platform/guides/api-and-sdk-credentials#client-credentials) returned from the connect flow. |
| `channelId` | `string` | ✅ | A unique identifier for the frame generated on your client. This value is attached to each `postMessage` payload to help identify messages.
The format of this string is up to you. |
## Events
All events are dispatched using the message pattern described in the [frames protocol](/platform/frames/overview#frames-protocol#messages). Below are the event payloads specific to the check frame.
### Outbound events
frame->parent
These events are sent from this frame to the parent window.
#### `handshake`
The frame requests that you open a message channel.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "handshake"
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type HandshakeEvent = Message<{
kind: "handshake";
}>;
```
#### `complete`
The frame finished checking the customer’s connection status.
```json Example expandable theme={null}
// Active
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "complete",
"payload": {
"status": "active",
"customer": {
"id": "Y3VzX2FiYzEyMw=="
},
"credentials": "",
"capabilities": {
"ramps": {
"requirements": {}
}
}
}
}
// Pending
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "complete",
"payload": {
"status": "pending"
}
}
// Unavailable
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "complete",
"payload": {
"status": "unavailable"
}
}
// Failed
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "complete",
"payload": {
"status": "failed",
"reason": "Unable to create MoonPay account."
}
}
// Connection required
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "complete",
"payload": {
"status": "connectionRequired",
"credentials": ""
}
}
```
```ts twoslash TypeScript definition expandable theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
enum ConnectionStatus {
/** The connection is valid and can be used. **/
active = "active",
/** The connection could not be completed. This typically occurs for customers whose KYC decisions are delayed. Often these cases are resolved out of band and the customer can connect on a subsequent visit to your app. **/
pending = "pending",
/** The connection cannot be used at the current time. This typically occurs when a KYC-verified customer is using a device or application from a restricted location. **/
unavailable = "unavailable",
/** The connection was not created or is invalid and should not be retried. This usually happens if a customer fails KYC or cannot be onboarded to MoonPay. It can also happen if a customer rejects a connection to your application. In these cases, direct the customer to an alternate flow within your app. **/
failed = "failed",
/** A new connection needs to be created. **/
connectionRequired = "connectionRequired",
}
type PaymentDisclosuresRequirement = {
/** The ISO 3166-1 alpha-3 country code. E.g. `"USA"` */
country: string;
/** The state or province code. E.g. `"NY"` or `"WA"` */
administrativeArea: string;
};
type CustomerCapabilities = {
ramps: {
requirements: {
/** Present only for customers in jurisdictions that require payment disclosures before a transaction (e.g. NY and WA in the USA). **/
paymentDisclosures?: PaymentDisclosuresRequirement;
};
};
};
type ActiveConnection = {
status: ConnectionStatus.active;
/** The MoonPay customer associated with this connection. **/
customer: {
/** The MoonPay customer identifier. **/
id: string;
};
/** Encrypted client credentials containing the accessToken and clientToken. Once decrypted, the value contains a stringified JSON object with the following structure:
*
* {
* "accessToken": "",
* "clientToken": "",
* "expiresAt": "",
* }
*
* - `accessToken`: A token that can be used to make API requests from the client.
* - `clientToken`: A token that can be used to initialize sensitive frames such as Apple Pay.
* - `expiresAt`: An ISO 8601 formatted string indicating when the tokens expire.
**/
credentials: string;
/**
* An [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) formatted string.
*
* Example: `2026-06-15T13:00:23Z`
*/
expiresAt: string;
/** Regulatory capabilities for the connected customer. **/
capabilities: CustomerCapabilities;
};
type PendingConnection = {
status: ConnectionStatus.pending;
};
type UnavailableConnection = {
status: ConnectionStatus.unavailable;
};
type FailedConnection = {
status: ConnectionStatus.failed;
/** A developer-friendly description for the failure. **/
reason: string;
};
type RequiredConnection = {
status: ConnectionStatus.connectionRequired;
/** Encrypted anonymous client credentials. Store these and use the decrypted `clientToken` to initialize the connect frame.
*
* Once decrypted, the value contains a stringified JSON object with the following structure:
* {
* "accessToken": "",
* "clientToken": "",
* "expiresAt": "",
* }
*
* When authentication completes via the connect frame, replace these with the authenticated credentials returned from the connect flow.
**/
credentials: string;
};
type ConnectionCompleteEvent = Message<{
kind: "complete";
payload:
| ActiveConnection
| PendingConnection
| UnavailableConnection
| FailedConnection
| RequiredConnection;
}>;
```
#### `error`
This event dispatches errors that occur in the flow and, if available, provides steps for recovery.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "error",
"payload": {
"code": "ipAddressMismatch",
"message": "The client IP address does not match the session."
}
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type ConnectFlowError = Message<{
kind: "error";
payload: {
code: "ipAddressMismatch" | "invalidSessionToken" | "generic";
/** A developer-facing error message with details on recovery or documentation. This message is not intended to be rendered in UI. */
message: string;
};
}>;
```
### Inbound events
parent->frame
These events are sent from the parent window to this frame.
#### `ack`
Acknowledge the [handshake](#handshake).
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "ack"
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type AckEvent = Message<{
kind: "ack";
}>;
```
# Connect flow
Source: https://dev.moonpay.com/platform/frames/connect
Details on working with the [connect](/platform/guides/connect-a-customer) frame.
## URL
```html theme={null}
https://blocks.moonpay.com/platform/v1/connect
```
## Requirements
### Permissions
The following [permission policies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Permissions-Policy#iframes) are required:
* `accelerometer`
* `autoplay`
* `camera`
* `encrypted-media`
* `gyroscope`
```tsx Example theme={null}
```
### Key exchange
Credentials returned from the frame are encrypted to protect their content since they are sent over `postMessage`. You need to generate an [X25519](https://datatracker.ietf.org/doc/html/rfc7748#section-5) keypair and pass the public key into the frame. The frame uses your public key to encrypt the payload, ensuring only you can read it with your private key.
Never persist the private key to disk or storage. Hold it in memory only for
the duration of the session.
The frame uses the [@noble/curves](https://github.com/paulmillr/noble-curves) library internally. On web and React Native, you can use this same library to generate your keypair and handle decryption. For native platforms, use a compatible utility like [CryptoKit](https://developer.apple.com/documentation/cryptokit/curve25519) on iOS or [KeyPairGenerator](https://developer.android.com/reference/java/security/KeyPairGenerator) on Android.
The following example shows how to generate a keypair and decrypt credentials using `@noble/curves`. You'll want to add your own error handling and input validation for production use.
```sh pnpm theme={null}
pnpm i @noble/curves @noble/hashes @noble/ciphers
```
```sh bun theme={null}
bun add @noble/curves @noble/hashes @noble/ciphers
```
```sh npm theme={null}
npm i @noble/curves @noble/hashes @noble/ciphers
```
```ts crypto.ts theme={null}
// An example module for generating keypairs and decrypting client credentials.
// This should not be used as-is in production!
import { gcm } from "@noble/ciphers/aes.js";
import { x25519 } from "@noble/curves/ed25519.js";
import { hkdf } from "@noble/hashes/hkdf.js";
import { sha256 } from "@noble/hashes/sha2.js";
/** The credentials returned from the connect flow. */
export type ClientCredentials = {
/** A JWT used to authenticate client requests to the Moonpay API. */
accessToken: string;
/** A JWT used to initialize authenticated frames such as Apple Pay. */
clientToken: string;
/** An [ISO 8601 timestamp](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString) representing the expiration time of the tokens. */
expiresAt: string;
};
/** X25519 `privateKey` and `publicKey`as hex strings. */
export type KeyPair = Record<"privateKey" | "publicKey", string>;
export type DecryptClientCredentialsResult =
| { ok: true; value: ClientCredentials }
| { ok: false; error: string };
const hexToBytes = (hex: string): Uint8Array => {
if (hex.length % 2 !== 0) {
throw new Error("Invalid hex string");
}
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return bytes;
};
const bytesToHex = (bytes: Uint8Array): string => {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
};
/** Decrypts an encrypted `ClientCredentials`. */
export const decryptClientCredentials = (
/** A base64-encoded string representing the encrypted JSON payload. **/
encryptedCredentials: string,
/** The recipient's X25519 private key as a hex string. **/
privateKeyHex: string,
): DecryptClientCredentialsResult => {
// Base64 decode the encrypted credentials
const payload = atob(encryptedCredentials);
// Guard and validate this deserialization
const parsedPayload = JSON.parse(payload);
// Convert the private key from a hex string to a `Uint8Array`
const privateKey = hexToBytes(privateKeyHex);
// Convert the ephemeral public key from a hex string to a `Uint8Array`
const publicKey = hexToBytes(parsedPayload.ephemeralPublicKey);
const ivBytes = hexToBytes(parsedPayload.iv);
const ciphertextBytes = hexToBytes(parsedPayload.ciphertext);
const sharedSecret = x25519.getSharedSecret(privateKey, publicKey);
const encryptionKey = hkdf(sha256, sharedSecret, undefined, undefined, 32);
const cipher = gcm(encryptionKey, ivBytes);
const plainTextBytes = cipher.decrypt(ciphertextBytes);
const plaintext = new TextDecoder().decode(plainTextBytes);
let parsed: unknown;
try {
parsed = JSON.parse(plaintext);
} catch {
return { ok: false, error: "Failed to parse decrypted payload as JSON" };
}
// Validate the decrypted payload
if (
typeof parsed !== "object" ||
parsed === null ||
typeof (parsed as Record).accessToken !== "string" ||
typeof (parsed as Record).clientToken !== "string" ||
typeof (parsed as Record).expiresAt !== "string"
) {
return { ok: false, error: "Decrypted payload missing required fields" };
}
return { ok: true, value: parsed as ClientCredentials };
};
/** Generates a new X25519 key pair encryption. */
export const generateKeyPair = (): KeyPair => {
const { secretKey: privateKey, publicKey } = x25519.keygen();
return {
privateKey: bytesToHex(privateKey),
publicKey: bytesToHex(publicKey),
};
};
```
The following example shows how to generate a keypair and decrypt credentials using `@noble/curves`. You'll want to add your own error handling and input validation for production use.
In React Native, yuo will need a [polyfill for `getRandomValues`](https://github.com/LinusU/react-native-get-random-values) ([MDN](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues)) which is only available in browsers.
```sh pnpm theme={null}
pnpm i react-native-get-random-values @noble/curves @noble/hashes @noble/ciphers
```
```sh bun theme={null}
bun add react-native-get-random-values @noble/curves @noble/hashes @noble/ciphers
```
```sh npm theme={null}
npm i react-native-get-random-values @noble/curves @noble/hashes @noble/ciphers
```
```ts crypto.ts theme={null}
// An example module for generating keypairs and decrypting client credentials.
// This should not be used as-is in production!
import { gcm } from "@noble/ciphers/aes.js";
import { x25519 } from "@noble/curves/ed25519.js";
import { hkdf } from "@noble/hashes/hkdf.js";
import { sha256 } from "@noble/hashes/sha2.js";
// React Native polyfill for getRandomValues
import "react-native-get-random-values";
/** The credentials returned from the connect flow. */
export type ClientCredentials = {
/** A JWT used to authenticate client requests to the Moonpay API. */
accessToken: string;
/** A JWT used to initialize authenticated frames such as Apple Pay. */
clientToken: string;
/** An [ISO 8601 timestamp](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString) representing the expiration time of the tokens. */
expiresAt: string;
};
/** X25519 `privateKey` and `publicKey`as hex strings. */
export type KeyPair = Record<"privateKey" | "publicKey", string>;
export type DecryptClientCredentialsResult =
| { ok: true; value: ClientCredentials }
| { ok: false; error: string };
const hexToBytes = (hex: string): Uint8Array => {
if (hex.length % 2 !== 0) {
throw new Error("Invalid hex string");
}
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return bytes;
};
const bytesToHex = (bytes: Uint8Array): string => {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
};
/** Decrypts an encrypted `ClientCredentials`. */
export const decryptClientCredentials = (
/** A base64-encoded string representing the encrypted JSON payload. **/
encryptedCredentials: string,
/** The recipient's X25519 private key as a hex string. **/
privateKeyHex: string,
): DecryptClientCredentialsResult => {
// Base64 decode the encrypted credentials
const payload = atob(encryptedCredentials);
// Guard and validate this deserialization
const parsedPayload = JSON.parse(payload);
// Convert the private key from a hex string to a `Uint8Array`
const privateKey = hexToBytes(privateKeyHex);
// Convert the ephemeral public key from a hex string to a `Uint8Array`
const publicKey = hexToBytes(parsedPayload.ephemeralPublicKey);
const ivBytes = hexToBytes(parsedPayload.iv);
const ciphertextBytes = hexToBytes(parsedPayload.ciphertext);
const sharedSecret = x25519.getSharedSecret(privateKey, publicKey);
const encryptionKey = hkdf(sha256, sharedSecret, undefined, undefined, 32);
const cipher = gcm(encryptionKey, ivBytes);
const plainTextBytes = cipher.decrypt(ciphertextBytes);
const plaintext = new TextDecoder().decode(plainTextBytes);
let parsed: unknown;
try {
parsed = JSON.parse(plaintext);
} catch {
return { ok: false, error: "Failed to parse decrypted payload as JSON" };
}
// Validate the decrypted payload
if (
typeof parsed !== "object" ||
parsed === null ||
typeof (parsed as Record).accessToken !== "string" ||
typeof (parsed as Record).clientToken !== "string" ||
typeof (parsed as Record).expiresAt !== "string"
) {
return { ok: false, error: "Decrypted payload missing required fields" };
}
return { ok: true, value: parsed as ClientCredentials };
};
/** Generates a new X25519 key pair encryption. */
export const generateKeyPair = (): KeyPair => {
const { secretKey: privateKey, publicKey } = x25519.keygen();
return {
privateKey: bytesToHex(privateKey),
publicKey: bytesToHex(publicKey),
};
};
```
## Initialization parameters
| Property | Type | Required | Description |
| ------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `clientToken` | `string` | ✅ | The anonymous client token obtained after decrypting the `credentials` returned by the [check frame](/platform/frames/check) when the status is `connectionRequired`. |
| `publicKey` | `string` | ✅ | An ephemeral public key generated on the client. See [requirements](#requirements) for details.
The frame uses this key to encrypt the [client credentials](/platform/guides/api-and-sdk-credentials#client-credentials) returned from the connect flow. |
| `channelId` | `string` | ✅ | A unique identifier for the frame generated on your client. This value is attached to each `postMessage` payload to help identify messages.
The format of this string is up to you. |
| `theme` | `string` | | Pass `dark` or `light` to force a specific appearance. If you omit this, the frame uses the user's system appearance. |
## Events
All events are dispatched using the message pattern described in the [frames protocol](/platform/frames/overview#frames-protocol#messages). Below are the event payloads specific to the connect frame.
### Outbound events
frame->parent
These events are sent from this frame to the parent window.
#### `handshake`
The frame requests that you open a message channel.
```json Example theme={null}
{
"version": 2,
"meta": {
"channelId": "ch_1"
},
"kind": "handshake"
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type HandshakeEvent = Message<{
kind: "handshake";
}>;
```
#### `ready`
The frame finished loading and the UI is fully rendered. You can use this to coordinate UI transitions, but you don’t need it to complete the flow.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "ready"
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type ReadyEvent = Message<{
kind: "ready";
}>;
```
#### `complete`
The connect flow finished. If it succeeds, the payload includes encrypted client credentials.
```json Example expandable theme={null}
// Active
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "complete",
"payload": {
"status": "active",
"customer": {
"id": "Y3VzX2FiYzEyMw=="
},
"credentials": "",
"capabilities": {
"ramps": {
"requirements": {}
}
}
}
}
// Pending
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "complete",
"payload": {
"status": "pending"
}
}
// Unavailable
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "complete",
"payload": {
"status": "unavailable"
}
}
// Failed
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "complete",
"payload": {
"status": "failed",
"reason": "Unable to create MoonPay account."
}
}
```
```ts twoslash TypeScript definition expandable theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
enum ConnectionStatus {
/** The connection is valid and can be used. **/
active = "active",
/** The connection could not be completed. This typically occurs for customers whose KYC decisions are delayed. Often these cases are resolved out of band and the customer can connect on a subsequent visit to your app. **/
pending = "pending",
/** The connection cannot be used at the current time. This typically occurs when a KYC-verified customer is using a device or application from a restricted location. **/
unavailable = "unavailable",
/** The connection was not created or is invalid and should not be retried. This usually happens if a customer fails KYC or cannot be onboarded to MoonPay. It can also happen if a customer rejects a connection to your application. In these cases, direct the customer to an alternate flow within your app. **/
failed = "failed",
}
type PaymentDisclosuresRequirement = {
/** The ISO 3166-1 alpha-3 country code. E.g. `"USA"` */
country: string;
/** The state or province code. E.g. `"NY"` or `"WA"` */
administrativeArea: string;
};
type CustomerCapabilities = {
ramps: {
requirements: {
/** Present only for customers in jurisdictions that require payment disclosures before a transaction (e.g. NY and WA in the USA). **/
paymentDisclosures?: PaymentDisclosuresRequirement;
};
};
};
type ActiveConnection = {
status: ConnectionStatus.active;
/** The MoonPay customer associated with this connection. **/
customer: {
/** The MoonPay customer identifier. **/
id: string;
};
/** Encrypted client credentials containing the accessToken and clientToken. Once decrypted, the value contains a stringified JSON object with the following structure:
*
* {
* "accessToken": "",
* "clientToken": "",
* "expiresAt": "",
* }
*
* - `accessToken`: A token that can be used to make API requests from the client.
* - `clientToken`: A token that can be used to initialize sensitive frames such as Apple Pay.
* - `expiresAt`: An ISO 8601 formatted string indicating when the tokens expire.
**/
credentials: string;
/**
* An [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) formatted string.
*
* Example: `2026-06-15T13:00:23Z`
*/
expiresAt: string;
/** Regulatory capabilities for the connected customer. **/
capabilities: CustomerCapabilities;
};
type PendingConnection = {
status: ConnectionStatus.pending;
};
type UnavailableConnection = {
status: ConnectionStatus.unavailable;
};
type FailedConnection = {
status: ConnectionStatus.failed;
/** A developer-friendly description for the failure. **/
reason: string;
};
type ConnectionCompleteEvent = Message<{
kind: "complete";
payload:
| ActiveConnection
| PendingConnection
| UnavailableConnection
| FailedConnection;
}>;
```
#### `error`
This event dispatches errors that occur in the flow and, if available, provides steps for recovery.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "error",
"payload": {
"code": "ipAddressMismatch",
"message": "The client IP address does not match the session."
}
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type ConnectFlowError = Message<{
kind: "error";
payload: {
code: "ipAddressMismatch" | "invalidSessionToken" | "generic";
/** A developer-facing error message with details on recovery or documentation. This message is not intended to be rendered in UI. */
message: string;
};
}>;
```
### Inbound events
parent->frame
These events are sent from the parent window to this frame.
#### `ack`
Acknowledge the [handshake](#handshake).
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "ack"
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type AckEvent = Message<{
kind: "ack";
}>;
```
# Overview
Source: https://dev.moonpay.com/platform/frames/overview
Protocol details for co-branded and headless [frames](/platform/overview/core-concepts#frames).
Frames communicate with your app using `postMessage`. Each frame defines its own events and payloads. If you use the MoonPay SDK, it handles message serialization, validation, and dispatch for you. If you integrate frames directly (for example, with your own WebView or iframe wrapper), this page documents the shared protocol so you know what to implement.
## Libraries
Coming soon!
If you’re not using the SDK, MoonPay libraries can help you manage `postMessage` communication on web and mobile (via WebViews). Until those ship, use the protocol details below to build your own bridge.
## Frames protocol
### Messages
Frames use an event-driven model. You and the frame exchange events using a strongly typed message structure. Treat these messages like an API contract between two parties: the parent window (or app) and the frame.
#### Transport
In both web and mobile apps, frames send and receive messages over `postMessage` as **stringified JSON**.
If you integrate directly, you handle serialization and validation yourself. If you use the SDK, it handles this for you.
#### Format
Every message follows the same envelope format. The `kind` tells you what event you’re handling, and the `payload` shape depends on that `kind`.
| Field | Type | Required | Description |
| ---------------- | ---------------------- | -------- | ------------------------------------------------------------------------------------- |
| `version` | `2` | ✅ | The frames protocol version.
This value will always be `2`. |
| `meta` | `object` | ✅ | Transport metadata for the message. |
| `meta.channelId` | `string` | ✅ | A unique identifier for messages between frames. |
| `kind` | `enum` | ✅ | The name of the event. |
| `payload` | `object` | | An object containing the data for different events. This value depends on the `kind`. |
```json Example payload (deserialized) theme={null}
{
"version": 2,
"meta": {
"channelId": "some_unique_value"
},
"kind": "example",
"payload": {
"example": "an example payload"
}
}
```
```json Example payload (serialized) theme={null}
"{\"version\":\"2\",\"meta\":{\"channelId\":\"some_unique_value\"},\"kind\":\"example\",\"payload\":{\"example\":\"an example payload\"}}"
```
#### Validation and safety checks
You’ll have an easier time (and fewer mysterious bugs) if you validate messages like you would any external input:
* **Check the origin and sender**: only accept messages from the frame origin(s) you expect, and ignore everything else.
* **Parse defensively**: `postMessage` delivers strings; treat JSON parsing as fallible and handle errors.
* **Verify the envelope**: reject messages that don’t match the expected `version`, don’t include a `meta.channelId`, or use an unknown `kind`.
* **Route by `channelId`**: if you can have multiple frames open at once, use `meta.channelId` to keep messages from crossing streams.
### Lifecycle
Each frame follows the same basic handshake lifecycle to establish a bi-directional channel with your app. The SDK manages this automatically and gives you an events callback; in a direct integration, you implement these steps yourself.
```mermaid theme={null}
sequenceDiagram
%% autonumber
participant a as App
participant f as Frame
%% -----------------
activate f
a ->> a: Generate a channel ID
a ->> f: Inject the WebView or iframe with URL params including a channelId.
alt If no handshake request received in 5s
a -x a: Handle loading error
end
f ->> a: Send handshake w/channel ID
a ->> f: Reply with ack (w/channel ID)
f ->> f: Validate params
break Invalid params
f -x a: Send error and terminate the connection
end
f <<->> a: Bi-directional channel opened
deactivate f
```
In practice, you’ll typically: generate a channel, wait for the handshake, ack it, then start handling `kind` events for that channel. If the handshake doesn’t arrive quickly, fail fast and show a useful error to the developer (or retry, if that fits your app).
# Reset
Source: https://dev.moonpay.com/platform/frames/reset
Clear a user's session and authentication state
The reset frame is a headless page hosted on a MoonPay domain. Use it to clear the user's authentication tokens stored on MoonPay's domain, allowing partners to log users out. The frame communicates completion or errors to the parent window via postMessage.
## URL
```html theme={null}
https://blocks.moonpay.com/platform/v1/reset
```
## Initialization parameters
| Parameter | Type | Required | Description |
| ----------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `channelId` | `string` | ✅ | A unique identifier for the frame generated on your client. This value is attached to each `postMessage` payload to help identify messages.
The format of this string is up to you. |
| `apiKey` | `string` | | Your publishable API key. |
| `language` | `string` | | Language code for localization. |
| `theme` | `string` | | `light` or `dark`. |
## Events
All events are dispatched using the message pattern described in the [frames protocol](/frames/overview#frames-protocol#messages). Below are the event payloads specific to the reset frame.
### Outbound events
frame->parent
These events are sent from this frame to the parent window.
#### `handshake`
Sent when the frame loads. The parent must respond with `ack`.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "handshake"
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type HandshakeEvent = Message<{
kind: "handshake";
}>;
```
#### `complete`
Sent when the user's session has been successfully cleared.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "complete"
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type ResetCompleteEvent = Message<{
kind: "complete";
}>;
```
#### `error`
Sent when the reset fails.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "error",
"payload": {
"code": "generic",
"message": "Failed to clear session"
}
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type ResetErrorEvent = Message<{
kind: "error";
payload: {
code: "generic";
/** A developer-facing error message with details on recovery or documentation. This message is not intended to be rendered in UI. */
message: string;
};
}>;
```
### Inbound events
parent->frame
These events are sent from the parent window to this frame.
#### `ack`
Must be sent in response to `handshake`. The frame will not proceed until it receives this.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "ack"
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type AckEvent = Message<{
kind: "ack";
}>;
```
# Widget
Source: https://dev.moonpay.com/platform/frames/widget
Details on working with the [widget](/platform/guides/pay-with-widget) frame.
## URL
```html theme={null}
https://blocks.moonpay.com/platform/v1/widget
```
## Requirements
### Permissions
The `payment` [permission policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Permissions-Policy#iframes) is required.
```tsx Example theme={null}
```
## Initialization parameters
| Property | Type | Required | Description |
| ---------------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `flow` | `string` | ✅ | The transaction flow. Currently only `buy` is supported. |
| `clientToken` | `string` | ✅ | The [client token](/platform/guides/api-and-sdk-credentials#client-token) returned from the [connect flow](/platform/guides/connect-a-customer). |
| `quoteSignature` | `string` | ✅ | The quote `signature` from the quote endpoint. Pass `signature` as returned. |
| `channelId` | `string` | ✅ | A unique identifier for the frame generated on your client. This value is attached to each `postMessage` payload to help identify messages.
The format of this string is up to you. |
## Events
All events are dispatched using the message pattern described in the [frames protocol](/platform/frames/overview#frames-protocol#messages). Below are the event payloads specific to the widget frame.
### Outbound events
frame->parent
These events are sent from this frame to the parent window.
#### `handshake`
The frame requests that you open a message channel.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "handshake"
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type HandshakeEvent = Message<{
kind: "handshake";
}>;
```
#### `ready`
The widget finished loading and the UI is visible.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "ready"
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type ReadyEvent = Message<{
kind: "ready";
}>;
```
#### `transactionCreated`
A transaction has been initiated. The customer may still need to complete additional steps such as 3-D Secure authorization.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "transactionCreated",
"payload": {
"transaction": {
"id": "txn_01",
"status": "waitingAuthorization"
}
}
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type TransactionCreatedEvent = Message<{
kind: "transactionCreated";
payload: {
transaction: {
/** The MoonPay identifier for this transaction. **/
id: string;
/** The current status of the transaction. **/
status: string;
};
};
}>;
```
#### `complete`
The transaction has reached a terminal state. Use the transaction ID to track status updates via polling or webhooks.
```json Example (success) theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "complete",
"payload": {
"transaction": {
"id": "txn_01",
"status": "complete"
}
}
}
```
```json Example (fail) theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "complete",
"payload": {
"transaction": {
"status": "failed",
"failureReason": "The payment could not be completed."
}
}
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
enum TransactionStatus {
complete = "complete",
pending = "pending",
failed = "failed",
}
type Transaction =
| {
/** The MoonPay identifier for this transaction. **/
id: string;
/** The status of the transaction. **/
status: TransactionStatus.complete | TransactionStatus.pending;
}
| {
status: TransactionStatus.failed;
/** A developer-friendly error message detailing the reason for the transaction failure. **/
failureReason: string;
};
type WidgetCompleteEvent = Message<{
kind: "complete";
payload: {
transaction: Transaction;
};
}>;
```
#### `error`
An error occurred in the widget flow.
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "error",
"payload": {
"code": "apiError",
"message": "Failed to build widget URL."
}
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type WidgetError = Message<{
kind: "error";
payload: {
code: "configurationError" | "apiError" | "generic";
/** A developer-facing error message. Not intended for end-user display. */
message: string;
};
}>;
```
### Inbound events
parent->frame
These events are sent from the parent window to this frame.
#### `ack`
Acknowledge the [handshake](#handshake).
```json Example theme={null}
{
"version": 2,
"meta": { "channelId": "ch_1" },
"kind": "ack"
}
```
```ts twoslash TypeScript definition theme={null}
type Message = T & {
version: 2;
meta: { channelId: string };
};
type AckEvent = Message<{
kind: "ack";
}>;
```
# API and SDK credentials
Source: https://dev.moonpay.com/platform/guides/api-and-sdk-credentials
Understand the tokens and credentials you need for your integration.
During the preview, we will work with you directly to set up your account and
credentials.
## Server credentials
### Secret key
A server-to-server credential passed as an
[`Authorization` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Authorization).
Keep your secret key secure and never expose it. Never commit it to your
codebase or send it to your frontend.
```text Test mode theme={null}
Authorization: "Api-Key sk_test_123"
```
```text Live mode theme={null}
Authorization: "Api-Key sk_live_123"
```
## Client credentials
Use these to make API requests from your frontend and initialize frames for
sensitive actions.
Never persist client credentials to disk or storage. Hold them in memory only.
Use your server to get a new `sessionToken` on each app visit. When you
receive new credentials (for example, authenticated credentials after a
connect flow), replace the previously stored ones.
### Session token
A token you create on your server and send to your frontend. You use it to start a [connect flow](/platform/guides/connect-a-customer).
### Access token
A token returned from the [check frame](/platform/frames/check) and the [connect frame](/platform/frames/connect). Use it to make API requests from your frontend (via the SDK or directly), such as:
* Getting quotes
* Listing payment methods
* Listing transactions
Anonymous credentials returned when `connectionRequired` give you a scoped access token. Authenticated credentials returned after the connect flow give you a fully-privileged one. Always replace stored credentials when you receive new ones.
This token is intended for client use and shouldn’t be persisted to disk.
### Client token
A token returned from the [check frame](/platform/frames/check) and the [connect frame](/platform/frames/connect). Use it to initialize subsequent frames (for example, the Apple Pay frame or the connect frame). Within frames, this token is used to make authenticated requests.
When `connectionRequired` is returned, pass this anonymous `clientToken` to the connect frame. After authentication, replace it with the `clientToken` from the authenticated credentials.
This token is intended for client use and shouldn’t be persisted to disk.
# Connect a customer
Source: https://dev.moonpay.com/platform/guides/connect-a-customer
Connect a customer's MoonPay account to your app.
Connect a customer's MoonPay account so you can list payment methods, get executable quotes, and execute transactions. Before you start, review the [requirements](/platform/overview/requirements).
## Prerequisites
* A server that can create session tokens with your secret key.
* A client that can render frames (SDK or manual integration).
Connecting is a one or two-step process depending on whether the customer is new or returning:
* If the customer has never connected, render the co-branded connect frame.
* If the customer has connected before, first check whether their connection is still valid. You only need to render UI again if the connection has [expired](#connection-required).
For all cases, first [create a session](#create-a-session), then [check if a connection exists](#check-connection). If a connection is required, initialize the [connect flow](#connect-flow).
```mermaid theme={null}
sequenceDiagram
autonumber
actor c as customer
participant s as Your server
box Your app
participant fe as Your frontend
participant cf as MoonPay frame (check connection)
participant cnf as MoonPay frame (connection UI)
end
participant api as MoonPay API
%% -----------------
c ->> fe: Customer visits app and signs in
fe ->> s: Request session token
s ->> api: POST /platform/v1/sessions
api ->> s: { sessionToken: "c3N0XzAwMQ" }
s ->> fe: Send session token
activate cf
fe ->> cf: getConnection()
cf -->> fe: Dispatch result + credentials
deactivate cf
fe ->> fe: Store credentials
alt active connection
fe ->> fe: Continue to buy flow
else requires connect flow
activate cnf
fe ->> cnf: connect(clientToken)
c ->> cnf: Sign in or onboard to MoonPay
cnf -->> fe: Dispatch result + credentials
deactivate cnf
fe ->> fe: Replace stored credentials
alt active connection?
fe ->> fe: Continue to buy flow
end
end
```
## Create a session
To initiate a session on your server, provide:
1. A unique identifier from your system for the customer (`externalCustomerId`).
2. The IP address of the customer's device. This is used across frames to ensure the integrity of the session.
Once initiated, you receive a [`sessionToken`](/platform/guides/api-and-sdk-credentials#session-token) to send to your frontend.
```ts Create session token theme={null}
// Server-side code example
const url = "https://api.moonpay.com/platform/v1/sessions";
const res = await fetch(url, {
headers: {
"Content-Type": "application/json",
Authorization: "Api-Key sk_test_123",
},
method: "POST",
body: JSON.stringify({
externalCustomerId: "your_user_id",
deviceIp: "...ip address from client",
}),
});
console.log(await res.json());
```
```json Result theme={null}
{
"sessionToken": "c3N0XzAwMQ=="
}
```
Check the [API reference](/api-reference/platform/endpoints/sessions/create)
for detailed usage.
## Check connection
Using the `sessionToken`, check if the customer already has an active connection. No UI appears in this step, but it runs in a frame. You can check the session using the SDK or [manually](/platform/frames/check).
The check frame always returns encrypted credentials. Hold them in memory only and do not persist them, regardless of the status returned. For an `active` connection these are authenticated credentials. For `connectionRequired`, these are anonymous credentials whose `clientToken` you pass into the connect flow.
```ts Check the connection theme={null}
import { createClient } from "@moonpay/platform";
// Create the client with your session token
const clientResult = createClient({
sessionToken: "c3N0XzAwMQ==", // The session token from your server
});
if (!clientResult.ok) {
// Handle error creating client
}
const client = clientResult.value;
// Check if the customer has an active connection
const connectionResult = await client.getConnection();
if (!connectionResult.ok) {
// Handle error
}
console.log(connectionResult.value);
```
```ts Result (active) theme={null}
{
status: "active",
customer: {
id: "Y3VzX2FiYzEyMw=="
},
credentials: {
accessToken: "c2F0XzAwMQ==",
clientToken: "c2N0XzAwMQ=="
}
}
```
```ts Result (requires connection) theme={null}
{
status: "connectionRequired",
credentials: {
accessToken: "c2F0XzAwMQ==",
clientToken: "c2N0XzAwMQ=="
}
}
```
The SDK injects an invisible frame to check the connection. If you are integrating without the SDK, load the frame directly and listen for the [`postMessage` events](/platform/frames/check#events).
## Low-friction authentication
You can simplify login for returning customers by passing the optional `email` and `phoneNumber` parameters when you [create a session](/api-reference/platform/endpoints/sessions/create).
If these values match an existing MoonPay account, the [connect frame](#connect-flow) skips the full login and prompts the customer to enter an OTP code sent to their phone.
## Connect flow
If you need to create or revalidate a connection, initialize the connect flow with the `clientToken` from the anonymous credentials returned by the check frame. The SDK provides hooks to coordinate rendering the connect UI (for example, to animate a modal or sheet). You can also do this [manually](/platform/frames/connect).
When the connect flow completes, replace the anonymous credentials from the check step with the authenticated credentials from the `complete` event.
The resulting connection has one of the following [statuses](#connection-statuses): `active`, `pending`, `unavailable`, or `failed`.
In mobile apps, present the connect flow as a full sheet. See [presentation
and appearance](/platform/guides/presentation-and-appearance) for UI guidance.
```ts Initialize connect with SDK theme={null}
import { createClient, type ConnectEvent } from "@moonpay/platform";
// Create the client
const clientResult = createClient({
sessionToken: "c3N0XzAwMQ==", // The session token from your server
});
if (!clientResult.ok) {
// Handle error creating client
}
const client = clientResult.value;
// Initialize the connect flow
const connectResult = await client.connect({
container: connectContainer, // DOM element to render the connect frame
theme: { appearance: "dark" }, // Optional: force dark or light mode
onEvent: (event: ConnectEvent) => {
switch (event.kind) {
case "ready":
// The frame is ready and rendered
break;
case "complete":
// The connection is complete
console.log(event.connection);
// { status: "active", customer: { id: "..." }, credentials: { accessToken: "...", clientToken: "..." } }
// You can unmount the frame using the reference in the payload
event.payload.frame.dispose();
break;
case "error":
// Handle error
console.error(event.payload.message);
break;
}
},
});
// If there is an error setting up the connect frame, no events will be
// dispatched via the `onEvent` callback and you will receive an error here.
if (!connectResult.ok) {
// Handle error
}
// If the frame is successfully mounted, the returned value provides a reference for disposal at any time.
connectResult.value.dispose();
```
```ts Result (complete event) theme={null}
{
kind: "complete",
connection: {
status: "active",
customer: {
id: "Y3VzX2FiYzEyMw=="
},
credentials: {
accessToken: "c2F0XzAwMQ==",
clientToken: "c2N0XzAwMQ=="
},
capabilities: {
ramps: {
requirements: {}
}
}
},
payload: {
frame: {
dispose: [Function]
}
}
}
```
When you receive the `complete` event, the payload includes authenticated client
credentials and a `customer` object identifying the connected MoonPay customer.
Discard any anonymous credentials from the check step and use the new ones instead.
Use them to make scoped API calls from your frontend (for example, listing
payment methods and getting quotes). See [Pay with Apple
Pay](/platform/guides/pay-with-apple-pay) for a complete example flow.
### Capabilities
The `complete` event also includes a `capabilities` object that describes regulatory requirements for the connected customer. You can use this to customize your UI before initiating a transaction; for example, determining whether a payment disclosure is required.
```ts US User highlight={14-16} theme={null}
{
kind: "complete",
connection: {
status: "active",
customer: {
id: "Y3VzX2FiYzEyMw=="
},
credentials: {
accessToken: "c2F0XzAwMQ==",
clientToken: "c2N0XzAwMQ=="
},
capabilities: {
ramps: {
// No payment disclosure required for this customer
requirements: {}
}
}
},
payload: {
frame: {
dispose: [Function]
}
}
}
```
```ts US User (NY) highlight={13-20} theme={null}
{
kind: "complete",
connection: {
status: "active",
customer: {
id: "Y3VzX2FiYzEyMw=="
},
credentials: {
accessToken: "c2F0XzAwMQ==",
clientToken: "c2N0XzAwMQ=="
},
capabilities: {
ramps: {
requirements: {
paymentDisclosures: {
country: "USA",
administrativeArea: "NY"
}
}
}
}
},
payload: {
frame: {
dispose: [Function]
}
}
}
```
```ts US User (WA) highlight={13-20} theme={null}
{
kind: "complete",
connection: {
status: "active",
customer: {
id: "Y3VzX2FiYzEyMw=="
},
credentials: {
accessToken: "c2F0XzAwMQ==",
clientToken: "c2N0XzAwMQ=="
},
capabilities: {
ramps: {
requirements: {
paymentDisclosures: {
country: "USA",
administrativeArea: "WA"
}
}
}
}
},
payload: {
frame: {
dispose: [Function]
}
}
}
```
If `capabilities.ramps.requirements.paymentDisclosures` is present, you must display the required disclosure to the customer before they can complete a transaction. See [Going Live](/platform/overview/going-live#payment-disclosures) for the exact text to render and presentation requirements.
[Client
credentials](/platform/guides/api-and-sdk-credentials#client-credentials)
should never be persisted and should only be held in memory as this could pose
a security risk.
## Connection statuses
### Active
An `active` status means the connection is valid and can be used. Active connections typically remain live for 180 days without revalidation. If the connection expires, refresh it via the connect flow.
### Unavailable
An `unavailable` status means the connection cannot be used at the current time. This typically occurs when a KYC-verified customer is using a device or application from a restricted location.
### Pending
A `pending` status typically occurs for customers whose KYC decisions are delayed. Often these cases are resolved out of band and the customer can connect on a subsequent visit to your app.
### Failed
A `failed` status is a terminal state. This usually happens if the customer fails KYC or cannot be onboarded to MoonPay. It can also happen if the customer rejects the connection. In these cases, direct the customer to an alternate flow in your app.
### Connection required
The `connectionRequired` status is returned from the [check frame](#check-connection) as a signal to guide the customer through the full [connect flow](#connect-flow). This status is returned for new customers who have not connected to your app, or returning customers whose connections have expired. The response also includes anonymous `credentials` — keep them only in memory and use the `clientToken` to initialize the connect flow.
# Handle challenges
Source: https://dev.moonpay.com/platform/guides/handling-challenges
Detect and respond to challenges.
This guide shows you how to detect and handle challenges that require customer authentication or verification before a transaction can continue.
## Prerequisites
* A [connected customer](/platform/guides/connect-a-customer).
* A UI surface where you can render frames (WebView on mobile, iframe on web).
## When challenges appear
Challenges are extra steps a customer must complete before MoonPay can continue an
action. You most commonly see challenges when you:
* Request an executable quote and the customer needs to upgrade authentication or limits.
* Execute a transaction and the customer needs to complete additional authentication
or verification (for example, Strong Customer Authentication / 3D Secure or
identity verification).
## Where challenges show up
Challenges are returned as part of API or SDK results. For example, an
executable quote may include a `challenge` field:
```json Example challenge payload theme={null}
{
"id": "cV8wMDE=",
"signature": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9...",
"expiresAt": "2029-07-21T17:32:28Z",
"challenge": {
"kind": "frame",
"id": "ch_123e4567-e89b-12d3-a456-426614174000"
}
}
```
## How to handle a challenge
1. **Detect the challenge**: If a result includes a `challenge`, treat the
current action as blocked until the challenge completes.
2. **Render the challenge UI**:
* If the challenge is a **frame challenge** (`kind: "frame"`), render a
dedicated frame (WebView on mobile, iframe on web) and handle events the
same way you do for other frames.
* If the challenge is a **first-party challenge**, the SDK/API response
includes instructions for how to proceed.
3. **Retry the original action**: Once the challenge completes successfully,
request a new executable quote (if needed) and continue the flow.
## Implementation tips
* **Use a full-screen surface on mobile**: Challenge flows often involve
authentication or verification, so treat them like a separate screen or full
sheet.
* **Validate `postMessage` events**: If you integrate frames manually, validate
origin and message shape. The [frames protocol](/platform/frames/overview) documents
the shared envelope format.
* **Handle cancellation and timeouts**: If the customer closes the challenge or it
fails, show a clear next step (retry, choose a different payment method, or
exit the flow).
# Android
Source: https://dev.moonpay.com/platform/guides/manual-integration/android
Manual frame integration for Android using WebView and Kotlin.
Use `WebView` to embed frames in native Android applications. The WebView communicates with frames via JavaScript interfaces and `evaluateJavascript`.
Read the [manual integration
overview](/platform/guides/manual-integration/overview) for core concepts
before you continue.
## Setup
### Dependencies
The examples below use [Tink](https://github.com/google/tink) for cryptographic operations, but you can use any library that supports X25519 and AES-GCM.
```kotlin theme={null}
// build.gradle.kts (app level)
dependencies {
implementation("com.google.crypto.tink:tink-android:1.12.0")
}
```
### Key generation
```kotlin theme={null}
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
```kotlin theme={null}
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:
```kotlin theme={null}
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) {
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](/overview/test-mode#apple-pay), 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`:
```kotlin theme={null}
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](/platform/frames/check) for event details.
### Check fragment
```kotlin theme={null}
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
```kotlin theme={null}
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](/platform/frames/connect) for event details.
### Connect fragment
```kotlin theme={null}
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
```kotlin theme={null}
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](/platform/frames/add-card) for event details.
### What you'll need
Before you initialize the add card frame, you need:
1. A `clientToken` from a successful [connect flow](#connect-frame)
### Add Card fragment
```kotlin theme={null}
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
```kotlin theme={null}
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](/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 fragment
```kotlin theme={null}
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:
```kotlin theme={null}
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
```kotlin theme={null}
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](/platform/frames/widget) 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](/platform/guides/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](#connect-frame)
2. A valid [quote signature](/api-reference/platform/endpoints/quotes/get) for the transaction
### Widget fragment
```kotlin theme={null}
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
```kotlin theme={null}
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()
}
}
```
# Flutter
Source: https://dev.moonpay.com/platform/guides/manual-integration/flutter
Manual frame integration for Flutter using webview_flutter.
Use `webview_flutter` to embed frames in Flutter applications. The WebView communicates with your Dart code through JavaScript channels.
Read the [manual integration
overview](/platform/guides/manual-integration/overview) for core concepts
before you continue.
## Setup
### Dependencies
The examples below use [cryptography](https://pub.dev/packages/cryptography) for X25519 key exchange, but you can use any library that supports X25519 and AES-GCM. Add the required packages to your `pubspec.yaml`:
```yaml theme={null}
dependencies:
webview_flutter: ^4.13.0
cryptography: ^2.7.0
convert: ^3.1.1
```
For platform-specific setup, add the implementation packages:
```yaml theme={null}
dependencies:
webview_flutter_android: ^4.3.0 # Android
webview_flutter_wkwebview: ^3.18.0 # iOS/macOS
```
Run `flutter pub get` to install the packages.
### Platform configuration
Add the following to your `ios/Runner/Info.plist`:
```xml theme={null}
io.flutter.embedded_views_preview
```
Set the minimum SDK version in `android/app/build.gradle`:
```groovy theme={null}
android {
defaultConfig {
minSdkVersion 21
}
}
```
### Key generation
Create a utility class for X25519 key generation and decryption:
```dart theme={null}
import 'dart:convert';
import 'dart:typed_data';
import 'package:cryptography/cryptography.dart';
import 'package:convert/convert.dart';
class MoonPayCrypto {
final SimpleKeyPair _keyPair;
final String publicKeyHex;
MoonPayCrypto._(this._keyPair, this.publicKeyHex);
/// Generate a new X25519 keypair for frame communication
static Future generate() async {
final algorithm = X25519();
final keyPair = await algorithm.newKeyPair();
final publicKey = await keyPair.extractPublicKey();
final publicKeyHex = hex.encode(publicKey.bytes);
return MoonPayCrypto._(keyPair, publicKeyHex);
}
/// Decrypt an encrypted payload from the frame
Future decryptPayload(String encryptedValue) async {
// The frame returns the encrypted payload as a base64-encoded JSON
// string. Decode the base64 first, then parse the JSON.
final decodedJson = utf8.decode(base64Decode(encryptedValue));
final encrypted = jsonDecode(decodedJson) as Map;
final ephemeralPublicKeyBytes = hex.decode(encrypted['ephemeralPublicKey'] as String);
final iv = hex.decode(encrypted['iv'] as String);
final ciphertext = hex.decode(encrypted['ciphertext'] as String);
// Create shared secret using X25519
final algorithm = X25519();
final ephemeralPublicKey = SimplePublicKey(
Uint8List.fromList(ephemeralPublicKeyBytes),
type: KeyPairType.x25519,
);
final sharedSecret = await algorithm.sharedSecretKey(
keyPair: _keyPair,
remotePublicKey: ephemeralPublicKey,
);
// Derive AES key using HKDF
final hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32);
final derivedKey = await hkdf.deriveKey(
secretKey: sharedSecret,
info: Uint8List(0),
nonce: Uint8List(0),
);
// Decrypt using AES-GCM
final aesGcm = AesGcm.with256bits();
final secretBox = SecretBox(
ciphertext,
nonce: iv,
mac: Mac.empty, // MAC is appended to ciphertext
);
final decrypted = await aesGcm.decrypt(
secretBox,
secretKey: derivedKey,
);
return utf8.decode(decrypted);
}
}
/// Generate a unique channel ID for frame communication
String generateChannelId() {
final timestamp = DateTime.now().millisecondsSinceEpoch;
final random = (DateTime.now().microsecond * 1000).toRadixString(36);
return 'ch_${timestamp}_$random';
}
```
### Base WebView widget
Create a reusable widget for frame communication:
```dart theme={null}
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
const String frameOrigin = 'https://blocks.moonpay.com';
class FrameMessage {
final int version;
final String channelId;
final String kind;
final Map? payload;
FrameMessage({
required this.version,
required this.channelId,
required this.kind,
this.payload,
});
factory FrameMessage.fromJson(Map json) {
return FrameMessage(
version: json['version'] as int,
channelId: (json['meta'] as Map)['channelId'] as String,
kind: json['kind'] as String,
payload: json['payload'] as Map?,
);
}
Map toJson() => {
'version': version,
'meta': {'channelId': channelId},
'kind': kind,
if (payload != null) 'payload': payload,
};
}
class MoonPayWebView extends StatefulWidget {
final String url;
final String channelId;
final void Function(FrameMessage) onMessage;
final VoidCallback onHandshake;
final double? height;
const MoonPayWebView({
super.key,
required this.url,
required this.channelId,
required this.onMessage,
required this.onHandshake,
this.height,
});
@override
State createState() => _MoonPayWebViewState();
}
class _MoonPayWebViewState extends State {
late final WebViewController _controller;
@override
void initState() {
super.initState();
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel(
'MoonPayBridge',
onMessageReceived: _handleMessage,
)
..setNavigationDelegate(
NavigationDelegate(
onPageFinished: (_) => _injectMessageBridge(),
),
)
..loadRequest(Uri.parse(widget.url));
}
void _injectMessageBridge() {
_controller.runJavaScript('''
(function() {
window.addEventListener('message', function(e) {
if (MoonPayBridge && MoonPayBridge.postMessage) {
MoonPayBridge.postMessage(typeof e.data === 'string' ? e.data : JSON.stringify(e.data));
}
});
})();
''');
}
void _handleMessage(JavaScriptMessage jsMessage) {
try {
final data = jsonDecode(jsMessage.message) as Map;
final meta = data['meta'] as Map?;
if (meta?['channelId'] != widget.channelId) return;
final message = FrameMessage.fromJson(data);
if (message.kind == 'handshake') {
sendMessage('ack');
widget.onHandshake();
}
widget.onMessage(message);
} catch (_) {
// Ignore malformed messages
}
}
void sendMessage(String kind, [Map? payload]) {
final message = FrameMessage(
version: 2,
channelId: widget.channelId,
kind: kind,
payload: payload,
);
final jsonString = jsonEncode(message.toJson());
// 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.
final stringLiteral = jsonEncode(jsonString);
_controller.runJavaScript('''
window.postMessage($stringLiteral, '*');
''');
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: widget.height ?? double.infinity,
child: WebViewWidget(controller: _controller),
);
}
}
```
### 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 (`OK` = success, `Cancel` = failed).
`webview_flutter` does not surface `window.confirm` (or `alert` / `prompt`) by default — the underlying `WKWebView` on iOS and `WebView` on Android both require explicit dialog handlers. Without one, the call returns `false` with no UI shown, the frame interprets that as the customer cancelling, and every test transaction comes back with `status: "failed"`.
Wire dialog handling via the platform-specific implementations. On iOS use `WebKitWebViewController`'s UI delegate hooks; on Android use `AndroidWebViewController` to attach a `WebChromeClient` whose `onJsConfirm` returns the customer's choice. Surface the prompt with a Flutter `AlertDialog` so it matches the rest of your UI, and call back into the controller with the result.
Wire dialog handling even if you only plan to ship live mode. It 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](/platform/frames/check) for event details.
### Check widget
```dart theme={null}
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
class MoonPayCheckFrame extends StatefulWidget {
final String sessionToken;
final void Function(ConnectCredentials) onActive;
final void Function(ConnectCredentials) onConnectionRequired;
final void Function(ConnectError) onError;
final VoidCallback? onPending;
final VoidCallback? onUnavailable;
const MoonPayCheckFrame({
super.key,
required this.sessionToken,
required this.onActive,
required this.onConnectionRequired,
required this.onError,
this.onPending,
this.onUnavailable,
});
@override
State createState() => _MoonPayCheckFrameState();
}
class _MoonPayCheckFrameState extends State {
late final String _channelId;
late final Future _cryptoFuture;
@override
void initState() {
super.initState();
_channelId = generateChannelId();
_cryptoFuture = MoonPayCrypto.generate();
}
String _buildFrameUrl(String publicKeyHex) {
final params = {
'sessionToken': widget.sessionToken,
'publicKey': publicKeyHex,
'channelId': _channelId,
};
return '$frameOrigin/platform/v1/check-connection?${Uri(queryParameters: params).query}';
}
Future _handleMessage(FrameMessage message, MoonPayCrypto crypto) async {
switch (message.kind) {
case 'complete':
final payload = message.payload!;
final status = payload['status'] as String;
switch (status) {
case 'active':
final decryptedPayload = await crypto.decryptPayload(payload['credentials'] as String);
final credentials = jsonDecode(decryptedPayload) as Map;
// Check payload['capabilities']['ramps']['requirements']['paymentDisclosures']
// to determine if payment disclosures are required before transacting
widget.onActive(ConnectCredentials(
accessToken: credentials['accessToken'] as String,
clientToken: credentials['clientToken'] as String,
));
break;
case 'connectionRequired':
final decryptedPayload = await crypto.decryptPayload(payload['credentials'] as String);
final anonymousCredentials = jsonDecode(decryptedPayload) as Map;
widget.onConnectionRequired(ConnectCredentials(
accessToken: anonymousCredentials['accessToken'] as String,
clientToken: anonymousCredentials['clientToken'] as String,
));
break;
case 'pending':
widget.onPending?.call();
break;
case 'unavailable':
widget.onUnavailable?.call();
break;
case 'failed':
widget.onError(ConnectError(
code: 'failed',
message: payload['reason'] as String? ?? 'Check failed',
));
break;
}
break;
case 'error':
final payload = message.payload!;
widget.onError(ConnectError(
code: payload['code'] as String,
message: payload['message'] as String,
));
break;
}
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _cryptoFuture,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final crypto = snapshot.data!;
return MoonPayWebView(
url: _buildFrameUrl(crypto.publicKeyHex),
channelId: _channelId,
onMessage: (msg) => _handleMessage(msg, crypto),
onHandshake: () {},
);
},
);
}
}
```
### Usage
```dart theme={null}
class SplashScreen extends StatelessWidget {
const SplashScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: MoonPayCheckFrame(
sessionToken: 'your-session-token',
onActive: (credentials) {
// Customer is already connected — skip to payment
debugPrint('Already connected! Access token: ${credentials.accessToken}');
Navigator.of(context).pushReplacementNamed('/payment');
},
onConnectionRequired: (credentials) {
// Store both tokens in memory, then pass the clientToken to the connect frame
CredentialsStore.instance.set(
accessToken: credentials.accessToken,
clientToken: credentials.clientToken,
);
Navigator.of(context).pushReplacementNamed(
'/connect',
arguments: credentials.clientToken,
);
},
onError: (error) {
debugPrint('Check failed: ${error.message}');
},
),
);
}
}
```
***
## Connect frame
The connect frame establishes a customer connection to your application. See [connect frame reference](/platform/frames/connect) for event details.
### Connect widget
```dart theme={null}
import 'dart:async';
import 'package:flutter/material.dart';
class ConnectCredentials {
final String accessToken;
final String clientToken;
ConnectCredentials({required this.accessToken, required this.clientToken});
}
class ConnectError {
final String code;
final String message;
ConnectError({required this.code, required this.message});
}
class MoonPayConnectFrame extends StatefulWidget {
final String clientToken;
final void Function(ConnectCredentials) onComplete;
final void Function(ConnectError) onError;
final VoidCallback? onPending;
final VoidCallback? onUnavailable;
const MoonPayConnectFrame({
super.key,
required this.clientToken,
required this.onComplete,
required this.onError,
this.onPending,
this.onUnavailable,
});
@override
State createState() => _MoonPayConnectFrameState();
}
class _MoonPayConnectFrameState extends State {
late final String _channelId;
late final Future _cryptoFuture;
@override
void initState() {
super.initState();
_channelId = generateChannelId();
_cryptoFuture = MoonPayCrypto.generate();
}
String _buildFrameUrl(String publicKeyHex) {
final params = {
'clientToken': widget.clientToken,
'publicKey': publicKeyHex,
'channelId': _channelId,
};
return '$frameOrigin/platform/v1/connect?${Uri(queryParameters: params).query}';
}
Future _handleMessage(FrameMessage message, MoonPayCrypto crypto) async {
switch (message.kind) {
case 'complete':
final payload = message.payload!;
final status = payload['status'] as String;
switch (status) {
case 'active':
final decryptedPayload = await crypto.decryptPayload(payload['credentials'] as String);
final credentials = jsonDecode(decryptedPayload) as Map;
// Check payload['capabilities']['ramps']['requirements']['paymentDisclosures']
// to determine if payment disclosures are required before transacting
widget.onComplete(ConnectCredentials(
accessToken: credentials['accessToken'] as String,
clientToken: credentials['clientToken'] as String,
));
break;
case 'pending':
widget.onPending?.call();
break;
case 'unavailable':
widget.onUnavailable?.call();
break;
case 'failed':
widget.onError(ConnectError(
code: 'failed',
message: payload['reason'] as String? ?? 'Connection failed',
));
break;
}
break;
case 'error':
final payload = message.payload!;
widget.onError(ConnectError(
code: payload['code'] as String,
message: payload['message'] as String,
));
break;
}
}
void _handleHandshake() {
// Handshake complete
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _cryptoFuture,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final crypto = snapshot.data!;
return MoonPayWebView(
url: _buildFrameUrl(crypto.publicKeyHex),
channelId: _channelId,
onMessage: (msg) => _handleMessage(msg, crypto),
onHandshake: _handleHandshake,
);
},
);
}
}
```
### Usage
```dart theme={null}
class ConnectScreen extends StatelessWidget {
const ConnectScreen({super.key});
@override
Widget build(BuildContext context) {
// The clientToken is the anonymous token passed from the check frame's
// `connectionRequired` response.
final clientToken = ModalRoute.of(context)!.settings.arguments as String;
return Scaffold(
appBar: AppBar(title: const Text('Connect')),
body: MoonPayConnectFrame(
clientToken: clientToken,
onComplete: (credentials) {
// Replace the anonymous credentials with the authenticated ones and
// store them in memory (e.g., Provider or Riverpod).
debugPrint('Connected! Access token: ${credentials.accessToken}');
Navigator.of(context).pushReplacementNamed('/home');
},
onError: (error) {
debugPrint('Connection failed: ${error.message}');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${error.message}')),
);
},
onPending: () => debugPrint('Connection pending'),
onUnavailable: () => debugPrint('Connection unavailable'),
),
);
}
}
```
***
## Apple Pay frame
The Apple Pay frame renders the Apple Pay button and handles the payment flow. See [Apple Pay frame reference](/platform/frames/apple-pay) for event details.
Apple Pay only works on iOS devices with Apple Pay configured.
### Apple Pay widget
```dart theme={null}
import 'dart:async';
import 'package:flutter/material.dart';
class TransactionResult {
final String id;
final String status;
TransactionResult({required this.id, required this.status});
}
class MoonPayApplePayFrame extends StatefulWidget {
final String clientToken;
final String quoteSignature;
final void Function(TransactionResult) onComplete;
final void Function(ConnectError) onError;
final VoidCallback onQuoteExpired;
final VoidCallback? onReady;
const MoonPayApplePayFrame({
super.key,
required this.clientToken,
required this.quoteSignature,
required this.onComplete,
required this.onError,
required this.onQuoteExpired,
this.onReady,
});
@override
State createState() => _MoonPayApplePayFrameState();
}
class _MoonPayApplePayFrameState extends State {
late final String _channelId;
late String _currentQuote;
final GlobalKey<_MoonPayWebViewState> _webViewKey = GlobalKey();
@override
void initState() {
super.initState();
_channelId = generateChannelId();
_currentQuote = widget.quoteSignature;
}
@override
void didUpdateWidget(MoonPayApplePayFrame oldWidget) {
super.didUpdateWidget(oldWidget);
// Send updated quote to frame
if (widget.quoteSignature != _currentQuote) {
_currentQuote = widget.quoteSignature;
_webViewKey.currentState?.sendMessage('setQuote', {
'quote': {'signature': _currentQuote},
});
}
}
String _buildFrameUrl() {
final params = {
'clientToken': widget.clientToken,
'signature': widget.quoteSignature,
'channelId': _channelId,
};
return '$frameOrigin/platform/v1/apple-pay?${Uri(queryParameters: params).query}';
}
void _handleMessage(FrameMessage message) {
switch (message.kind) {
case 'ready':
widget.onReady?.call();
break;
case 'complete':
final payload = message.payload!;
final transaction = payload['transaction'] as Map;
if (transaction['status'] == 'failed') {
widget.onError(ConnectError(
code: 'transactionFailed',
message: transaction['failureReason'] as String,
));
} else {
widget.onComplete(TransactionResult(
id: transaction['id'] as String,
status: transaction['status'] as String,
));
}
break;
case 'error':
final payload = message.payload!;
final code = payload['code'] as String;
if (code == 'quoteExpired') {
widget.onQuoteExpired();
} else {
widget.onError(ConnectError(
code: code,
message: payload['message'] as String,
));
}
break;
}
}
@override
Widget build(BuildContext context) {
return MoonPayWebView(
key: _webViewKey,
url: _buildFrameUrl(),
channelId: _channelId,
onMessage: _handleMessage,
onHandshake: () {}, // Handled in _handleMessage
height: 56, // Apple Pay button height
);
}
}
```
### Usage
```dart theme={null}
class PaymentScreen extends StatefulWidget {
final String clientToken;
final String initialQuoteSignature;
const PaymentScreen({
super.key,
required this.clientToken,
required this.initialQuoteSignature,
});
@override
State createState() => _PaymentScreenState();
}
class _PaymentScreenState extends State {
late String _quoteSignature;
@override
void initState() {
super.initState();
_quoteSignature = widget.initialQuoteSignature;
}
Future _fetchNewQuote() async {
// Fetch a new quote from your API
final newQuote = await yourApi.getQuote();
setState(() => _quoteSignature = newQuote.signature);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Pay with Apple Pay')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Your payment summary UI
const Spacer(),
MoonPayApplePayFrame(
clientToken: widget.clientToken,
quoteSignature: _quoteSignature,
onComplete: (transaction) {
debugPrint('Transaction initiated: ${transaction.id}');
Navigator.of(context).pushNamed('/transaction-status');
},
onError: (error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Payment error: ${error.message}')),
);
},
onQuoteExpired: _fetchNewQuote,
onReady: () => debugPrint('Apple Pay button 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.
### What you'll need
Before you initialize the add card frame, you need:
1. A `clientToken` from a successful [connect flow](#connect-frame)
### Add Card widget
```dart theme={null}
import 'dart:async';
import 'package:flutter/material.dart';
class CardResult {
final String id;
final String brand;
final String last4;
final String cardType;
final int expirationMonth;
final int expirationYear;
final bool active;
CardResult({
required this.id,
required this.brand,
required this.last4,
required this.cardType,
required this.expirationMonth,
required this.expirationYear,
required this.active,
});
}
class MoonPayAddCardFrame extends StatefulWidget {
final String clientToken;
final void Function(CardResult) onComplete;
final void Function(ConnectError) onError;
final VoidCallback? onReady;
const MoonPayAddCardFrame({
super.key,
required this.clientToken,
required this.onComplete,
required this.onError,
this.onReady,
});
@override
State createState() => _MoonPayAddCardFrameState();
}
class _MoonPayAddCardFrameState extends State {
late final String _channelId;
@override
void initState() {
super.initState();
_channelId = generateChannelId();
}
String _buildFrameUrl() {
final params = {
'clientToken': widget.clientToken,
'channelId': _channelId,
};
return '$frameOrigin/platform/v1/add-card?${Uri(queryParameters: params).query}';
}
void _handleMessage(FrameMessage message) {
switch (message.kind) {
case 'ready':
widget.onReady?.call();
break;
case 'complete':
final payload = message.payload!;
final card = payload['card'] as Map;
final availability = card['availability'] as Map;
widget.onComplete(CardResult(
id: card['id'] as String,
brand: card['brand'] as String,
last4: card['last4'] as String,
cardType: card['cardType'] as String,
expirationMonth: card['expirationMonth'] as int,
expirationYear: card['expirationYear'] as int,
active: availability['active'] as bool,
));
break;
case 'error':
final payload = message.payload!;
widget.onError(ConnectError(
code: payload['code'] as String,
message: payload['message'] as String,
));
break;
}
}
@override
Widget build(BuildContext context) {
return MoonPayWebView(
url: _buildFrameUrl(),
channelId: _channelId,
onMessage: _handleMessage,
onHandshake: () {},
);
}
}
```
### Usage
```dart theme={null}
class SaveCardScreen extends StatelessWidget {
final String clientToken;
const SaveCardScreen({super.key, required this.clientToken});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Add Card')),
body: MoonPayAddCardFrame(
clientToken: clientToken,
onComplete: (card) {
debugPrint('Card saved: ${card.brand} •••• ${card.last4}');
Navigator.of(context).pop();
},
onError: (error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${error.message}')),
);
},
onReady: () => debugPrint('Add card frame ready'),
),
);
}
}
```
***
## 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 widget
```dart theme={null}
import 'dart:async';
import 'package:flutter/material.dart';
class MoonPayBuyFrame extends StatefulWidget {
final String clientToken;
final String quoteSignature;
final void Function(TransactionResult) onComplete;
final void Function(String url) onChallenge;
final void Function(ConnectError) onError;
final VoidCallback? onQuoteExpired;
const MoonPayBuyFrame({
super.key,
required this.clientToken,
required this.quoteSignature,
required this.onComplete,
required this.onChallenge,
required this.onError,
this.onQuoteExpired,
});
@override
State createState() => _MoonPayBuyFrameState();
}
class _MoonPayBuyFrameState extends State {
late final String _channelId;
late String _currentQuote;
final GlobalKey<_MoonPayWebViewState> _webViewKey = GlobalKey();
@override
void initState() {
super.initState();
_channelId = generateChannelId();
_currentQuote = widget.quoteSignature;
}
@override
void didUpdateWidget(MoonPayBuyFrame oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.quoteSignature != _currentQuote) {
_currentQuote = widget.quoteSignature;
_webViewKey.currentState?.sendMessage('setQuote', {
'quote': {'signature': _currentQuote},
});
}
}
String _buildFrameUrl() {
final params = {
'clientToken': widget.clientToken,
'channelId': _channelId,
'signature': widget.quoteSignature,
};
return '$frameOrigin/platform/v1/buy?${Uri(queryParameters: params).query}';
}
void _handleMessage(FrameMessage message) {
switch (message.kind) {
case 'complete':
final payload = message.payload!;
final transaction = payload['transaction'] as Map;
widget.onComplete(TransactionResult(
id: transaction['id'] as String,
status: transaction['status'] as String,
));
break;
case 'challenge':
final payload = message.payload!;
widget.onChallenge(payload['url'] as String);
break;
case 'error':
final payload = message.payload!;
final code = payload['code'] as String;
if (code == 'quoteExpired') {
widget.onQuoteExpired?.call();
} else {
widget.onError(ConnectError(
code: code,
message: payload['message'] as String,
));
}
break;
}
}
@override
Widget build(BuildContext context) {
return SizedBox.shrink(
child: MoonPayWebView(
key: _webViewKey,
url: _buildFrameUrl(),
channelId: _channelId,
onMessage: _handleMessage,
onHandshake: () {},
height: 0,
),
);
}
}
```
### Challenge widget
```dart theme={null}
import 'dart:async';
import 'package:flutter/material.dart';
class MoonPayChallengeFrame extends StatefulWidget {
final String challengeUrl;
final void Function(TransactionResult) onComplete;
final VoidCallback onCancelled;
final void Function(ConnectError) onError;
const MoonPayChallengeFrame({
super.key,
required this.challengeUrl,
required this.onComplete,
required this.onCancelled,
required this.onError,
});
@override
State createState() => _MoonPayChallengeFrameState();
}
class _MoonPayChallengeFrameState extends State {
late final String _channelId;
@override
void initState() {
super.initState();
_channelId = generateChannelId();
}
void _handleMessage(FrameMessage message) {
switch (message.kind) {
case 'complete':
final payload = message.payload!;
final transaction = payload['transaction'] as Map;
widget.onComplete(TransactionResult(
id: transaction['id'] as String,
status: transaction['status'] as String,
));
break;
case 'cancelled':
widget.onCancelled();
break;
case 'error':
final payload = message.payload!;
widget.onError(ConnectError(
code: payload['code'] as String,
message: payload['message'] as String,
));
break;
}
}
String get _frameUrl {
final uri = Uri.parse(widget.challengeUrl);
return uri
.replace(queryParameters: {
...uri.queryParameters,
'channelId': _channelId,
})
.toString();
}
@override
Widget build(BuildContext context) {
return MoonPayWebView(
url: _frameUrl,
channelId: _channelId,
onMessage: _handleMessage,
onHandshake: () {},
);
}
}
```
### Usage
```dart theme={null}
class BuyPaymentScreen extends StatefulWidget {
final String clientToken;
final String initialQuoteSignature;
const BuyPaymentScreen({
super.key,
required this.clientToken,
required this.initialQuoteSignature,
});
@override
State createState() => _BuyPaymentScreenState();
}
class _BuyPaymentScreenState extends State {
late String _quoteSignature;
String? _challengeUrl;
@override
void initState() {
super.initState();
_quoteSignature = widget.initialQuoteSignature;
}
Future _fetchNewQuote() async {
final newQuote = await yourApi.getQuote();
setState(() => _quoteSignature = newQuote.signature);
}
void _handleComplete(TransactionResult transaction) {
setState(() => _challengeUrl = null);
debugPrint('Transaction initiated: ${transaction.id}');
Navigator.of(context).pushNamed('/transaction-status');
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Buy Crypto')),
body: Stack(
children: [
// Your payment summary UI
MoonPayBuyFrame(
clientToken: widget.clientToken,
quoteSignature: _quoteSignature,
onComplete: _handleComplete,
onChallenge: (url) => setState(() => _challengeUrl = url),
onError: (error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${error.message}')),
);
},
onQuoteExpired: _fetchNewQuote,
),
if (_challengeUrl != null)
Positioned.fill(
child: MoonPayChallengeFrame(
challengeUrl: _challengeUrl!,
onComplete: _handleComplete,
onCancelled: () => setState(() => _challengeUrl = null),
onError: (error) {
setState(() => _challengeUrl = null);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${error.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 WebView, including payment-method selection and transaction confirmation. See [pay with widget](/platform/guides/pay-with-widget) for a full walkthrough.
### Widget widget
```dart theme={null}
import 'dart:async';
import 'package:flutter/material.dart';
class MoonPayWidgetFrame extends StatefulWidget {
final String clientToken;
final String quoteSignature;
final void Function(TransactionResult) onComplete;
final void Function(ConnectError) onError;
final void Function(String transactionId, String status)? onTransactionCreated;
final VoidCallback? onReady;
const MoonPayWidgetFrame({
super.key,
required this.clientToken,
required this.quoteSignature,
required this.onComplete,
required this.onError,
this.onTransactionCreated,
this.onReady,
});
@override
State createState() => _MoonPayWidgetFrameState();
}
class _MoonPayWidgetFrameState extends State {
late final String _channelId;
@override
void initState() {
super.initState();
_channelId = generateChannelId();
}
String _buildFrameUrl() {
final params = {
'flow': 'buy',
'clientToken': widget.clientToken,
'quoteSignature': widget.quoteSignature,
'channelId': _channelId,
};
return '$frameOrigin/platform/v1/widget?${Uri(queryParameters: params).query}';
}
void _handleMessage(FrameMessage message) {
switch (message.kind) {
case 'ready':
widget.onReady?.call();
break;
case 'transactionCreated':
final payload = message.payload!;
final transaction = payload['transaction'] as Map;
widget.onTransactionCreated?.call(
transaction['id'] as String,
transaction['status'] as String,
);
break;
case 'complete':
final payload = message.payload!;
final transaction = payload['transaction'] as Map;
if (transaction['status'] == 'failed') {
widget.onError(ConnectError(
code: 'transactionFailed',
message: transaction['failureReason'] as String,
));
} else {
widget.onComplete(TransactionResult(
id: transaction['id'] as String,
status: transaction['status'] as String,
));
}
break;
case 'error':
final payload = message.payload!;
widget.onError(ConnectError(
code: payload['code'] as String,
message: payload['message'] as String,
));
break;
}
}
@override
Widget build(BuildContext context) {
return MoonPayWebView(
url: _buildFrameUrl(),
channelId: _channelId,
onMessage: _handleMessage,
onHandshake: () {},
);
}
}
```
### Usage
```dart theme={null}
class WidgetPaymentScreen extends StatelessWidget {
final String clientToken;
final String quoteSignature;
const WidgetPaymentScreen({
super.key,
required this.clientToken,
required this.quoteSignature,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Buy Crypto')),
body: MoonPayWidgetFrame(
clientToken: clientToken,
quoteSignature: quoteSignature,
onReady: () => debugPrint('Widget loaded'),
onTransactionCreated: (id, status) {
debugPrint('Transaction created: $id ($status)');
},
onComplete: (transaction) {
debugPrint('Transaction complete: ${transaction.id}');
Navigator.of(context).pushNamed('/transaction-status');
},
onError: (error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Widget error: ${error.message}')),
);
},
),
);
}
}
```
# iOS
Source: https://dev.moonpay.com/platform/guides/manual-integration/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.
Read the [manual integration
overview](/platform/guides/manual-integration/overview) for core concepts
before you continue.
## 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.. 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](/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
}
// 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
```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
}
// 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
```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 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
```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 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)
}
}
```
# Overview
Source: https://dev.moonpay.com/platform/guides/manual-integration/overview
Integrate MoonPay frames without using the SDK.
The MoonPay SDK handles frame lifecycle, `postMessage` communication, and encryption automatically. If you're building for a platform where the SDK isn't available or you prefer a direct integration, use the guides in this section.
Before you start, familiarize yourself with how [frames](/platform/frames) work in this integration including their lifecycle, messaging patterns, and events.
## When to integrate manually
You should integrate manually when:
* **You prefer not to bundle third-party SDKs.** If your application has strict requirements around third-party dependencies, you can integrate directly using platform-standard protocols and APIs.
* **You're building for a platform without SDK support.** We currently only provide an SDK for [web](/platform/sdk-reference/web/overview). For native iOS (Swift), Android (Kotlin), and React Native, you can integrate directly.
**Need an SDK for your platform?** SDK support is expanding. Contact us if you
need an SDK for iOS, Android, React Native, or another platform.
## Platform guides
The guides below are **reference implementations**, not production-ready starter projects. They illustrate the messaging protocol, encryption flow, and frame lifecycle for each platform. Review and adapt the code to fit your app's architecture, error handling, and security requirements before shipping.
For web, the SDK is strongly recommended, but you can follow the guide below for a direct integration.
Check out the platform-specific guides:
Use iframes in desktop and mobile browsers
Use `react-native-webview`
Use `WKWebView` in Swift
Use `WebView` in Kotlin
# React Native
Source: https://dev.moonpay.com/platform/guides/manual-integration/react-native
Manual frame integration for React Native using react-native-webview.
Use `react-native-webview` to embed frames in React Native applications. The WebView communicates with your app through the `ReactNativeWebView` JavaScript interface.
Read the [manual integration
overview](/platform/guides/manual-integration/overview) for core concepts
before you continue.
## Setup
The `react-native-webview` library automatically injects the
`ReactNativeWebView` JavaScript interface into the WebView. The frame detects
this interface and uses it for communication between the frame and your app.
You don't need to inject a custom JavaScript bridge.
### Dependencies
Install `react-native-webview` to render frames in your app.
```bash pnpm theme={null}
pnpm i react-native-webview
```
```bash bun theme={null}
bun add react-native-webview
```
```bash npm theme={null}
npm i react-native-webview
```
If targeting iOS, you may also need to run:
```bash theme={null}
cd ios && pod install
```
```bash theme={null}
npx expo install react-native-webview
```
See the [Expo WebView docs](https://docs.expo.dev/versions/latest/sdk/webview/) for additional platform configuration.
The connect and check frames require X25519 key exchange to encrypt client credentials. The examples below use [noble-curves](https://github.com/paulmillr/noble-curves), but you can use any library that supports X25519 and AES-GCM. You also need a [polyfill for `getRandomValues`](https://github.com/LinusU/react-native-get-random-values) ([MDN](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues)) which is only available in browsers.
```bash pnpm theme={null}
pnpm i react-native-get-random-values @noble/curves @noble/hashes @noble/ciphers
```
```bash bun theme={null}
bun add react-native-get-random-values @noble/curves @noble/hashes @noble/ciphers
```
```bash npm theme={null}
npm i react-native-get-random-values @noble/curves @noble/hashes @noble/ciphers
```
### Base WebView component
Create a reusable base component for frame communication:
```tsx twoslash MoonPayWebView.tsx theme={null}
import React, {
useRef,
useCallback,
useImperativeHandle,
forwardRef,
} from "react";
import { View, ViewStyle, StyleSheet } from "react-native";
import { WebView, WebViewMessageEvent } from "react-native-webview";
type MoonPayWebViewProps = {
url: string;
channelId: string;
onMessage: (data: FrameMessage) => void;
onHandshake: () => void;
style?: ViewStyle;
};
export type MoonPayWebViewRef = {
sendMessage: (kind: string, payload?: object) => void;
};
export type FrameMessage = {
version: number;
meta: { channelId: string };
kind: string;
payload?: unknown;
};
export const MoonPayWebView = forwardRef<
MoonPayWebViewRef,
MoonPayWebViewProps
>(({ url, channelId, onMessage, onHandshake, style }, ref) => {
const webViewRef = useRef(null);
const sendMessage = useCallback(
(kind: string, payload?: object) => {
const message = {
version: 2,
meta: { channelId },
kind,
...(payload && { payload }),
};
webViewRef.current?.postMessage(JSON.stringify(message));
},
[channelId],
);
useImperativeHandle(ref, () => ({ sendMessage }), [sendMessage]);
const handleMessage = useCallback(
(event: WebViewMessageEvent) => {
try {
const data: FrameMessage = JSON.parse(event.nativeEvent.data);
if (data.meta?.channelId !== channelId) return;
if (data.kind === "handshake") {
sendMessage("ack");
onHandshake();
}
onMessage(data);
} catch {
// Ignore malformed messages
}
},
[channelId, sendMessage, onMessage, onHandshake],
);
return (
);
});
const styles = StyleSheet.create({
container: { flex: 1 },
webview: { flex: 1 },
});
```
### Encryption utility
When using the connect or check frame, you generate an X25519 keypair and provide the public key to the frame. The frame encrypts the returned client credentials with this key.
#### Key generation
Generate an X25519 keypair for secure communication:
```ts crypto.ts theme={null}
import { x25519 } from "@noble/curves/ed25519";
import { bytesToHex } from "@noble/hashes/utils";
import "react-native-get-random-values"; // Polyfill for crypto.getRandomValues
export function generateKeyPair() {
const { secretKey, publicKey } = x25519.keygen();
return {
privateKeyHex: bytesToHex(secretKey),
publicKeyHex: bytesToHex(publicKey),
};
}
export function generateChannelId() {
return `ch_${Date.now()}_${Math.random().toString(36).slice(2)}`;
}
```
#### Decryption utility
Decrypt the encrypted credentials returned from connect/check frames:
```ts decrypt.ts theme={null}
import { x25519 } from "@noble/curves/ed25519";
import { hkdf } from "@noble/hashes/hkdf";
import { sha256 } from "@noble/hashes/sha256";
import { gcm } from "@noble/ciphers/aes";
import { hexToBytes } from "@noble/hashes/utils";
interface ClientCredentials {
accessToken: string;
clientToken: string;
}
export function decryptClientCredentials(
encryptedValue: string,
privateKeyHex: string,
): ClientCredentials {
const encrypted = JSON.parse(encryptedValue) as {
ephemeralPublicKey: string;
iv: string;
ciphertext: string;
};
const sharedSecret = x25519.getSharedSecret(
hexToBytes(privateKeyHex),
hexToBytes(encrypted.ephemeralPublicKey),
);
const derivedKey = hkdf(sha256, sharedSecret, undefined, undefined, 32);
const aes = gcm(derivedKey, hexToBytes(encrypted.iv));
const decrypted = aes.decrypt(hexToBytes(encrypted.ciphertext));
const text = new TextDecoder().decode(decrypted);
return JSON.parse(text) as ClientCredentials;
}
```
***
## Check frame
The check frame verifies whether a customer already has an active connection. It's headless — no UI is rendered. See [check frame reference](/platform/frames/check) for event details.
### Check component
```tsx theme={null}
import React, { useState, useCallback } from "react";
import { MoonPayWebView, FrameMessage } from "./MoonPayWebView";
import {
generateKeyPair,
generateChannelId,
decryptClientCredentials,
} from "./crypto";
const FRAME_ORIGIN = "https://blocks.moonpay.com";
interface CheckFrameProps {
sessionToken: string;
onActive: (credentials: { accessToken: string; clientToken: string }) => void;
onConnectionRequired: (credentials: {
accessToken: string;
clientToken: string;
}) => void;
onError: (error: { code: string; message: string }) => void;
onPending?: () => void;
onUnavailable?: () => void;
}
export function MoonPayCheckFrame({
sessionToken,
onActive,
onConnectionRequired,
onError,
onPending,
onUnavailable,
}: CheckFrameProps) {
const [channelId] = useState(generateChannelId);
const [keyPair] = useState(generateKeyPair);
const frameUrl = `${FRAME_ORIGIN}/platform/v1/check-connection?${new URLSearchParams(
{
sessionToken,
publicKey: keyPair.publicKeyHex,
channelId,
},
).toString()}`;
const handleMessage = useCallback(
(data: FrameMessage) => {
switch (data.kind) {
case "complete":
const payload = data.payload as {
status: string;
credentials?: string;
reason?: string;
};
switch (payload.status) {
case "active":
const credentials = decryptClientCredentials(
payload.credentials!,
keyPair.privateKeyHex,
);
// Check payload.capabilities.ramps.requirements.paymentDisclosures
// to determine if payment disclosures are required before transacting
onActive(credentials);
break;
case "connectionRequired":
const anonymousCredentials = decryptClientCredentials(
payload.credentials!,
keyPair.privateKeyHex,
);
onConnectionRequired(anonymousCredentials);
break;
case "pending":
onPending?.();
break;
case "unavailable":
onUnavailable?.();
break;
case "failed":
onError({
code: "failed",
message: payload.reason || "Check failed",
});
break;
}
break;
case "error":
onError(data.payload as { code: string; message: string });
break;
}
},
[
keyPair,
onActive,
onConnectionRequired,
onError,
onPending,
onUnavailable,
],
);
return (
{}}
/>
);
}
```
### Usage
```tsx theme={null}
import { MoonPayCheckFrame } from "./MoonPayCheckFrame";
function SplashScreen({ navigation }) {
return (
{
// Customer is already connected — skip to payment
console.log("Already connected!", { accessToken, clientToken });
navigation.navigate("Payment");
}}
onConnectionRequired={({ accessToken, clientToken }) => {
// Store both tokens in memory, then pass clientToken to the connect frame
CredentialsStore.set({ accessToken, clientToken });
navigation.navigate("Connect", { clientToken });
}}
onError={(error) => {
console.error("Check failed:", error);
}}
/>
);
}
```
***
## Connect frame
The connect frame establishes a customer connection to your application. See [connect frame reference](/platform/frames/connect) for event details.
### Connect component
```tsx theme={null}
import React, { useState, useCallback } from "react";
import { MoonPayWebView, FrameMessage } from "./MoonPayWebView";
import {
generateKeyPair,
generateChannelId,
decryptClientCredentials,
} from "./crypto";
const FRAME_ORIGIN = "https://blocks.moonpay.com";
interface ConnectFrameProps {
clientToken: string;
onComplete: (credentials: {
accessToken: string;
clientToken: string;
}) => void;
onError: (error: { code: string; message: string }) => void;
onPending?: () => void;
onUnavailable?: () => void;
}
export function MoonPayConnectFrame({
clientToken,
onComplete,
onError,
onPending,
onUnavailable,
}: ConnectFrameProps) {
const [channelId] = useState(generateChannelId);
const [keyPair] = useState(generateKeyPair);
const frameUrl = `${FRAME_ORIGIN}/platform/v1/connect?${new URLSearchParams({
clientToken,
publicKey: keyPair.publicKeyHex,
channelId,
}).toString()}`;
const handleMessage = useCallback(
(data: FrameMessage) => {
switch (data.kind) {
case "complete":
const payload = data.payload as {
status: string;
credentials?: string;
reason?: string;
};
switch (payload.status) {
case "active":
const credentials = decryptClientCredentials(
payload.credentials!,
keyPair.privateKeyHex,
);
// Check payload.capabilities.ramps.requirements.paymentDisclosures
// to determine if payment disclosures are required before transacting
onComplete(credentials);
break;
case "pending":
onPending?.();
break;
case "unavailable":
onUnavailable?.();
break;
case "failed":
onError({
code: "failed",
message: payload.reason || "Connection failed",
});
break;
}
break;
case "error":
onError(data.payload as { code: string; message: string });
break;
}
},
[keyPair, onComplete, onError, onPending, onUnavailable],
);
return (
{}}
/>
);
}
```
### Usage
```tsx theme={null}
import { MoonPayConnectFrame } from "./MoonPayConnectFrame";
function ConnectScreen({ route, navigation }) {
// The clientToken is the anonymous token passed from the check frame's
// `connectionRequired` response.
const { clientToken } = route.params;
const handleComplete = ({ accessToken, clientToken }) => {
// Replace the anonymous credentials with the authenticated ones and store
// them in memory (e.g., React Context or state management).
console.log("Connected!", { accessToken, clientToken });
navigation.navigate("Home");
};
const handleError = (error) => {
console.error("Connection failed:", error);
// Show error UI
};
return (
console.log("Connection pending")}
onUnavailable={() => console.log("Connection unavailable")}
/>
);
}
```
***
## Apple Pay frame
The Apple Pay frame renders the Apple Pay button and handles the payment flow. See [Apple Pay frame reference](/platform/frames/apple-pay) for event details.
### Apple Pay component
```tsx theme={null}
import React, {
useState,
useCallback,
useRef,
useImperativeHandle,
forwardRef,
} from "react";
import {
MoonPayWebView,
MoonPayWebViewRef,
FrameMessage,
} from "./MoonPayWebView";
import { generateChannelId } from "./crypto";
const FRAME_ORIGIN = "https://blocks.moonpay.com";
interface ApplePayFrameProps {
clientToken: string;
quoteSignature: string;
onComplete: (transaction: { id: string; status: string }) => void;
onError: (error: { code: string; message: string }) => void;
onQuoteExpired: () => void;
onReady?: () => void;
}
export type ApplePayFrameRef = {
updateQuote: (signature: string) => void;
};
export const MoonPayApplePayFrame = forwardRef<
ApplePayFrameRef,
ApplePayFrameProps
>(
(
{
clientToken,
quoteSignature,
onComplete,
onError,
onQuoteExpired,
onReady,
},
ref,
) => {
const frameRef = useRef(null);
const [channelId] = useState(generateChannelId);
const frameUrl = `${FRAME_ORIGIN}/platform/v1/apple-pay?${new URLSearchParams(
{
clientToken,
channelId,
signature: quoteSignature,
},
).toString()}`;
useImperativeHandle(
ref,
() => ({
updateQuote: (signature: string) => {
frameRef.current?.sendMessage("setQuote", {
quote: { signature },
});
},
}),
[],
);
const handleMessage = useCallback(
(data: FrameMessage) => {
switch (data.kind) {
case "ready":
onReady?.();
break;
case "complete":
const payload = data.payload as {
transaction:
| { id: string; status: string }
| { status: "failed"; failureReason: string };
};
if (payload.transaction.status === "failed") {
onError({
code: "transactionFailed",
message: (payload.transaction as { failureReason: string })
.failureReason,
});
} else {
onComplete(payload.transaction as { id: string; status: string });
}
break;
case "error":
const error = data.payload as { code: string; message: string };
if (error.code === "quoteExpired") {
onQuoteExpired();
} else {
onError(error);
}
break;
}
},
[onComplete, onError, onQuoteExpired, onReady],
);
return (
{}}
style={{ height: 56 }}
/>
);
},
);
```
### Usage
```tsx theme={null}
import { useRef } from "react";
import { MoonPayApplePayFrame, ApplePayFrameRef } from "./MoonPayApplePayFrame";
function PaymentScreen({ clientToken, quoteSignature }) {
const applePayRef = useRef(null);
const handleQuoteExpired = async () => {
// Fetch a new quote and send it to the frame
const newQuote = await fetchNewQuote();
applePayRef.current?.updateQuote(newQuote.signature);
};
return (
console.log("Transaction initiated:", tx.id)}
onError={(error) => console.error("Payment error:", error)}
onQuoteExpired={handleQuoteExpired}
onReady={() => console.log("Apple Pay button 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.
### What you'll need
Before you initialize the add card frame, you need:
1. A `clientToken` from a successful [connect flow](#connect-frame)
### Add Card component
```tsx theme={null}
import React, { useState, useCallback } from "react";
import { MoonPayWebView, FrameMessage } from "./MoonPayWebView";
import { generateChannelId } from "./crypto";
const FRAME_ORIGIN = "https://blocks.moonpay.com";
interface CardResult {
id: string;
brand: string;
last4: string;
cardType: string;
expirationMonth: number;
expirationYear: number;
availability: { active: boolean };
}
interface AddCardFrameProps {
clientToken: string;
onComplete: (card: CardResult) => void;
onError: (error: { code: string; message: string }) => void;
onReady?: () => void;
}
export function MoonPayAddCardFrame({
clientToken,
onComplete,
onError,
onReady,
}: AddCardFrameProps) {
const [channelId] = useState(generateChannelId);
const frameUrl = `${FRAME_ORIGIN}/platform/v1/add-card?${new URLSearchParams({
clientToken,
channelId,
}).toString()}`;
const handleMessage = useCallback(
(data: FrameMessage) => {
switch (data.kind) {
case "ready":
onReady?.();
break;
case "complete":
const payload = data.payload as { card: CardResult };
onComplete(payload.card);
break;
case "error":
onError(data.payload as { code: string; message: string });
break;
}
},
[onComplete, onError, onReady],
);
return (
{}}
style={{ flex: 1 }}
/>
);
}
```
### Usage
```tsx theme={null}
import { MoonPayAddCardFrame } from "./MoonPayAddCardFrame";
function SaveCardScreen({ clientToken, navigation }) {
return (
{
console.log("Card saved:", card.id, card.brand, card.last4);
navigation.navigate("Payment");
}}
onError={(error) => {
console.error("Add card error:", error);
}}
onReady={() => console.log("Add card frame ready")}
/>
);
}
```
***
## 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 component
```tsx theme={null}
import React, {
useState,
useCallback,
useRef,
useImperativeHandle,
forwardRef,
} from "react";
import { StyleSheet } from "react-native";
import {
MoonPayWebView,
MoonPayWebViewRef,
FrameMessage,
} from "./MoonPayWebView";
import { generateChannelId } from "./crypto";
const FRAME_ORIGIN = "https://blocks.moonpay.com";
interface BuyFrameProps {
clientToken: string;
quoteSignature: string;
onComplete: (transaction: { id: string; status: string }) => void;
onChallenge: (url: string) => void;
onError: (error: { code: string; message: string }) => void;
onQuoteExpired?: () => void;
}
export type BuyFrameRef = {
updateQuote: (signature: string) => void;
};
export const MoonPayBuyFrame = forwardRef(
(
{
clientToken,
quoteSignature,
onComplete,
onChallenge,
onError,
onQuoteExpired,
},
ref,
) => {
const frameRef = useRef(null);
const [channelId] = useState(generateChannelId);
const frameUrl = `${FRAME_ORIGIN}/platform/v1/buy?${new URLSearchParams({
clientToken,
channelId,
signature: quoteSignature,
}).toString()}`;
useImperativeHandle(
ref,
() => ({
updateQuote: (signature: string) => {
frameRef.current?.sendMessage("setQuote", {
quote: { signature },
});
},
}),
[],
);
const handleMessage = useCallback(
(data: FrameMessage) => {
switch (data.kind) {
case "complete":
onComplete(
(data.payload as { transaction: { id: string; status: string } })
.transaction,
);
break;
case "challenge":
const challenge = data.payload as { kind: string; url: string };
onChallenge(challenge.url);
break;
case "error":
const error = data.payload as { code: string; message: string };
if (error.code === "quoteExpired") {
onQuoteExpired?.();
} else {
onError(error);
}
break;
}
},
[onComplete, onChallenge, onError, onQuoteExpired],
);
return (
{}}
style={StyleSheet.absoluteFillObject}
/>
);
},
);
```
### Challenge component
```tsx theme={null}
import React, { useState, useCallback } from "react";
import { MoonPayWebView, FrameMessage } from "./MoonPayWebView";
import { generateChannelId } from "./crypto";
const FRAME_ORIGIN = "https://blocks.moonpay.com";
interface ChallengeFrameProps {
challengeUrl: string;
onComplete: (transaction: { id: string; status: string }) => void;
onCancelled: () => void;
onError: (error: { code: string; message: string }) => void;
}
export function MoonPayChallengeFrame({
challengeUrl,
onComplete,
onCancelled,
onError,
}: ChallengeFrameProps) {
const [channelId] = useState(generateChannelId);
const frameUrl = useMemo(() => {
const url = new URL(challengeUrl);
url.searchParams.set("channelId", channelId);
return url.toString();
}, [challengeUrl, channelId]);
const handleMessage = useCallback(
(data: FrameMessage) => {
switch (data.kind) {
case "complete":
const payload = data.payload as {
flow: "buy";
transaction: { id: string; status: string };
};
onComplete(payload.transaction);
break;
case "cancelled":
onCancelled();
break;
case "error":
onError(data.payload as { code: string; message: string });
break;
}
},
[onComplete, onCancelled, onError],
);
return (
{}}
/>
);
}
```
### Usage
```tsx theme={null}
import { useRef, useState } from "react";
import { View, StyleSheet } from "react-native";
import { MoonPayBuyFrame, BuyFrameRef } from "./MoonPayBuyFrame";
import { MoonPayChallengeFrame } from "./MoonPayChallengeFrame";
function BuyPaymentScreen({ clientToken, quoteSignature, navigation }) {
const buyRef = useRef(null);
const [challengeUrl, setChallengeUrl] = useState(null);
const handleComplete = (transaction: { id: string; status: string }) => {
setChallengeUrl(null);
console.log("Transaction initiated:", transaction.id);
navigation.navigate("TransactionStatus", { transactionId: transaction.id });
};
const handleChallengeResult = (transaction: {
id: string;
status: string;
}) => {
setChallengeUrl(null);
console.log("Transaction initiated:", transaction.id);
navigation.navigate("TransactionStatus", { transactionId: transaction.id });
};
const handleQuoteExpired = async () => {
const newQuote = await fetchNewQuote();
buyRef.current?.updateQuote(newQuote.signature);
};
return (
setChallengeUrl(url)}
onError={(error) => console.error("Buy error:", error)}
onQuoteExpired={handleQuoteExpired}
/>
{challengeUrl && (
setChallengeUrl(null)}
onError={(error) => {
console.error("Challenge error:", error);
setChallengeUrl(null);
}}
/>
)}
);
}
```
***
## 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 WebView, including payment-method selection and transaction confirmation. See [pay with widget](/platform/guides/pay-with-widget) for a full walkthrough.
### Widget component
```tsx theme={null}
import React, { useState, useCallback } from "react";
import { MoonPayWebView, FrameMessage } from "./MoonPayWebView";
import { generateChannelId } from "./crypto";
const FRAME_ORIGIN = "https://blocks.moonpay.com";
interface WidgetFrameProps {
clientToken: string;
quoteSignature: string;
onTransactionCreated?: (transaction: { id: string; status: string }) => void;
onComplete: (transaction: { id: string; status: string }) => void;
onError: (error: { code: string; message: string }) => void;
onReady?: () => void;
}
export function MoonPayWidgetFrame({
clientToken,
quoteSignature,
onTransactionCreated,
onComplete,
onError,
onReady,
}: WidgetFrameProps) {
const [channelId] = useState(generateChannelId);
const frameUrl = `${FRAME_ORIGIN}/platform/v1/widget?${new URLSearchParams({
flow: "buy",
clientToken,
quoteSignature,
channelId,
}).toString()}`;
const handleMessage = useCallback(
(data: FrameMessage) => {
switch (data.kind) {
case "ready":
onReady?.();
break;
case "transactionCreated":
const created = data.payload as {
transaction: { id: string; status: string };
};
onTransactionCreated?.(created.transaction);
break;
case "complete":
const payload = data.payload as {
transaction:
| { id: string; status: string }
| { status: "failed"; failureReason: string };
};
if (payload.transaction.status === "failed") {
onError({
code: "transactionFailed",
message: (payload.transaction as { failureReason: string })
.failureReason,
});
} else {
onComplete(payload.transaction as { id: string; status: string });
}
break;
case "error":
onError(data.payload as { code: string; message: string });
break;
}
},
[onComplete, onError, onTransactionCreated, onReady],
);
return (
{}}
/>
);
}
```
### Usage
```tsx theme={null}
import { MoonPayWidgetFrame } from "./MoonPayWidgetFrame";
function WidgetPaymentScreen({ clientToken, quoteSignature }) {
return (
console.log("Widget loaded")}
onTransactionCreated={(tx) =>
console.log("Transaction created:", tx.id, tx.status)
}
onComplete={(tx) => console.log("Transaction complete:", tx.id)}
onError={(error) => console.error("Widget error:", error)}
/>
);
}
```
# Web
Source: https://dev.moonpay.com/platform/guides/manual-integration/web
Integrate MoonPay iframes directly in your application.
Use iframes and `postMessage` to embed frames directly in web applications without installing any MoonPay packages.
Read the [manual integration
overview](/platform/guides/manual-integration/overview) for core concepts
before you continue.
## Setup
### Encryption
The connect and check frames require X25519 key exchange to encrypt client credentials. The examples below use [@noble/curves](https://github.com/paulmillr/noble-curves), but you can use any library that supports X25519 and AES-GCM.
```sh pnpm theme={null}
pnpm i @noble/curves @noble/hashes @noble/ciphers
```
```sh bun theme={null}
bun add @noble/curves @noble/hashes @noble/ciphers
```
```sh npm theme={null}
npm i @noble/curves @noble/hashes @noble/ciphers
```
### Message utilities
Create helper functions for sending and receiving frame messages. All messages follow the [frames protocol](/platform/frames/overview#frames-protocol).
```ts messageUtils.ts theme={null}
const FRAME_ORIGIN = "https://blocks.moonpay.com";
interface FrameMessage {
version: number;
meta: { channelId: string };
kind: string;
payload?: unknown;
}
function parseFrameMessage(
event: MessageEvent,
channelId: string,
): FrameMessage | null {
if (event.origin !== FRAME_ORIGIN) return null;
try {
const data: FrameMessage =
typeof event.data === "string" ? JSON.parse(event.data) : event.data;
if (data.meta?.channelId !== channelId) return null;
return data;
} catch {
return null;
}
}
function sendFrameMessage(
iframe: HTMLIFrameElement,
channelId: string,
kind: string,
payload?: object,
) {
const message: FrameMessage = {
version: 2,
meta: { channelId },
kind,
...(payload && { payload }),
};
iframe.contentWindow?.postMessage(JSON.stringify(message), FRAME_ORIGIN);
}
```
***
## Encryption utility
The connect and check frames return encrypted credentials. Generate an X25519 keypair and provide the public key to the frame.
### Key generation
```ts crypto.ts theme={null}
import { x25519 } from "@noble/curves/ed25519";
import { bytesToHex } from "@noble/hashes/utils";
function generateKeyPair() {
const { secretKey, publicKey } = x25519.keygen();
return {
privateKeyHex: bytesToHex(secretKey),
publicKeyHex: bytesToHex(publicKey),
};
}
```
### Decryption
```ts decrypt.ts theme={null}
import { x25519 } from "@noble/curves/ed25519";
import { hkdf } from "@noble/hashes/hkdf";
import { sha256 } from "@noble/hashes/sha256";
import { gcm } from "@noble/ciphers/aes";
import { hexToBytes } from "@noble/hashes/utils";
interface ClientCredentials {
accessToken: string;
clientToken: string;
}
function decryptClientCredentials(
encryptedBase64: string,
privateKeyHex: string,
): ClientCredentials {
const json = JSON.parse(atob(encryptedBase64)) as {
ephemeralPublicKey: string;
iv: string;
ciphertext: string;
};
const sharedSecret = x25519.getSharedSecret(
hexToBytes(privateKeyHex),
hexToBytes(json.ephemeralPublicKey),
);
const derivedKey = hkdf(sha256, sharedSecret, undefined, undefined, 32);
const aes = gcm(derivedKey, hexToBytes(json.iv));
const decrypted = aes.decrypt(hexToBytes(json.ciphertext));
const text = new TextDecoder().decode(decrypted);
return JSON.parse(text) as ClientCredentials;
}
```
***
## Check frame
The check frame verifies whether a customer already has an active connection. It's headless — no UI is rendered. Use it to skip the connect flow for returning customers. See [check frame reference](/platform/frames/check) for event details.
### Initialize the frame
```ts theme={null}
const FRAME_ORIGIN = "https://blocks.moonpay.com";
function initializeCheckFrame(sessionToken: string) {
const iframe = document.getElementById("moonpay-frame") as HTMLIFrameElement;
const channelId = crypto.randomUUID();
const keyPair = generateKeyPair();
const params = new URLSearchParams({
sessionToken,
publicKey: keyPair.publicKeyHex,
channelId,
});
iframe.src = `${FRAME_ORIGIN}/platform/v1/check-connection?${params.toString()}`;
return { iframe, channelId, keyPair };
}
```
### Handle events
```ts theme={null}
interface CheckCompletePayload {
status:
| "active"
| "connectionRequired"
| "pending"
| "unavailable"
| "failed";
credentials?: string;
expiresAt?: string;
capabilities?: {
ramps: {
requirements: {
paymentDisclosures?: {
country: string;
administrativeArea: string;
};
};
};
};
reason?: string;
}
function setupCheckListener(channelId: string, privateKeyHex: string) {
const handler = (event: MessageEvent) => {
const data = parseFrameMessage(event, channelId);
if (!data) return;
const iframe = document.getElementById(
"moonpay-frame",
) as HTMLIFrameElement;
switch (data.kind) {
case "handshake":
sendFrameMessage(iframe, channelId, "ack");
break;
case "complete":
handleCheckComplete(
data.payload as CheckCompletePayload,
privateKeyHex,
);
break;
case "error":
const error = data.payload as { code: string; message: string };
console.error("Check error:", error.code, error.message);
break;
}
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}
function handleCheckComplete(
payload: CheckCompletePayload,
privateKeyHex: string,
) {
switch (payload.status) {
case "active":
const credentials = decryptClientCredentials(
payload.credentials!,
privateKeyHex,
);
// Store credentials in memory for subsequent API calls and frames
console.log("Already connected!");
// Check payload.capabilities.ramps.requirements.paymentDisclosures
// to determine if payment disclosures are required before transacting
break;
case "connectionRequired":
const anonymousCredentials = decryptClientCredentials(
payload.credentials!,
privateKeyHex,
);
// Store both tokens in memory, then pass clientToken to the connect frame
console.log("No active connection — show connect frame");
break;
case "pending":
console.log("Connection pending — customer may need to complete KYC");
break;
case "unavailable":
console.log("Connection unavailable — likely geo-restricted");
break;
case "failed":
console.error("Check failed:", payload.reason);
break;
}
}
```
### Usage
```ts theme={null}
const { channelId, keyPair } = initializeCheckFrame("your-session-token");
const cleanup = setupCheckListener(channelId, keyPair.privateKeyHex);
// When done, clean up the listener
// cleanup();
```
***
## Connect frame
The connect frame establishes a customer connection to your application. See [connect frame reference](/platform/frames/connect) for event details.
### Initialize the frame
```ts theme={null}
const FRAME_ORIGIN = "https://blocks.moonpay.com";
function initializeConnectFrame(clientToken: string) {
const iframe = document.getElementById("moonpay-frame") as HTMLIFrameElement;
const channelId = crypto.randomUUID();
const keyPair = generateKeyPair();
const params = new URLSearchParams({
clientToken,
publicKey: keyPair.publicKeyHex,
channelId,
});
iframe.src = `${FRAME_ORIGIN}/platform/v1/connect?${params.toString()}`;
return { iframe, channelId, keyPair };
}
```
### Handle events
```ts theme={null}
interface ConnectCompletePayload {
status: "active" | "pending" | "unavailable" | "failed";
credentials?: string;
expiresAt?: string;
capabilities?: {
ramps: {
requirements: {
paymentDisclosures?: {
country: string;
administrativeArea: string;
};
};
};
};
reason?: string;
}
function setupConnectListener(channelId: string, privateKeyHex: string) {
const handler = (event: MessageEvent) => {
const data = parseFrameMessage(event, channelId);
if (!data) return;
const iframe = document.getElementById(
"moonpay-frame",
) as HTMLIFrameElement;
switch (data.kind) {
case "handshake":
sendFrameMessage(iframe, channelId, "ack");
break;
case "complete":
handleConnectComplete(
data.payload as ConnectCompletePayload,
privateKeyHex,
);
break;
case "error":
const error = data.payload as { code: string; message: string };
console.error("Connect error:", error.code, error.message);
break;
}
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}
function handleConnectComplete(
payload: ConnectCompletePayload,
privateKeyHex: string,
) {
switch (payload.status) {
case "active":
const credentials = decryptClientCredentials(
payload.credentials!,
privateKeyHex,
);
// Store tokens in memory for subsequent API calls and frames
console.log("Connected!");
// Check payload.capabilities.ramps.requirements.paymentDisclosures
// to determine if payment disclosures are required before transacting
break;
case "pending":
console.log("Connection pending — customer may need to complete KYC");
break;
case "unavailable":
console.log("Connection unavailable — likely geo-restricted");
break;
case "failed":
console.error("Connection failed:", payload.reason);
break;
}
}
```
### Usage
```ts theme={null}
// The clientToken is obtained from the check frame's `connectionRequired` response
const { channelId, keyPair } = initializeConnectFrame("your-client-token");
const cleanup = setupConnectListener(channelId, keyPair.privateKeyHex);
// When done, clean up the listener
// cleanup();
```
***
## Apple Pay frame
The Apple Pay frame renders the Apple Pay button and handles the payment flow. See [Apple Pay frame reference](/platform/frames/apple-pay) for event details.
Apple Pay only works on Safari (macOS and iOS). Check availability before
rendering.
### What you'll need
Before you initialize the Apple Pay frame, you need:
1. A `clientToken` from a successful [connect flow](#connect-frame)
2. A valid [quote signature](/api-reference/platform/endpoints/quotes/get) for the transaction
### Initialize the frame
```ts theme={null}
function initializeApplePayFrame(clientToken: string, quoteSignature: string) {
const iframe = document.getElementById("moonpay-frame") as HTMLIFrameElement;
const channelId = crypto.randomUUID();
const params = new URLSearchParams({
clientToken,
channelId,
signature: quoteSignature,
});
iframe.src = `${FRAME_ORIGIN}/platform/v1/apple-pay?${params.toString()}`;
return { iframe, channelId };
}
```
### Handle events
```ts theme={null}
interface ApplePayCompletePayload {
transaction:
| { id: string; status: "complete" | "pending" }
| { status: "failed"; failureReason: string };
}
function setupApplePayListener(channelId: string) {
const iframe = document.getElementById("moonpay-frame") as HTMLIFrameElement;
const handler = (event: MessageEvent) => {
const data = parseFrameMessage(event, channelId);
if (!data) return;
switch (data.kind) {
case "handshake":
sendFrameMessage(iframe, channelId, "ack");
break;
case "ready":
console.log("Apple Pay button ready");
break;
case "complete":
const payload = data.payload as ApplePayCompletePayload;
if (payload.transaction.status === "failed") {
console.error(
"Transaction failed:",
payload.transaction.failureReason,
);
} else {
console.log("Transaction initiated:", payload.transaction.id);
// Poll for transaction status or wait for webhook
}
break;
case "error":
const error = data.payload as { code: string; message: string };
if (error.code === "quoteExpired") {
// Fetch a new quote and send it to the frame
console.log("Quote expired, fetching new quote...");
// updateApplePayQuote(iframe, channelId, newQuoteSignature);
} else {
console.error("Apple Pay error:", error.code, error.message);
}
break;
}
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}
```
### Update the quote
When the quote expires or changes, send a new quote to the frame:
```ts theme={null}
function updateApplePayQuote(
iframe: HTMLIFrameElement,
channelId: string,
newQuoteSignature: string,
) {
sendFrameMessage(iframe, channelId, "setQuote", {
quote: { signature: newQuoteSignature },
});
}
```
***
## Add Card frame
The add card frame lets a customer save a new card to their account. See [add card frame reference](/platform/frames/add-card) for event details.
### What you'll need
Before you initialize the add card frame, you need:
1. A `clientToken` from a successful [connect flow](#connect-frame)
### Initialize the frame
```ts theme={null}
function initializeAddCardFrame(clientToken: string) {
const iframe = document.getElementById("moonpay-frame") as HTMLIFrameElement;
const channelId = crypto.randomUUID();
const params = new URLSearchParams({
clientToken,
channelId,
});
iframe.src = `${FRAME_ORIGIN}/platform/v1/add-card?${params.toString()}`;
return { iframe, channelId };
}
```
### Handle events
```ts theme={null}
interface AddCardCompletePayload {
card: {
id: string;
brand: string;
last4: string;
cardType: string;
expirationMonth: number;
expirationYear: number;
availability: { active: boolean };
};
}
function setupAddCardListener(channelId: string) {
const iframe = document.getElementById("moonpay-frame") as HTMLIFrameElement;
const handler = (event: MessageEvent) => {
const data = parseFrameMessage(event, channelId);
if (!data) return;
switch (data.kind) {
case "handshake":
sendFrameMessage(iframe, channelId, "ack");
break;
case "ready":
console.log("Add card frame ready");
break;
case "complete":
const payload = data.payload as AddCardCompletePayload;
console.log("Card saved:", payload.card.id);
// Poll for card status or proceed to payment
break;
case "error":
const error = data.payload as { code: string; message: string };
console.error("Add card error:", error.code, error.message);
break;
}
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}
```
### Usage
```ts theme={null}
const { channelId } = initializeAddCardFrame("your-client-token");
const cleanup = setupAddCardListener(channelId);
// When done, clean up the listener
// cleanup();
```
***
## Buy frame
The buy frame processes a card payment for a quote. It is headless — rendered at zero size — while the customer completes payment. If 3-D Secure is required, the frame emits a `challenge` event with a URL you open in a separate challenge frame. See [buy frame reference](/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
### Initialize the frame
```ts theme={null}
function initializeBuyFrame(
clientToken: string,
quoteSignature: string,
externalTransactionId?: string,
) {
const channelId = crypto.randomUUID();
const params = new URLSearchParams({
clientToken,
channelId,
signature: quoteSignature,
...(externalTransactionId && { externalTransactionId }),
});
const iframe = document.createElement("iframe");
iframe.style.cssText =
"position: absolute; width: 0; height: 0; border: none; overflow: hidden;";
iframe.src = `${FRAME_ORIGIN}/platform/v1/buy?${params.toString()}`;
document.body.appendChild(iframe);
return { iframe, channelId };
}
```
### Handle events
```ts theme={null}
interface BuyCompletePayload {
transaction: { id: string; status: string };
}
interface BuyChallengePayload {
kind: string;
url: string;
}
function setupBuyListener(
channelId: string,
iframe: HTMLIFrameElement,
onComplete: (transaction: { id: string; status: string }) => void,
onChallenge: (url: string) => void,
) {
const handler = (event: MessageEvent) => {
const data = parseFrameMessage(event, channelId);
if (!data) return;
switch (data.kind) {
case "handshake":
sendFrameMessage(iframe, channelId, "ack");
break;
case "ready":
console.log("Buy frame ready");
break;
case "complete":
const payload = data.payload as BuyCompletePayload;
console.log("Transaction complete:", payload.transaction.id);
onComplete(payload.transaction);
break;
case "challenge":
const challenge = data.payload as BuyChallengePayload;
onChallenge(challenge.url);
break;
case "error":
const error = data.payload as { code: string; message: string };
if (error.code === "quoteExpired") {
// Fetch a new quote and send it to the frame
console.log("Quote expired, fetching new quote...");
// updateBuyQuote(iframe, channelId, newQuoteSignature);
} else {
console.error("Buy error:", error.code, error.message);
}
break;
}
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}
```
### Update the quote
When the quote expires or changes, send a new quote to the frame:
```ts theme={null}
function updateBuyQuote(
iframe: HTMLIFrameElement,
channelId: string,
newQuoteSignature: string,
) {
sendFrameMessage(iframe, channelId, "setQuote", {
quote: { signature: newQuoteSignature },
});
}
```
### Challenge handling
When the buy frame emits a `challenge` event, open the challenge URL in a new iframe inside a modal. The challenge frame is self-driving after the handshake:
```ts theme={null}
interface ChallengeCompletePayload {
flow: "buy";
transaction: { id: string; status: string };
}
function setupChallengeListener(
challengeChannelId: string,
challengeIframe: HTMLIFrameElement,
buyIframe: HTMLIFrameElement,
buyCleanup: () => void,
onResult: (transaction: { id: string; status: string } | null) => void,
) {
const handler = (event: MessageEvent) => {
const data = parseFrameMessage(event, challengeChannelId);
if (!data) return;
switch (data.kind) {
case "handshake":
sendFrameMessage(challengeIframe, challengeChannelId, "ack");
break;
case "ready":
console.log("Challenge frame ready");
break;
case "complete":
const payload = data.payload as ChallengeCompletePayload;
cleanup();
onResult(payload.transaction);
break;
case "cancelled":
cleanup();
onResult(null);
break;
case "error":
const error = data.payload as { code: string; message: string };
console.error("Challenge error:", error.code, error.message);
cleanup();
onResult(null);
break;
}
};
function cleanup() {
window.removeEventListener("message", handler);
buyCleanup();
challengeIframe.remove();
buyIframe.remove();
}
window.addEventListener("message", handler);
return cleanup;
}
function openChallengeFrame(
challengeUrl: string,
buyIframe: HTMLIFrameElement,
buyCleanup: () => void,
onResult: (transaction: { id: string; status: string } | null) => void,
) {
const modal = document.getElementById("challengeModal") as HTMLElement;
const challengeChannelId = crypto.randomUUID();
const urlWithChannel = new URL(challengeUrl);
urlWithChannel.searchParams.set("channelId", challengeChannelId);
const challengeIframe = document.createElement("iframe");
challengeIframe.src = urlWithChannel.toString();
modal.appendChild(challengeIframe);
return setupChallengeListener(
challengeChannelId,
challengeIframe,
buyIframe,
buyCleanup,
onResult,
);
}
```
### Usage
```ts theme={null}
const { iframe: buyIframe, channelId } = initializeBuyFrame(
"your-client-token",
"your-quote-signature",
);
const buyCleanup = setupBuyListener(
channelId,
buyIframe,
(transaction) => {
console.log("Transaction initiated:", transaction.id);
// Navigate to transaction status screen or poll for updates
},
(challengeUrl) => {
openChallengeFrame(challengeUrl, buyIframe, buyCleanup, (transaction) => {
if (transaction) {
console.log("Challenge complete:", transaction.id);
} else {
console.log("Challenge cancelled or failed");
}
});
},
);
// When done, clean up the listener
// buyCleanup();
```
***
## Widget frame
For payment methods beyond Apple Pay — including credit/debit cards, Google Pay, bank transfers, and more — use the [widget frame](/platform/frames/widget). It renders the full MoonPay buy experience inside an iframe, including payment-method selection and transaction confirmation. See [pay with widget](/platform/guides/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](#connect-frame)
2. A valid [quote signature](/api-reference/platform/endpoints/quotes/get) for the transaction
### Initialize the frame
The widget iframe requires the `payment` [permission
policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Permissions-Policy#iframes)
to process payments.
```ts theme={null}
function initializeWidgetFrame(clientToken: string, quoteSignature: string) {
const iframe = document.getElementById("moonpay-frame") as HTMLIFrameElement;
const channelId = crypto.randomUUID();
// The widget requires the "payment" permission policy
iframe.allow = "payment";
const params = new URLSearchParams({
flow: "buy",
clientToken,
quoteSignature,
channelId,
});
iframe.src = `${FRAME_ORIGIN}/platform/v1/widget?${params.toString()}`;
return { iframe, channelId };
}
```
### Handle events
```ts theme={null}
interface WidgetCompletePayload {
transaction:
| { id: string; status: "complete" | "pending" }
| { status: "failed"; failureReason: string };
}
function setupWidgetListener(channelId: string) {
const iframe = document.getElementById("moonpay-frame") as HTMLIFrameElement;
const handler = (event: MessageEvent) => {
const data = parseFrameMessage(event, channelId);
if (!data) return;
switch (data.kind) {
case "handshake":
sendFrameMessage(iframe, channelId, "ack");
break;
case "ready":
console.log("Widget loaded and visible");
break;
case "transactionCreated":
const created = data.payload as {
transaction: { id: string; status: string };
};
console.log("Transaction created:", created.transaction.id);
// Customer may still need to complete 3-D Secure
break;
case "complete":
const payload = data.payload as WidgetCompletePayload;
if (payload.transaction.status === "failed") {
console.error(
"Transaction failed:",
payload.transaction.failureReason,
);
} else {
console.log("Transaction initiated:", payload.transaction.id);
// Poll for transaction status or wait for webhook
}
break;
case "error":
const error = data.payload as { code: string; message: string };
console.error("Widget error:", error.code, error.message);
break;
}
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}
```
### Usage
```ts theme={null}
const { channelId } = initializeWidgetFrame(
"your-client-token",
"your-quote-signature",
);
const cleanup = setupWidgetListener(channelId);
// When done, clean up the listener
// cleanup();
```
# Generate API clients
Source: https://dev.moonpay.com/platform/guides/openapi-codegen
Generate type-safe API clients from the OpenAPI specification.
The MoonPay Developer Platform API is defined using an OpenAPI 3.1 specification. You can use this spec to generate typed clients for any language.
## Full specification
The OpenAPI 3.1 specification is served at **[https://api.moonpay.com/platform/openapi.json](https://api.moonpay.com/platform/openapi.json)**. Use this URL in your codegen config or download the file for offline use.
## Usage
### TypeScript / JavaScript
Use `@hey-api/openapi-ts` to generate TypeScript types:
```bash theme={null}
npm install -D @hey-api/openapi-ts
```
```ts openapi-ts.config.ts theme={null}
import { defineConfig } from "@hey-api/openapi-ts";
export default defineConfig({
input: "https://api.moonpay.com/platform/openapi.json",
output: {
path: "./src/gen",
},
plugins: [
{
name: "@hey-api/typescript",
enums: false,
},
],
});
```
```bash theme={null}
npx openapi-ts
```
### Dart / Flutter
Use `openapi_generator` with build\_runner:
```yaml theme={null}
# pubspec.yaml
dev_dependencies:
build_runner: ^2.4.0
openapi_generator: ^5.0.0
openapi_generator:
input_spec:
path: https://api.moonpay.com/platform/openapi.json
generator_name: dart
output_directory: lib/gen
```
```bash theme={null}
flutter pub run build_runner build
```
### Other Languages
Use the [OpenAPI Generator](https://openapi-generator.tech/) CLI to generate clients for 50+ languages:
```bash theme={null}
# Install
npm install @openapitools/openapi-generator-cli -g
# Generate (examples)
openapi-generator-cli generate -i https://api.moonpay.com/platform/openapi.json -g kotlin -o ./gen
openapi-generator-cli generate -i https://api.moonpay.com/platform/openapi.json -g swift5 -o ./gen
openapi-generator-cli generate -i https://api.moonpay.com/platform/openapi.json -g python -o ./gen
openapi-generator-cli generate -i https://api.moonpay.com/platform/openapi.json -g go -o ./gen
```
See the [full list of generators](https://openapi-generator.tech/docs/generators).
# Pay with Apple Pay
Source: https://dev.moonpay.com/platform/guides/pay-with-apple-pay
Allow customers to buy crypto headlessly with Apple Pay.
Use this guide to execute a transaction with Apple Pay after you have a [connected customer](/platform/guides/connect-a-customer).
See the [Going Live](/platform/overview/going-live) section for details on the requirements you must meet before you can take this integration to production.
## Prerequisites
* A connected customer (via `client.getConnection()` or `client.connect()`).
* A UI surface where you can render the [Apple Pay frame](/platform/frames/apple-pay).
You can test the full Apple Pay flow without a real Apple Pay account by using
[test mode](/platform/overview/test-mode#apple-pay). The frame renders a mock
Apple Pay button that simulates successful and failed transactions.
## Display payment methods
Use the SDK or API to fetch and display the payment methods that are available for the customer right now.
During the preview, only Apple Pay is available (Safari desktop and iOS only).
```ts List payment methods theme={null}
// After connecting, list available payment methods
const paymentMethodsResult = await client.getPaymentMethods();
if (!paymentMethodsResult.ok) {
// Handle error
}
console.log(paymentMethodsResult.value);
```
```ts Result theme={null}
[
{
type: "apple_pay",
capabilities: {
supportedCurrencies: ["USD", "EUR", "GBP"],
supportedTransactionTypes: ["buy"],
},
availability: {
active: true,
},
},
];
```
## Get quotes
Quotes provide real-time prices and fees for transactions. Present detailed quotes in your UI using data from the SDK or API.
Only executable quotes can be used for transactions. In some cases, quotes require a challenge before you can execute a transaction. See [handling challenges](/platform/guides/handling-challenges) for details.
```ts Get quote theme={null}
const quoteResult = await client.getQuote({
source: "USD", // The fiat currency for payment
destination: "ETH", // The crypto the customer will receive
sourceAmount: "100.00", // The amount to purchase
walletAddress: "0x1234...", // The destination wallet address
paymentMethod: "apple_pay", // The payment method type
});
if (!quoteResult.ok) {
// Handle error
}
console.log(quoteResult.value);
```
```ts Result theme={null}
{
source: {
amount: "100.00",
asset: {
code: "USD",
name: "US Dollar",
precision: 2
}
},
destination: {
amount: "0.025",
asset: {
code: "ETH",
name: "Ethereum",
precision: 18
}
},
fees: {
network: {
amount: "2.50",
currencyCode: "USD"
},
moonpay: {
amount: "3.99",
currencyCode: "USD"
}
},
wallet: {
address: "0x1234..."
},
paymentMethod: {
type: "apple_pay"
},
expiresAt: "2026-01-12T14:45:00Z",
executable: true,
signature: "eyJhbGciOiJFUzI1NiIs..."
}
```
## Execute the transaction
To execute a transaction, set up the payment flow based on the quote. Different payment methods have different requirements—you can configure each as needed to control your experience. For the frame URL, size, permissions, and events, see the [Apple Pay frame](/platform/frames/apple-pay) reference.
In some cases, transactions require a challenge. See [handling
challenges](/platform/guides/handling-challenges) for details.
```ts Apple Pay theme={null}
import type { ApplePayEvent } from "@moonpay/platform";
// Render the Apple Pay frame into your UI and handle callbacks
const applePayResult = await client.setupApplePay({
quote: quoteResult.value.signature, // The quote signature from getQuote
container: document.querySelector("#applePayContainer"), // DOM element to render the button
onEvent: (event: ApplePayEvent) => {
switch (event.kind) {
case "ready":
// The frame is ready. Use this event to reveal the button if needed.
break;
case "complete":
// The transaction is executing. Track the final status via polling and/or webhooks.
console.log(event.payload.transaction);
// { id: "txn_01", status: "pending" }
break;
case "quoteExpired":
// Fetch a new quote, then pass its signature into the frame
// const newQuote = await client.getQuote({...});
// event.payload.setQuote(newQuote.value.signature);
break;
case "error":
// Depending on the error, you can have the customer pick a different payment method or retry.
console.error(event.payload.message);
break;
case "unsupported":
// Apple Pay isn't supported in the current environment.
break;
}
},
});
if (!applePayResult.ok) {
// Handle error setting up Apple Pay
}
// You can update the quote or dispose the frame later
// applePayResult.value.setQuote(newQuoteSignature);
// applePayResult.value.dispose();
```
## Transaction statuses
Transactions have the following statuses:
* **Pending:** The transaction has been initiated and the payment accepted. The assets are being transferred.
* **Complete:** The transaction is finalized. The payment is complete and the assets have been delivered to their destination.
* **Failed:** The transaction has failed. The payment was not executed and funds were not transferred.
# Pay with card
Source: https://dev.moonpay.com/platform/guides/pay-with-card
Allow customers to buy crypto using stored credit and debit cards.
Use this guide to execute a card transaction after you have a [connected
customer](/platform/guides/connect-a-customer). You will list stored cards, add
new ones through a MoonPay-hosted frame, get a quote, and execute the
transaction — with MoonPay handling PCI-compliant card collection, payment
orchestration, and all verification challenges inside hosted frames.
See the [Going Live](/platform/overview/going-live) section for requirements you
must meet before taking this integration to production.
## Prerequisites
* A MoonPay account with card payments enabled. Contact your MoonPay account
team to enable it.
* A connected customer (via `client.getConnection()` or `client.connect()`).
* A UI surface where you can render MoonPay frames (iframe on web, or
[WebView](/platform/overview/requirements#webviews) on mobile).
* A destination wallet address for the purchased crypto.
## Flow overview
```mermaid theme={null}
sequenceDiagram
autonumber
actor C as Customer
participant FE as Your frontend
participant API as MoonPay API
participant ACF as Add Card frame
participant BF as Buy frame
participant CF as Challenge frame
Note over C,CF: Prerequisite: customer is connected
FE->>API: GET /platform/v1/payment-methods
API-->>FE: { paymentMethodConfigs, paymentMethods }
alt No stored cards
FE->>ACF: Render Add Card frame
ACF-->>FE: complete({ card: { id, brand, last4, ... } })
end
C->>FE: Selects card, enters amount
FE->>API: POST /platform/v1/quotes/buy
API-->>FE: { quote with signature }
C->>FE: Confirms purchase
FE->>BF: Render buy frame (signature, clientToken)
alt Happy path
BF-->>FE: complete({ transaction: { id, status } })
else Verification required
BF-->>FE: challenge({ url })
FE->>CF: Render challenge frame at URL
CF-->>FE: complete({ transaction: { id, status } })
end
FE->>API: GET /platform/v1/transactions/{id}
Note over FE: Poll for final status
```
Fetch the customer's available payment method types and stored cards.
```ts List payment methods theme={null}
const paymentMethodsResult = await client.getPaymentMethods();
if (!paymentMethodsResult.ok) {
// Handle error
}
console.log(paymentMethodsResult.value);
```
```ts Result theme={null}
{
paymentMethodConfigs: [
{
type: "card",
capabilities: {
supportedCurrencies: ["USD", "EUR", "GBP"],
supportedTransactionTypes: ["buy"],
allowsDeletion: true,
},
availability: { active: true },
},
],
paymentMethods: [
{
id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
type: "card",
brand: "visa",
last4: "4242",
expirationMonth: "12",
expirationYear: "2027",
cardType: "credit",
availability: { active: true },
},
],
}
```
The result contains two collections:
* **`paymentMethodConfigs`** — available payment method types. Check for
`type: "card"` to confirm card payments are available for this customer.
* **`paymentMethods`** — the customer's stored cards. Each entry includes
`brand`, `last4`, `expirationMonth`, `expirationYear`, `cardType`, and
`availability`.
Display active cards in your payment method picker. For inactive cards
(`availability.active: false`), show the `reasons` value and prompt the user to
add a new card.
| `reasons` value | Suggested UX |
| --------------- | ------------------------------------------------ |
| `card_expired` | "This card has expired. Add a new card." |
| `card_blocked` | "This card is no longer available." |
| `card_declined` | "This card can't be used. Try a different card." |
If `paymentMethods` is empty and `paymentMethodConfigs` includes `type:
"card"`, guide the customer to add one using the Add Card frame (Step 2).
When the customer needs to add a new card, set up the Add Card frame. The frame
collects card details and billing address inside a PCI-compliant MoonPay-hosted
UI — card data never touches your domain. For the frame URL, size, and events,
see the [Add Card frame](/platform/frames/add-card) reference.
```ts Add a card theme={null}
import type { AddCardEvent } from "@moonpay/platform";
const addCardResult = await client.setupAddCard({
container: document.querySelector("#addCardContainer"),
onEvent: (event: AddCardEvent) => {
switch (event.kind) {
case "ready":
// Frame rendered — reveal the modal if it was hidden
break;
case "complete":
// Card added. Use event.payload.card.id to get a quote in Step 3.
console.log(event.payload.card);
// { id, brand, last4, cardType, expirationMonth, expirationYear }
break;
case "error":
console.error(event.payload.message);
break;
}
},
});
if (!addCardResult.ok) {
// Handle error setting up the Add Card frame
}
```
The `complete` event returns the new card's full details including its `id`. Use
this `id` directly to get a quote in Step 3 — no need to re-fetch payment
methods.
With a stored card selected, request a quote. Pass the card's `id` in
`paymentMethod` so MoonPay can evaluate card-specific requirements.
```ts Get quote theme={null}
const quoteResult = await client.getQuote({
source: "USD",
destination: "ETH",
sourceAmount: "100.00",
walletAddress: "0x1234567890abcdef1234567890abcdef12345678",
paymentMethod: { type: "card", id: cardId },
});
if (!quoteResult.ok) {
// Handle error
}
console.log(quoteResult.value);
```
```ts Result theme={null}
{
source: {
amount: "100.00",
asset: { code: "USD" }
},
destination: {
amount: "0.025",
asset: { code: "ETH" }
},
fees: {
network: { amount: "2.50", currencyCode: "USD" },
moonpay: { amount: "3.99", currencyCode: "USD" }
},
wallet: { address: "0x1234..." },
paymentMethod: { type: "card", id: "a1b2c3d4-..." },
expiresAt: "2026-04-29T15:45:00Z",
executable: true,
signature: "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```
Display the quote in your buy confirmation screen: source amount, destination
amount, fees, and exchange rate. Monitor `expiresAt` and refresh the quote
before it expires. If the buy frame is already loaded, call
`buyResult.value.setQuote(newSignature)` instead of re-creating the frame.
### Headless buy frame
Use `client.setupBuy()` when you want full control over your purchase UI. The
frame is headless — no visible UI — and emits events for you to handle. For the
frame URL, parameters, and events, see the [Buy frame](/platform/frames/buy)
reference.
```ts setupBuy theme={null}
import type { BuyEvent } from "@moonpay/platform";
const buyResult = await client.setupBuy({
quote: quoteResult.value.signature,
onEvent: (event: BuyEvent) => {
switch (event.kind) {
case "ready":
// Pipeline starting — show a loading indicator
break;
case "complete":
// Transaction complete. Track status via polling.
console.log(event.payload.transaction);
// { id: "txn_01", status: "pending" }
break;
case "challenge":
// Verification required — render the challenge frame
openChallengeFrame(event.payload.url, buyResult);
break;
case "quoteExpired":
// Fetch a new quote, then update the frame:
// const newQuote = await client.getQuote({...});
// event.payload.setQuote(newQuote.value.signature);
break;
case "error":
console.error(event.payload.message);
break;
}
},
});
if (!buyResult.ok) {
// Handle error
}
```
When the buy frame emits a `challenge` event, the customer must complete one or
more verification steps before the transaction can proceed. Set up the challenge
frame with the URL from the event payload — do not construct the URL yourself.
For the frame URL, parameters, and events, see the [Challenge
frame](/platform/frames/challenge) reference.
The frame is self-driving: after initialization, it sequences through all
required verification steps, creates the transaction, and emits `complete` when
the pipeline finishes.
```ts Handle challenges theme={null}
import type { ChallengeEvent } from "@moonpay/platform";
async function openChallengeFrame(
challengeUrl: string,
buyResult: SetupBuyResult,
) {
// The challenge URL does not include a channelId — append one before rendering.
const url = new URL(challengeUrl);
url.searchParams.set("channelId", crypto.randomUUID());
const challengeResult = await client.setupChallenge({
challengeUrl: url.toString(),
container: document.querySelector("#challengeModal"),
onEvent: (event: ChallengeEvent) => {
switch (event.kind) {
case "ready":
// Challenge UI is rendered and visible
break;
case "complete":
// All verification resolved, transaction complete.
buyResult.value.dispose();
navigateToConfirmation(event.payload.transaction);
break;
case "cancelled":
// Customer dismissed the challenge — allow retry.
buyResult.value.dispose();
showRetryOption();
break;
case "error":
buyResult.value.dispose();
console.error(event.payload.message);
break;
}
},
});
}
```
When you receive `complete`, `cancelled`, or `error` from the challenge frame,
call `buyResult.value.dispose()` to also tear down the buy frame.
The frame handles all verification types automatically — KYC, Strong Customer
Authentication (SCA), CVC re-entry, wallet ownership, micro-authorization, and
3D Secure (3DS). You never need to distinguish between them.
When the buy frame or challenge frame emits `complete`, the payload includes
`{ transaction: { id, status } }`. The transaction is created and payment is
processing.
```ts Track the transaction theme={null}
async function pollTransaction(transactionId: string) {
const terminal = new Set(["completed", "failed"]);
while (true) {
const res = await client.getTransaction(transactionId);
if (!res.ok) throw new Error(res.error.message);
if (terminal.has(res.value.status)) return res.value.status;
await new Promise((r) => setTimeout(r, 3000));
}
}
```
## Transaction statuses
Transactions have the following statuses:
* **Pending:** The transaction has been initiated and the payment accepted. The
assets are being transferred.
* **Complete:** The transaction is finalized. The payment is complete and the
assets have been delivered to their destination.
* **Failed:** The transaction has failed. The payment was not executed and funds
were not transferred.
Webhook support is coming soon. Until then, use polling to track transaction
status.
Let customers remove stored cards at any time.
```ts Delete a stored card theme={null}
const deleteResult = await client.deletePaymentMethod(paymentMethodId);
if (!deleteResult.ok) {
// Handle error
}
```
* Deleting an already-deleted card returns success (idempotent).
* Deleting a card with a pending transaction is rejected.
* Only payment methods with `allowsDeletion: true` can be deleted.
# Pay with widget
Source: https://dev.moonpay.com/platform/guides/pay-with-widget
Render the MoonPay buy widget to support all payment methods and regions.
Use this guide to render the MoonPay buy widget after you have a
[connected customer](/platform/guides/connect-a-customer). The widget renders
the full MoonPay buy experience — including payment-method selection
and transaction confirmation — inside an iframe in your application.
It supports all payment methods and regions available in the standard
MoonPay integration.
## Prerequisites
* A connected customer (via `client.getConnection()` or `client.connect()`).
* A UI surface where you can render the widget frame (for example, a
modal or full-screen container).
## Get a quote
Request a quote for the transaction. The widget requires an executable
quote with a wallet address.
If you include a `paymentMethod` in the quote, the widget pre-selects
that method. If you omit it, the customer picks one inside the widget.
```ts Get quote (pre-select payment method) theme={null}
const quoteResult = await client.getQuote({
source: "USD",
destination: "ETH",
sourceAmount: "100.00",
walletAddress: "0x1234...",
paymentMethod: "credit_debit_card", // Pre-selects credit/debit card
});
if (!quoteResult.ok) {
// Handle error
}
```
```ts Get quote (customer chooses) theme={null}
const quoteResult = await client.getQuote({
source: "USD",
destination: "ETH",
sourceAmount: "100.00",
walletAddress: "0x1234...",
// No paymentMethod — customer selects inside the widget
});
if (!quoteResult.ok) {
// Handle error
}
```
Supported `paymentMethod` values include `credit_debit_card`, `google_pay`,
`sepa_bank_transfer`, `gbp_bank_transfer`, `gbp_open_banking_payment`,
`pix_instant_payment`, `interac`, `paypal`, `revolut_pay`, `venmo`, and
`moonpay_balance`. If the pre-selected method is unavailable for the customer,
the widget falls back to the payment selection screen. Passing `apple_pay`
pre-selects the card form instead — use the [headless Apple Pay
flow](/platform/guides/pay-with-apple-pay) for native Apple Pay.
## Render the widget
Pass the quote signature to `setupWidget`. The widget handles the
entire purchase flow inside the iframe.
```ts Widget theme={null}
import type { WidgetEvent } from "@moonpay/platform";
const widgetResult = await client.setupWidget({
quote: quoteResult.value.signature,
container: document.querySelector("#widgetContainer"),
onEvent: (event: WidgetEvent) => {
switch (event.kind) {
case "ready":
// The widget is loaded and visible.
break;
case "transactionCreated":
// A transaction has been initiated. The customer may still
// need to complete additional steps (for example, 3-D Secure).
console.log(event.payload.transaction);
// { id: "txn_01", status: "waitingAuthorization" }
break;
case "complete":
// The transaction has reached a terminal state.
console.log(event.payload.transaction);
// { id: "txn_01", status: "pending" }
break;
case "error":
console.error(event.payload.message);
break;
}
},
});
if (!widgetResult.ok) {
// Handle error setting up the widget
}
// Clean up when done
// widgetResult.value.dispose();
```
## Transaction statuses
* **waitingAuthorization:** The transaction has been created but the
customer needs to complete an authorization step (for example,
3-D Secure).
* **Pending:** The payment has been accepted and the assets are being
transferred.
* **Complete:** The transaction is finalized and the assets have been
delivered.
* **Failed:** The transaction has failed. No payment was applied.
## Choosing between Apple Pay and the widget
| Criteria | Apple Pay | Widget |
| --------------- | ------------------------------- | ---------------------- |
| Payment methods | Apple Pay only | All supported methods |
| UI control | Headless — you own the UI | MoonPay-hosted UI |
| Regions | Where Apple Pay is available | All supported regions |
| Best for | Streamlined single-tap checkout | Broad payment coverage |
Both flows start from a connected customer and a quote. Use
`getPaymentMethods()` to determine which methods are available and
choose the flow that fits your experience.
# Configure frame appearance
Source: https://dev.moonpay.com/platform/guides/presentation-and-appearance
Control how co-branded frames look and behave in your app.
Use this guide to keep co-branded frames consistent with the rest of your UI.
Frames render MoonPay-hosted UI inside your app, so presentation choices affect
how the flow feels to customers.
## Prerequisites
* A [connected customer](/platform/guides/connect-a-customer).
* A container element in your UI where you render frames.
## Choose the right UI surface
* **Web**: Render co-branded frames in a modal or sheet and keep the rest of your UI visible behind it.
* **Mobile**: Present co-branded frames in a full-screen route or full sheet. This reduces layout issues when the frame navigates between steps.
## Control light and dark appearance
The [connect frame](/platform/frames/connect) supports a `theme` parameter. Use it to force `dark` or `light` instead of relying on the user's system appearance.
If you use the SDK, pass `theme` when you initialize the connect flow:
```ts Set appearance example highlight={6-8} theme={null}
import { createClient } from "@moonpay/platform";
const clientResult = createClient({ sessionToken: "..." });
const client = clientResult.value;
const connectResult = await client.connect({
container: connectContainer,
theme: {
appearance: "dark", // Force dark mode
},
onEvent: (event) => {
// Handle events
},
});
```
If you build the connect frame URL manually, include `theme` in the query string:
```html Example URL theme={null}
https://blocks.moonpay.com/platform/v1/connect?sessionToken=c3N0XzAwMQ==&publicKey=...&channelId=ch_1&theme=dark
```
If you omit `theme`, the frame uses the user's system preference.
## Practical customization tips
* **Avoid resizing containers mid-flow**: Use a stable container size while the frame is mounted to prevent content jumps.
* **Show clear loading states**: If you wait for a frame’s `ready` event, keep your UI responsive and show a spinner/skeleton.
* **Plan for errors**: If a frame dispatches an `error` event, show a developer-friendly fallback (retry, exit, or contact support).
## Next steps
Learn the shared messaging protocol for integrating without the SDK.
See all initialization parameters including `theme`.
# Core concepts
Source: https://dev.moonpay.com/platform/overview/core-concepts
Key terms and concepts for integrating with the MoonPay Platform
## Connections
An active **connection** represents a customer's permission to associate the MoonPay account to your application so you can:
* List and manage payment methods in your UI
* Get quotes that include detailed fees and customer limits
* Execute payments (for example, Apple Pay or debit cards) without redirecting the customer
* View and track transaction history
Read the [connect a customer guide](/platform/guides/connect-a-customer) for
implementation details.
## Frames
The integration uses a combination of API calls and frames. For steps that have compliance or regulatory requirements, you render an embedded frame (a WebView in mobile apps and an iframe on the web). This keeps sensitive data out of your application while MoonPay manages compliance.
There are two types of frames: **co-branded** and **headless**. Both communicate with your app using `postMessage` (on web and mobile). Each frame has its own lifecycle events and message patterns, documented in the [frames overview](/platform/frames/overview). You can manage frames yourself, or use the SDK for drop-in frame setup and event handling.
### Co-branded frames
Co-branded frames render MoonPay-hosted UI that you can theme to match your application. They are designed for contextual rendering in modals or sheets. A typical example is the MoonPay login flow used when a customer connects their account.
### Headless frames
Headless frames are either invisible or display minimal, non-customizable elements such as an Apple Pay button. You can inline these frames wherever needed in your UI.
## Challenges
Challenges complete specific tasks that require upgraded authentication, identity verification, or connection updates. Common cases include:
* Authentication upgrades (required for sensitive or destructive actions)
* Identity verification (Know Your Customer, KYC)
* Strong Customer Authentication (SCA), such as [3D Secure](https://www.checkout.com/products/authentication-3ds), where the customer’s bank requires additional verification
Challenges are typically rendered in frames. When an API or SDK response requires a challenge, it includes instructions for rendering and completing it.
## Customer
A customer is a person using your app who can have a connected MoonPay account.
### KYC
When opening an account, MoonPay performs Know Your Customer (KYC) checks to meet financial compliance requirements and help prevent fraud and money laundering. By default, this happens in a themeable co-branded frame.
KYC Sharing
Coming soon!
If you already capture KYC information using a provider like SumSub or Persona,
you can use their sharing capabilities to simplify onboarding. You can
also share MoonPay-verified KYC data with other services in your app, like
setting up a debit card.
## Quotes
Quotes provide real-time prices and fees for fiat-to-crypto purchases. There are two types: **price quotes** and **executable quotes**.
Check the [quotes API reference](/api-reference/platform/endpoints/quotes/get)
for detailed usage.
### Price quotes
Price quotes help you estimate transaction costs before you show a confirmation step. They include general limits, and you can request them through the API without a connected customer.
### Executable quotes
Executable quotes provide detailed fees and limits for transactions. Getting an executable quote requires an active [connection](#connections).
# Going Live
Source: https://dev.moonpay.com/platform/overview/going-live
Acceptance criteria for going live with your integration.
All requirements on this page are verified by MoonPay before going live.
## Overview
This page defines the acceptance criteria that apply to all MoonPay Platform integrations. Meeting these requirements is a condition of going live. They address usability, legal accuracy, and regulatory compliance.
MoonPay may update these criteria at any time. When changes are required, MoonPay will provide reasonable written notice to allow time for implementation.
All information you present to the customer must be true, accurate, and not
misleading.
***
## Payment disclosures
When rendering the [Apple Pay frame](/platform/guides/pay-with-apple-pay), you must show a specific disclosure to customers located in New York or Washington. Customers in other regions do not need to be shown a disclosure. The region is returned with an active connection on the [capabilities](/platform/guides/connect-a-customer#capabilities) object.
The disclosure must be visible without any interaction. It must not be hidden behind expandable menus, tooltips, or secondary screens. The Terms of Use must be a tappable link. The full text must be rendered without truncation.
Display the following exact text directly above the Apple Pay frame for customers located in **NY or WA**:
I agree to MoonPay's Terms of Use and understand that, once executed, this transaction cannot be cancelled, recalled, refunded, or otherwise undone. Fraudulent transactions may result in the loss of funds with no recourse.
```html wrap theme={null}
I agree to MoonPay's
Terms of Use and
understand that, once executed, this transaction cannot be cancelled, recalled,
refunded, or otherwise undone. Fraudulent transactions may result in the loss of
funds with no recourse.
```
***
## Fee display
When using Apple Pay, you don't need to display fees before a transaction — the Apple Pay sheet shows them on the customer's device. If you decide to present fees anyway, or are using a non-Apple Pay payment method, the following criteria apply.
### Required line items
Every transaction quote must surface the following line items in your UI:
| Line item | Description |
| ------------------------------- | ------------------------------------------------------- |
| **You pay** | The total fiat amount charged to the customer |
| **Network fee** | The on-chain gas fee |
| **Ecosystem fee** | Any fee applied by your platform |
| **MoonPay fee** | MoonPay's transaction fee |
| **Amount used to buy \[token]** | The fiat amount used for the crypto purchase after fees |
| **At the exchange rate** | The fiat-to-crypto rate applied to the transaction |
| **Total crypto you'll get** | The crypto amount the customer will receive |
### Waived fees
If any fee is waived, it must still appear in the UI with a displayed value of **\$0.00**. Do not omit a line item because the fee is zero.
### Amount consistency
The total shown to the customer must exactly match the amount that will be charged. Any discrepancy between the quoted total and the final payment will block go-live approval.
# Introduction
Source: https://dev.moonpay.com/platform/overview/introduction
Build fiat->crypto experiences with headless payments. You control the user experience while MoonPay handles compliance, risk, and fraud.
## Welcome to the MoonPay Platform
Use the MoonPay Platform APIs, SDKs, and frames to build crypto ramps directly in your app. Before you start, review the [requirements](/platform/overview/requirements) and [core concepts](/platform/overview/core-concepts).
## Getting started
Connect a customer’s MoonPay account so you can list payment methods, get
quotes, and execute transactions.
Set up a payment method (like Apple Pay) and execute a transaction in your
UI.
Embed headless and co-branded UI in your app.
Explore the Platform API endpoints and parameters.
## Quickstart
Get a session token>}>
Create a [session token](/api-reference/platform/endpoints/sessions/create) on your server and send the token to your frontend.
```ts Create session token theme={null}
// Server-side code example
const url = "https://api.moonpay.com/platform/v1/sessions";
const res = await fetch(url, {
headers: {
"Content-Type": "application/json",
Authorization: "Api-Key sk_test_123",
},
method: "POST",
body: JSON.stringify({
externalCustomerId: "your_user_id",
deviceIp: "...ip address from client",
}),
});
console.log(await res.json());
```
```json Result theme={null}
{
"sessionToken": "c3N0XzAwMQ=="
}
```
Connect a customer>}>
On your frontend, check whether the customer has an active connection. If they do, you receive credentials for the next steps.
```ts Check the connection theme={null}
import { createClient } from "@moonpay/platform";
// Create the client with your session token
const clientResult = createClient({
sessionToken: "c3N0XzAwMQ==", // The session token from your server
});
if (!clientResult.ok) {
// Handle error creating client
}
const client = clientResult.value;
// Check if the customer has an active connection
const connectionResult = await client.getConnection();
if (!connectionResult.ok) {
// Handle error
}
console.log(connectionResult.value);
```
```ts Result (active) theme={null}
{
status: "active",
customer: {
id: "Y3VzX2FiYzEyMw=="
},
credentials: {
accessToken: "c2F0XzAwMQ==",
clientToken: "c2N0XzAwMQ=="
}
}
```
```ts Result (requires connection) theme={null}
{
status: "connectionRequired",
credentials: {
accessToken: "c2F0XzAwMQ==",
clientToken: "c2N0XzAwMQ=="
}
}
```
List payment methods>}>
List the payment methods available to the customer at the current time.
```ts List payment methods theme={null}
// After connecting, list available payment methods
const paymentMethodsResult = await client.getPaymentMethods();
if (!paymentMethodsResult.ok) {
// Handle error
}
console.log(paymentMethodsResult.value);
// [{ type: "apple_pay", capabilities: {...}, availability: {...} }, ...]
```
Get quotes>}>
Get [quotes](/platform/overview/core-concepts#executable-quotes) with detailed fees and limits for transactions.
```ts Get quotes theme={null}
const quoteResult = await client.getQuote({
source: "USD", // The fiat currency for payment
destination: "ETH", // The crypto the customer will receive
sourceAmount: "100.00", // The amount to purchase
walletAddress: "0x...", // The destination wallet address
paymentMethod: "apple_pay",
});
if (!quoteResult.ok) {
// Handle error
}
console.log(quoteResult.value);
// { signature: "...", expiresAt: "2026-01-12T14:45:00Z", ... }
```
Execute headless payments>}>
Once you have a quote, [execute the transaction](/platform/guides/pay-with-apple-pay).
```ts Pay with Apple Pay theme={null}
import type { ApplePayEvent } from "@moonpay/platform";
const paymentButtonContainer = document.querySelector("#payment");
const setupApplePayResult = await client.setupApplePay({
quote: quoteResult.value.signature, // The quote signature
container: paymentButtonContainer,
onEvent: (event: ApplePayEvent) => {
switch (event.kind) {
case "ready":
// Reveal the button
paymentButtonContainer.style.opacity = "1";
break;
case "complete":
// The transaction is executing. Use polling and/or webhooks to track final status.
console.log(event.payload.transaction);
// { id: "txn_01", status: "pending" }
break;
case "quoteExpired":
// Fetch a new quote and update the frame
// event.payload.setQuote(newQuote.signature);
break;
}
},
});
if (!setupApplePayResult.ok) {
// Handle error
}
```
# Requirements
Source: https://dev.moonpay.com/platform/overview/requirements
Requirements for the headless ramp integration.
To integrate the headless ramp, you will need:
* A partner account and API credentials
* A frontend (web or mobile) app
* If you're using the SDK, ensure it's installed
* A server for sending requests to MoonPay and receiving webhooks
During the preview, we will work with you directly to set up your account and
credentials.
## Integrating on the web
### Content Security Policy
If you embed MoonPay frames on the web, your Content Security Policy (CSP) must allow MoonPay’s iframes and network calls.
Your [CSP](https://developer.mozilla.org/en-US/docs/Glossary/CSP) should include at least the following rules:
* [frame-src](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/frame-src)
* [connect-src](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/connect-src)
```sh theme={null}
Content-Security-Policy: frame-src https://*.moonpay.com/; connect-src https://*.moonpay.com/;
```
## Configuration
### Domain settings
When integrating MoonPay on the web, provide your app’s [origin](https://developer.mozilla.org/en-US/docs/Web/API/URL/origin) per environment. This allows MoonPay to embed frames securely.
### Apple Pay
To use Apple Pay on the web, you must complete Apple’s domain verification to prove ownership of your site. This is currently a manual process.
## Integrating in mobile apps
### WebViews
This integration supports using [WKWebView](https://developer.apple.com/documentation/webkit/wkwebview) on iOS and [WebView](https://developer.android.com/reference/android/webkit/WebView) on Android.
When using `WKWebview` on iOS you will need to set [`allowsInlineMediaPlayback`](https://developer.apple.com/documentation/webkit/wkwebviewconfiguration/allowsinlinemediaplayback) to `true` for the [Connect frame](/platform/frames/connect).
# Test mode
Source: https://dev.moonpay.com/platform/overview/test-mode
Use test mode to develop and test your integration without transferring real assets.
MoonPay provides a test mode for integration development and testing. Test mode uses testnet blockchains and simulated payments. No real assets are transferred.
## Enabling test mode
Test mode is determined by the API key you use when [creating a session](/api-reference/platform/endpoints/sessions/create). Use your **test API key** (`sk_test_...`) to enable test mode. Use your **live API key** (`sk_live_...`) for production.
You can find your API keys on the [Developers page](https://dashboard.moonpay.com/developers) in your MoonPay dashboard.
All frames and SDK methods automatically operate in test mode when initialized
with a session token created using test API keys.
## Test accounts
When creating test accounts:
* **KYC verification is simulated** - documents are not verified
* **We recommend using a US or UK address** for test accounts, as these work best with test payment cards
* You can skip document submission by clicking "Skip document submission" if prompted
## Test data
### Email addresses
You must use a real email address that you can access. MoonPay sends OTP codes for login verification, and there are no test bypass values.
You cannot reuse the same email address for different customers.
**Tip:** Use the `+` suffix pattern to create multiple unique addresses from a single inbox: `you+test1@example.com`, `you+test2@example.com`, etc.
### Phone numbers
You must use a real phone number that you can access. MoonPay sends OTP codes for verification, and there are no test bypass values.
A phone number can only be associated with one customer at a time. If you
verify with the same phone number on a different customer, it will be removed
from the previous customer.
### SSN (US residents)
SSN values are not verified in test mode. You can enter any 9-digit value.
Do not use your real Social Security number. Use a fake value like
`123456789`.
## Test payment cards
Use the following test cards to simulate payments. **Do not enter real payment card information.** Use any valid 3-digit CVV (or 4-digit for Amex) and any future expiration date.
### US customers
| Card type | Card number | Expiration | CVV |
| :---------------- | :-------------------- | :--------- | :----- |
| Visa Credit | `4000 0200 0000 0000` | `12/2030` | `100` |
| Mastercard Credit | `5436 0310 3060 6378` | `12/2030` | `100` |
| Amex Credit | `3456 7890 1234 564` | `12/2030` | `1000` |
### UK customers
| Card type | Card number | Expiration | CVV |
| :--------------- | :-------------------- | :--------- | :---- |
| Visa Credit | `4242 4242 4242 4242` | `12/2030` | `100` |
| Visa Debit | `4659 1055 6905 1157` | `12/2030` | `100` |
| Mastercard Debit | `5305 4847 4880 0098` | `12/2030` | `100` |
### EU customers
| Card type | Card number | Expiration | CVV |
| :-------------------- | :-------------------- | :--------- | :----- |
| Visa Debit (FR) | `4010 0617 0000 0021` | `12/2030` | `100` |
| Mastercard Debit (DE) | `5305 4847 4880 0098` | `12/2030` | `100` |
| Amex Credit (ES) | `3456 7890 1234 564` | `12/2030` | `1000` |
### Declined transactions
Use these cards to test error handling for different failure scenarios.
| Card number | Expiration | CVV | Decline reason |
| :-------------------- | :--------- | :---- | :----------------------- |
| `4544 2491 6767 3670` | `12/2030` | `100` | Insufficient funds |
| `4897 4535 6848 5113` | `12/2030` | `100` | Suspected fraud |
| `4818 9242 5013 1070` | `12/2030` | `100` | Restricted card |
| `4556 2537 5271 2245` | `12/2030` | `100` | Security violation |
| `4095 2548 0264 2505` | `12/2030` | `100` | Timeout / Internal error |
| `5437 8211 3539 9682` | `12/2030` | `100` | Insufficient funds |
| `5279 9884 0539 8834` | `12/2030` | `100` | Restricted card |
| `5265 1622 7058 7964` | `12/2030` | `100` | Timeout / Internal error |
| `5363 4501 8040 2239` | `12/2030` | `100` | Lost card |
## Apple Pay
In test mode, the Apple Pay frame renders a mock Apple Pay button instead of the native Apple Pay UI. When a customer taps the mock button, a browser confirmation dialog (`window.confirm`) appears in place of the Apple Pay payment sheet:
* **Ok** simulates a successful transaction.
* **Cancel** simulates a failed transaction.
This lets you test the full Apple Pay flow without a real Apple Pay account or Safari-specific setup.
If you embed the Apple Pay frame in an iframe with a `sandbox` attribute,
include `allow-modals` in the sandbox value. This allows `window.confirm` to
work cross-origin inside the frame. See the [Apple Pay frame
reference](/platform/frames/apple-pay#permissions) for details.
## Supported testnets
Test mode supports the following tokens and testnets:
| Token | Testnet |
| :------------ | :------- |
| Bitcoin | Testnet3 |
| Ethereum | Sepolia |
| ERC-20 tokens | Sepolia |
| Solana | Testnet |
| Binance Coin | Testnet |
| TON | Testnet |
| Stellar | Testnet |
| Litecoin | Testnet |
ERC-20 token transfers in test mode use MoonPay's test token contract.
## Troubleshooting
### Common errors
| Error | Cause | Solution |
| :-------------------------------------------------------------------- | :-------------------------------------------------- | :-------------------------------------------------------------------------------------------------- |
| `Framing 'https://blocks.moonpay.com' violates "frame-ancestors" CSP` | Your app or website domain has not been allowlisted | Add domains at [https://dashboard.moonpay.com/developers](https://dashboard.moonpay.com/developers) |
# Using agents
Source: https://dev.moonpay.com/platform/overview/using-agents
Connect AI coding agents to the MoonPay Platform documentation using MCP, llms.txt, and contextual code actions.
AI coding agents can search and reference the MoonPay Platform documentation
directly from your development environment. The docs provide four integration
points: an MCP server for real-time search, agent skills for structured
capabilities, `llms.txt` files for bulk context, and contextual code actions on
every code block.
## MCP server
The documentation includes a
[Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that
gives AI agents direct access to search and retrieve content. Instead of relying
on web search, your agent queries the documentation index for accurate,
up-to-date results.
The server URL is:
```bash MCP server URL theme={null}
https://dev.moonpay.com/mcp
```
### Connect your client
Run the following command to add the MCP server to Claude Code:
```bash Add MCP server theme={null}
claude mcp add --transport http moonpay-docs https://dev.moonpay.com/mcp
```
By default this adds the server to your local scope. Use `--scope project`
to share the configuration with your team via `.mcp.json`, or
`--scope user` to make it available across all your projects.
Add the MCP server to your project's `.cursor/mcp.json` file, or open
**Settings → Cursor Settings → MCP** and add a new server:
```json .cursor/mcp.json theme={null}
{
"mcpServers": {
"moonpay-docs": {
"url": "https://dev.moonpay.com/mcp"
}
}
}
```
Restart Cursor after adding the server.
Add the following to your Claude Desktop configuration file
(`claude_desktop_config.json`). On macOS, this file is at
`~/Library/Application Support/Claude/claude_desktop_config.json`. On
Windows, it is at `%APPDATA%\Claude\claude_desktop_config.json`.
```json claude_desktop_config.json theme={null}
{
"mcpServers": {
"moonpay-docs": {
"url": "https://dev.moonpay.com/mcp"
}
}
}
```
Restart Claude Desktop after saving the file.
Add the following to your Codex configuration file at
`~/.codex/config.toml`, or to a project-scoped `.codex/config.toml`:
```toml config.toml theme={null}
[mcp_servers.moonpay-docs]
url = "https://dev.moonpay.com/mcp"
```
You can also add it with the CLI:
```bash Add MCP server theme={null}
codex mcp add moonpay-docs https://dev.moonpay.com/mcp
```
Any MCP-compatible client can connect using the server URL above. Refer to
your client's documentation for setup instructions.
## Skills
The documentation publishes a
[`SKILL.md`](https://agentskills.io/) file that describes MoonPay Platform
capabilities in a structured, machine-readable format. Unlike the MCP server,
which responds to individual queries, `SKILL.md` gives an agent a complete
picture of available workflows, required inputs, and constraints up front.
View the generated file at
[`dev.moonpay.com/SKILL.md`](https://dev.moonpay.com/SKILL.md).
### Install skills
Run the following command to add MoonPay Platform skills to your agent's
context:
```bash npx theme={null}
npx skills add https://dev.moonpay.com
```
```bash bunx theme={null}
bunx skills add https://dev.moonpay.com
```
```bash pnpm dlx theme={null}
pnpm dlx skills add https://dev.moonpay.com
```
The skills CLI discovers and installs the `skill.md` file automatically. Once
installed, your agent can reference MoonPay Platform capabilities without
additional configuration.
## Contextual code actions
Code blocks throughout these docs include contextual actions for sending code
directly to an AI tool. Hover over any code block to see options for Cursor,
Claude, and ChatGPT. Each option copies the code along with surrounding
documentation context so the AI tool understands how to use it.
## llms.txt
The documentation provides
[`llms.txt`](https://llmstxt.org/) files for direct content
ingestion by large language models:
| File | Description |
| :------------------------------------------------------- | :------------------------------------------------------------- |
| [`llms.txt`](https://dev.moonpay.com/llms.txt) | A concise summary of the documentation structure and key pages |
| [`llms-full.txt`](https://dev.moonpay.com/llms-full.txt) | The complete documentation content for full-context indexing |
Use these files to give an AI tool broad context about the MoonPay Platform
without connecting to the MCP server. You can paste the contents into a chat
session or point a tool at the URL directly.
# Overview
Source: https://dev.moonpay.com/widget/advantages-of-our-products
MoonPay is the top payment infrastructure solution for cryptocurrencies that provides a seamless bridge between traditional financial systems and the new world of cryptocurrencies. Developers who are building on Web3 (a vision for a decentralized internet where users control their data and interactions) can greatly benefit from integrating with the MoonPay API and SDKs for a few key reasons:
**Web3 leader**: MoonPay is the leading ramps provider in the space and continues to be the most popular choice for crypto and NFT applications across the world.
**Highest conversion**: Millions of users have brought crypto using MoonPay before and so have their payment information and kyc details pre-saved, resulting in the highest possible conversions.
**Easy access to cryptocurrencies**: MoonPay makes it easy for users to buy cryptocurrencies using traditional payment methods like credit cards, bank transfers, and mobile money. This can help increase the accessibility and usability of Web3 applications.
**Broad range of supported cryptocurrencies**: MoonPay supports a wide range of cryptocurrencies, which means that users of your Web3 app will have more choices and flexibility.
**Broad range of supported payment methods**: MoonPay supports a wide range of payment methods on `Buy` and `Sell` : credit and debit cards, EUR, GBP, USD bank transfers, PayPal, Venmo, Revolut as well as Apple & Google Pay so your users can complete their transactions in any way they want.
**Global coverage**: MoonPay operates in many countries worldwide, making it a good choice for applications with a global user base.
**Security and compliance**: MoonPay adheres to high standards of security and regulatory compliance. This can give users of your Web3 application peace of mind when it comes to financial transactions.
**Easy integration**: MoonPay provides a well-documented [API](/api-reference/widget) and various SDKs, making it straightforward for developers to integrate their services into Web3 applications.
**Support for NFTs**: MoonPay has been expanding its services to support NFT marketplaces. This opens up even more possibilities for developers building in the Web3 space.
***
[Integration examples](/widget/integration-examples)
# Widget theming
Source: https://dev.moonpay.com/widget/customize-the-widgets-appearance
Enhance your website or applications user experience by tailoring the MoonPay widget's appearance to align with your brand. These personalization options are referred to as a .
## Building your theme
Your website or application can feature a unique theme that includes a dedicated theme.
You can build your theme using [MoonPay dashboard theme builder](https://dashboard.moonpay.com/theme). Themes built on the MoonPay dashboard are tied to your API key and will show up wherever you use your API key (e.g. widget, transaction tracker).
## Implementing your theme
To apply your custom theme, simply set your newly created theme to the default and save the changes.
This will ensure that the widget or transaction tracker is initialized with the corresponding theme whenever you use your MoonPay API key.
## Examples
## Available personalizations
* **Brand logos**. Please contact your MoonPay team and include:
* 1 rectangular logo for transactional emails
* **Color choices** accommodating both light and dark preferences.
* Widget Background Color
* Primary Button Color
* **Custom Loader** - for a simple approach to customizing the loader that your MoonPay widget displays, you can use the dashboard:
1. Go to the B2B dashboard
2. Navigate to "Theme" → "Loader"
3. Choose your preferred loader type (MoonPay default or generic spinner)
4. Select custom colors to match your brand
5. Save changes
## Usage of `theme`
You can manually choose light or dark modes using the`theme` widget parameter. e.g. `theme=dark` or `theme=light`
# Integration design guide
Source: https://dev.moonpay.com/widget/design-your-integration
Build your integration with the MoonPay Standard—proven insights from top-performing partner integrations that drive revenue and usability.
## Go-live requirements
To receive your production API keys, the following integration requirements
must be met.
* **Multiple entry points** — Buy buttons in home screen, main app navigation, and individual cryptocurrency screens. See [Buy button placement](#buy-button-placement).
* **Amount input** — Pre-fill fiat/crypto amount using `currencyCode` with `baseCurrencyAmount` or `quoteCurrencyAmount`; show minimum and maximum buy limits. See [Amount input screen](#amount-input-screen) and [Minimum and maximum buy limits](#minimum-and-maximum-buy-limits).
* **Payment method selection screen** — Show all supported payment methods, payment method logos, estimated transaction completion times, and default to Apple Pay on iOS. See [Payment method selection](#payment-method-selection) and [Supported payment methods](/api-reference/widget).
* **Provider selection screen** — Show [`ID verified` badges](#id-verified-and-previously-used-badges) for returning users, quotes from `/buy_quote` using `paymentMethod`, and estimated completion times. See [Provider selection](#provider-selection).
* **Pre-fill the customer's email address** — Pre-fill via the `email` parameter to skip the widget login screen. See [Pre-fill the customer's email address](#pre-fill-the-customers-email-address).
* **Pre-fill the customer's wallet address(es)** — Pass `walletAddress` or `walletAddresses` (with `currencyCode` or `defaultCurrencyCode` when using the singular form), and [sign URLs with your secret key](/widget/on-ramp-enhance-security-using-signed-urls). See [Pre-fill the customer's wallet addresses in the widget](#pre-fill-the-customers-wallet-address).
* **Transaction tracking** — Display a toast after the user completes their transaction with a link to the MoonPay transaction tracker, or to your own history page. See [Track the order status](#track-the-order-status).
* **Mobile app configuration** — Use a fullscreen in-app browser without a nav bar, allow KYC, and allow pop-ups at the browser level. See [Mobile integrations](#mobile-integrations).
## User journey demo
Explore the demo below for an end-to-end showcase of a well-designed on-ramp integration with a wallet partner.
## Implementation details
### Buy button placement
All partner apps must show buy buttons in the following:
* [ ] Home screen
* [ ] Main app navigation
* [ ] Cryptocurrency-specific screens
Highly visible Buy buttons in these areas reduces the number of clicks for users to transact and improves the user experience. You should also Buy buttons wherever users expect to be able to transact, like their wallet, NFT purchase sections, trading screens, and other screens specific to your app.
**Impact**: A minimum 2x increase in transaction volume based on data from a basket of partners including Uniswap.
#### Currency selector
When a user clicks on the Buy button, it should be easy to find crypto to purchase:
* List all cryptocurrencies available for purchase through your MoonPay integration
* Include a search bar and filters for ease of use
* Show the currency icon, abbreviation, full name, and network of each token so users can easily browse the list. Cryptocurrency and Fiat asset images can be found by accessing our [v3/currencies API](/api-reference/widget/getcurrencies).
### Amount input screen
A dedicated screen in your app for the user to select the amount, crypto, and fiat for their transaction provides a more seamless experience with the MoonPay widget.
For partner apps that have an amount input screen, use the following design elements for ease of use:
* When opening the MoonPay widget, pass the widget parameters`currencyCode` with`baseCurrencyAmount` or `quoteCurrencyAmount`to pre-fill the fiat or crypto amount.
* Allow users to change which crypto they're purchasing on this screen without leaving the flow
* [Validate minimum and maximum buy amounts](/widget/design-your-integration#minimum-and-maximum-buy-limits) against our Limits endpoint
### Minimum and maximum buy limits
For partner apps that have an amount input screen, ensure a seamless user experience between your app and the MoonPay widget by checking entered amounts against our [GET v3/currencies/:currencyCode/limits](/api-reference/widget/getcurrencylimits) endpoint. Please note that the quote provided in this endpoint represents a platform-wide floor value, not a user-specific value. Widget errors caused by invalid amounts will require the user to re-enter the amount to buy, which adds another step and impacts the user experience.
**Impact**
* Partners that offer transactions over \$10K see a 45% higher median for completed transaction volume.
* Partners also see 3% of their total completed volume from transactions over \$10K.
### Payment method selection
For partner apps with a payment method selection screen:
* Show [all payment methods supported by MoonPay](https://support.moonpay.com/customers/docs/all-supported-payment-methods), depending on the user's region and currency
* All available payment methods per transaction type, region, and fiat currency can be surfaced using the [`/payment_method_config` endpoint](/api-reference/widget)
* Show payment method details that are returned in the `/payment_method_config` response
* Use payment method logos
* Show estimated transaction completion times
* Default to Apple Pay on iOS, as Apple Pay typically has higher transaction success rates
* Pre-fill the `?paymentMethod` widget param with the selected payment method
A payment method selection screen in your app lets your customers easily choose how they want to transact while also getting more accurate quotes. If your app has a payment method selector, it's highly recommended to use the [`/payment_method_config` endpoint](/api-reference/widget) to surface all available payment methods. Otherwise, you'll need to periodically update your app as we release new payment methods:
* Update `?paymentMethod` when calling the `/buy_quote` endpoint and showing the widget
* Add the new payment method as an option in your app, including the logo, estimated transaction completion times
* Any other payment method-specific requirements
### Provider selection
For partner apps that offer ramps providers other than MoonPay, display key context on a provider selection screen, including:
* `ID verified` [badge for users who have completed KYC](/widget/moonpay-conversion-badges)
* Quotes from our[`/buy_quote` endpoint](/api-reference/widget) using `paymentMethod`
* Supported payment methods and their logos
### ID verified and previously used badges
`ID verified` and `Previously used` badges ensure a frictionless experience for returning users by indicating that their KYC has been completed. Our data show that returning users can be less price-sensitive than new users, preferring a more convenient checkout experience with fewer steps. This is crucial even if MoonPay is your exclusive provider, as displaying a previously used badge helps remind and reassure users.
Call our [GET /v3/customers/badges endpoint](/widget/moonpay-conversion-badges) with the customer's wallet address and we will return the customer's KYC status which you can use to show the `ID verified` badge.
**Impact**
* User retention rates are 70% higher when they know they've transacted before.
* 30% of wallet address that get passed to our Badges endpoint are already KYC'd.
### Payment method logos
Build customer trust by displaying the supported payment and payout method logos for your integration. For customers new to crypto, these familiar payment methods indicate a safe checkout process.
[Download these logos as a ZIP file](https://drive.google.com/uc?export=download\&id=1Z26OICqzkzDusYpNUyQNcJm__HOWsGNZ) and review the supported payment / payout methods below:
* [On-ramp payment methods](https://support.moonpay.com/hc/en-gb/articles/360017624078-What-are-your-supported-payment-methods-)
* [Off-ramp payout methods](https://support.moonpay.com/hc/en-gb/articles/360013743077-Which-bank-accounts-are-supported-for-withdrawals-)
* [Checkout payment methods](https://support.moonpay.com/hc/en-gb/articles/4412759522705)
### Loading screen
Use logos to guide the user when redirecting the user to MoonPay. Before opening the MoonPay widget, show a loading screen with both your logo and MoonPay’s logo to build user trust and set expectations about the user journey.
Additionally, you can use the MoonPay dashboard to create a **custom loader** for your widget:
* Go to the B2B dashboard
* Navigate to "Theme" → "Loader"
* Choose your preferred loader type (MoonPay default or generic spinner)
* Select custom colors to match your brand
* Save changes
### Skip the amount screen
By default, the first screen in the MoonPay widget asks the user to confirm the amount of crypto to purchase. If your app has an amount input screen, you can skip the amount screen in the MoonPay widget for a more seamless end-to-end customer journey and increase conversions by 6%.
Pass the following [widget parameters](/widget/ramps-sdk-buy-params):
* `currencyCode`
* `baseCurrencyCode`
* `baseCurrencyAmount`
* `walletAddress`
* `signature`
**Impact**: Skipping the initial amount screen increases conversions by 6%.
### Login options
#### Pre-fill the customer's email address
Partner apps that know the user's email address must pre-fill the customer's email address by passing the [`email` widget parameter](/widget/ramps-sdk-buy-params), so the customer won't be prompted to enter one. The widget will skip to the MFA code screen.
Returning customers that are logged in are 5% more likely to complete a transaction than returning customers that are not logged in. By pre-filling the customer's email address, you can capture some of this shortfall and increase transaction volume.
**Impact**
* Returning customers that are logged in are 5% more likely to complete a transaction.
* For new users who haven't transacted before, this is even higher at 25% more likely to complete their first transaction.
#### Pre-fill the customer's wallet address
Partner apps that know the user's wallet address(es) must use the walletAddress or walletAddresses parameters to skip the wallet address screen in the buy flow.
**Impact**
* Pre-selecting the currency, transaction amounts and wallet address to skip the initial screen of the widget increases conversions by 6%.
* Users who have to manually enter the wallet address results in 5% reduction in overall transactions created.
When using the walletAddress parameter, you must also:
* Pass currencyCode or baseCurrencyCode and
* [Sign URLs server-side](/widget/on-ramp-enhance-security-using-signed-urls)
You can alternatively use the walletAddresses parameter to pass multiple wallet addresses with their corresponding chains. The user will be able to choose from a filtered list of cryptocurrencies limited to those you pass in the parameter, and the wallet address screen will also be skipped.
#### Pre-select the payment method
Use the `paymentMethod` parameter to pre-select the user's payment method and match what they chose in the partner app.
### Track the order status
All partner apps must provide some kind of transaction tracking, whether a toast message that links to the MoonPay transaction tracker or your app's own transaction history screen.
After a user completes the buy flow in the MoonPay widget, it's crucial for them to be able to monitor their order's progress and understand its status. Increased transparency results in greater user trust and returning users who are 13% more likely to return and complete another transaction.
**Impact:** Increased transparency results in greater user trust and returning users who are 13% more likely to complete another transaction.
MoonPay facilitates this through the use of webhooks and APIs. Webhooks provide real-time order updates to a specified endpoint of your choice, while the API allows for on-demand retrieval of the latest order status.
Both our [webhooks](/api-reference/widget/webhooks/buy) and [API](/api-reference/widget/getbuytransactions) offer consistent data, giving you the flexibility to employ either or both methods as per your requirements.
1. **Transaction tracking toast message**: After a user completes a transaction, show a toast message that persists until the crypto has been received in the user's wallet. When clicked, either the MoonPay transaction tracker or your app's transaction history page should open.
* Partners not able to use webhooks for this toast message should implement a transaction history screen and use API calls to retrieve transaction data on-demand.
2. **Use the MoonPay transaction tracker**: We return the transaction tracker URL in our webhooks and API in the `data.returnURL` object. **Example tracker URL**: `https://buy.moonpay.com/transaction_receipt?transactionId=45751523-c3e2-47a5-ad0f-e5cb89e94037`. You can optionally pass the `apiKey` parameter to enable your custom theme on the tracker.
3. **Use your own transaction tracker**: You can optionally create your own transaction tracking screen that shows transaction data from our API and webhooks. We recommend that partners use the `redirectURL` parameter to route users to your destination URL (HTTPS or Universal/App Link) instead of the default MoonPay order status screen. Custom-scheme links (e.g., myapp\://…) are not supported. To enable automatic redirection for users, please contact your MoonPay representative.
#### API and webhook objects for transaction tracking
Additional examples and details for all parameters can be found in our transaction [API documentation](/api-reference/widget/getbuytransaction) under the *200 Responses* section.
| Object | Description | Notes |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
| `cryptoTransactionId` | On-chain transaction hash that can be searched on the corresponding blockchain explorer | |
| `status` and `failureReason` | Order status and failure reason. Failure reason will be set only if the transaction status is `failed`. | [Possible values](/api-reference/widget) |
| `stages` and `stages.failureReason` | Order stage and failure reason. Failure reason will be set only if the stage is `failed`. | [Possible values](/api-reference/widget) |
| `id` | Unique MoonPay transaction ID that can be added to the MoonPay transaction receipt URL | |
| `externalTransactionId` | Your transaction identifier that was passed in the `externalTransactionId` widget parameter. This identifier will be present whenever MoonPay sends you transaction data. | |
| `createdAt` | Timestamp of when the transaction was created | |
| `currency.name` and `currency.code` | Cryptocurrency name and code, e.g. Ethereum and ETH | |
## Mobile integrations
All partner mobile apps must follow these guidelines for setting up in-app browsers.
### iOS in-app browser setup
Use a full screen in-app browser and remove the navigation bar to provide a more native user experience.
```typescript React Native Example wrap theme={null}
import { WebView } from "react-native-webview";
const YourMoonpayWidgetScreen = ({
route: {
params: { moonpayWidgetUrl },
},
}) => ;
```
This enables the widget to slide up from the bottom of the screen.
```typescript React Native example wrap theme={null}
import { createNativeStackNavigator } from "@react-navigation/native-stack";
const Stack = createNativeStackNavigator();
;
```
* Allow pop-ups at the in-app browser level so the customer can use PayPal, Venmo and Revolut without seeing an error
* Follow the [*App Requirements for KYC*](/widget/design-your-integration#app-requirements-for-kyc) section below so the customer can upload their KYC documents and complete a selfie check.
### Android in-app browser setup
On Android we recommend loading the MoonPay widget within the `react-native-inappbrowser-reborn` as it supports Google Pay out of the box.
```typescript React Native example theme={null}
import InAppBrowser from "react-native-inappbrowser-reborn";
export const openBrowser = async ({ link }) => {
try {
InAppBrowser.close();
const browserResult = await InAppBrowser.open(link, {
forceCloseOnRedirection: false,
showInRecents: true,
animated: true,
});
// Automatically closes the browser if there is an issue with loading the widget.
if (browserResult?.type === "dismiss") {
InAppBrowser.close();
}
return browserResult;
} catch (e) {
// handle error
}
};
```
### App requirements for KYC
When not using a MoonPay SDK, make sure that the following requirements are
met so that users can successfully complete KYC (Know Your Customer) steps.
These requirements make sure that customers can complete each KYC step, including uploading documents and doing a selfie check. Failing to follow these steps will cause new customers to drop off, as they won't be able to finish KYC or make a purchase.
#### General app requirements for all implementations
All partner apps should ensure the following:
* `Feature-Policy` header for your webpage / frame or any other container has no restrictions for initializing camera like value `camera 'none'`.
* `Permissions-Policy` header doesn't restrict access to a camera and microphone (for some cases) and if allow is set check for `"camera; microphone"` values.
* When using [iOS WKWebview](https://developer.apple.com/documentation/webkit/wkwebviewconfiguration/1614793-allowsinlinemediaplayback) you may need to set `allowsInlineMediaPlayback` to `true` in the `WKWebViewConfiguration` used in your app. This adjustment ensures that media content, like a camera feed, can be displayed properly within the web view, rather than forcing full-screen playback.
* Your website is being run on a secure `https` connection.
#### WebView requirements
Partner apps that use `WebView` should ensure the following:
* The web view is able to access device local storage and initialize camera (for older iOS versions, the camera can be accessed only from Safari browser or WebView with `SFSafariViewController`)
* HTML5 video playback is allowed (`