Guide
React Native · Solana / USDC
WebView wiring for cash-in and cash-out — postMessage bridge and SPL USDC signing for cash-out.
MoneyGram Ramps — React Native Integration Guide (Solana / USDC)
Version 1.0 · Solana + USDC · May 2026
MoneyGram Ramps lets partners embed cash-out (off-ramp) and cash-in (on-ramp) flows for USDC on Solana inside mobile wallets.
Cash-out (off-ramp): Your app opens a WebView with the MoneyGram widget. When the user commits, the widget sends RAMPS_SIGN_TRANSACTION and your app signs and broadcasts a USDC transfer on Solana. The widget then issues a reference number for cash pickup.
Cash-in (on-ramp): The user selects a MoneyGram location in the widget, completes KYC, and receives a confirmation code for the counter. They deposit cash at the store and receive USDC in their Solana wallet. No RAMPS_SIGN_TRANSACTION in the standard cash-in path.
How this works end-to-end: React Native embeds the widget in a WebView using react-native-webview. Communication uses a postMessage-style bridge: your app injects JavaScript to send messages in, and receives messages via the onMessage prop.
Host: The message contract, including RAMPS_TRANSACTION_CREATED and the injectJavaScript rule, is Embed the widget. This guide covers the Solana non-custodial flow and the USDC transfer.
Transaction modes:
- New cash-out. The session URL is already
mode=off-ramp. - New cash-in. Replace the mode with
searchParams.set('mode', 'on-ramp')and settransaction.type: 'on-ramp'inRAMPS_CONFIG. Do not append a secondmode. - View mode. Send
RAMPS_CONFIGwithmode: 'view'andtransactionId. The sample below stores that id in aviewTransactionIdprop. On the web SDK, passviewTransaction: { id }tocreateRamps.
Supported asset: USDC on Solana only. Use the standard SPL USDC mint. Token 2022 / token-extensions USDC is not supported — see Step 4 for mint addresses.
Solana Pay: Ramps uses its own signing payload via RAMPS_SIGN_TRANSACTION. The Solana Pay QR/deeplink spec is not used here.
Reference implementation: A reference integration repository will be linked here once available. Contact your MoneyGram partner manager.
Integration time: 30–60 minutes for the front-end wiring, once prerequisites are in place (server-side session endpoint, Solana wallet adapter, and MoneyGram allowlisting)
Chain: Solana
Asset: USDC
Custodial partners: If you hold customer USDC in a shared omnibus wallet, use the React Native · Custodial cash-out guide or Web · Custodial cash-out guide. This guide covers non-custodial React Native integrations only.
When onboarded you receive two credentials:
| Sandbox | Production | |
|---|---|---|
Public key (pk) | ramps_pk_sbox_... | ramps_pk_prod_... |
Secret key (sk) | ramps_sk_sbox_... | ramps_sk_prod_... |
The secret key must only ever be used server-side — never bundled into the app binary.
Your app's domain or bundle ID must be allowlisted by MoneyGram before your keys work.
Quick reference
| Sandbox | Production | |
|---|---|---|
| Session API (server-side) | https://playground.xramps.moneygram.com/api/v1/sessions | Provided by MoneyGram |
apiBaseUrl (devConfig) | https://playground.xramps.moneygram.com/api | Provided by MoneyGram |
| Widget URL | Returned by session API | Returned by session API |
| Public key prefix | ramps_pk_sbox_... | ramps_pk_prod_... |
| Secret key prefix | ramps_sk_sbox_... | ramps_sk_prod_... |
| USDC mint (mainnet) | EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v | Same |
| USDC mint (devnet) | 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU | — |
| Real money | No | Yes |
| Reference numbers (cash-out) | Issued but not redeemable | Redeemable at MoneyGram locations |
| Confirmation codes (cash-in) | Issued for sandbox testing | Required at the counter |
| Cash-out (off-ramp) | Cash-in (on-ramp) | |
|---|---|---|
| Widget URL | ?mode=off-ramp (default) | ?mode=on-ramp |
| RAMPS_CONFIG | transaction.type: 'off-ramp' | transaction.type: 'on-ramp' |
| User action | Send USDC on Solana | Deposit cash at selected MoneyGram location |
| Your app handles | RAMPS_SIGN_TRANSACTION | No signing in the standard flow |
| Completion | referenceNumber for pickup | Counter confirmation code in referenceNumber / done screen |
When building the WebView URL from session.widgetUrl, replace mode with searchParams.set. Do not append a second mode parameter. The widget reads the first value.
The secret key must never live in the app binary. Create a server endpoint that calls MoneyGram's session API and returns only what the app needs.
The pattern below is plain Node.js — adapt the handler signature to your platform (Express, Next.js, Cloudflare Worker, AWS Lambda, etc.).
// Generic Node.js handler — adapt signature to your platformasync function createMoneyGramSession(req, res) { // CORS — restrict to your own origins const origin = req.headers.origin const allowedOrigins = ['https://yourapi.com'] if (allowedOrigins.includes(origin)) { res.setHeader('Access-Control-Allow-Origin', origin) res.setHeader('Vary', 'Origin') } res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS') res.setHeader('Access-Control-Allow-Headers', 'Content-Type') if (req.method === 'OPTIONS') return res.status(204).end() if (req.method !== 'POST') return res.status(405).end() const secretKey = process.env.MONEYGRAM_SK // ramps_sk_sbox_... — never in the app binary if (!secretKey) return res.status(500).json({ error: 'Secret key not configured' }) const { walletAddress } = req.body ?? {} const mgiRes = await fetch('https://playground.xramps.moneygram.com/api/v1/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': secretKey }, body: JSON.stringify({ walletAddress, // end-user Solana address chain: 'solana', }), }) const data = await mgiRes.json() if (!mgiRes.ok) return res.status(mgiRes.status).json(data) return res.status(200).json({ sessionToken: data.sessionToken, // JWT — used in RAMPS_CONFIG sessionId: data.sessionId, widgetUrl: data.widgetUrl, // widget URL — provided by MoneyGram, do not hardcode })}
Session response example:
{ "sessionToken": "eyJhbGci...", "sessionId": "b4641a16-2b96-4afe-acb6-b71682305d08", "widgetUrl": "https://playground.xramps.moneygram.com/widget.html?mode=off-ramp"}
Agent ID: MoneyGram assigns an agent ID when they onboard you. It is automatically embedded in the sessionToken by their API — you never set or pass it yourself. The widget reads it from the token claims. If you see IP-50000 errors, contact MoneyGram to provision your agent ID for Solana.
Session TTL: Session tokens expire after 1 hour. If the user leaves the widget open and returns later, the session will be expired and the widget will fail silently. Call fetchSession(walletAddress) fresh each time the MoneyGramWidget component mounts — do not cache the session token across app sessions. There is no refresh endpoint; create a new session instead.
npm install react-native-webview @solana/web3.js @solana/spl-token @react-native-async-storage/async-storage # iOS — install native podsnpx pod-install
Android (AndroidManifest.xml):
<uses-permission android:name="android.permission.INTERNET" />Library note: This guide uses @solana/web3.js v1, which is in long-term maintenance mode. The successor is @solana/kit (formerly @solana/web3.js v2). If your wallet already uses Kit, note that transaction construction and signing patterns differ — the sendUsdc example in Step 4 will need to be adapted to Kit's pipe / createTransaction API.
// ─── Status and error enums ────────────────────────────────────────────────── export const TransactionStatus = { QuotePending: 'quote_pending', KycPending: 'kyc_pending', AwaitingFunds: 'awaiting_funds', FundsReceived: 'funds_received', Completed: 'completed', RefundRequested: 'refund_requested', Refunded: 'refunded',} as constexport type TransactionStatus = typeof TransactionStatus[keyof typeof TransactionStatus] // ─── Customer / session types ───────────────────────────────────────────────── export interface CustomerProfile { // Personal firstName?: string middleName?: string lastName?: string secondLastName?: string dateOfBirth?: string // YYYY-MM-DD // Contact email?: string phone?: string // National number, no country prefix phoneCountryDialCode?: string // Dial code for phone, leading "+", e.g. "+33" // Address addressLine1?: string city?: string postalCode?: string countryCode?: string // ISO 3166-1 alpha-3 e.g. USA — defaults to USA countrySubdivisionCode?: string // ISO 3166-2 e.g. US-TX — required for USA/MEX/CAN senders // Nationality (optional) birthCountryCode?: string // ISO 3166-1 alpha-3 citizenshipCountryCode?: string // ISO 3166-1 alpha-3 // Identity idType?: 'PAS' | 'DRV' | 'STA' | 'GOV' idNumber?: string idIssueCountry?: string // ISO 3166-1 alpha-3 idCountrySubdivisionCode?: string // ISO 3166-2 — required for DRV/STA issued in USA or Canada} export interface MgiRecord { id: string // Ramps transactionId — needed to reopen in view mode referenceNumber: string // Shown to recipient at MoneyGram agent location amount: string // USDC amount sent on-chain asset: 'USDC' status: TransactionStatus createdAt: number // unix ms} interface Session { sessionToken: string sessionId: string widgetUrl: string}
Call this from your RAMPS_SIGN_TRANSACTION handler. Replace wallet and connection with your Solana wallet adapter and RPC connection.
The widget includes requiredNetwork, tokenAddress, and tokenDecimals on each sign payload, derived from your API key (_sbox_ → testnet/devnet, _prod_ → mainnet). Use those fields instead of hardcoding mints — pick RPC from requiredNetwork. Optionally echo blockchainNetwork in RAMPS_BALANCE_RESULT so the widget can block Continue when the wallet is on the wrong network (omit to fail open).
import { Connection, PublicKey, Transaction, ComputeBudgetProgram, type SendTransactionOptions, type TransactionSignature,} from '@solana/web3.js'import { getAssociatedTokenAddress, createAssociatedTokenAccountIdempotentInstruction, createTransferInstruction,} from '@solana/spl-token' // Use the standard SPL USDC mint only.// Token 2022 / token-extensions USDC is not supported by Ramps.const USDC_MINT_MAINNET = new PublicKey('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v')const USDC_MINT_DEVNET = new PublicKey('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU') async function sendUsdc( wallet: { publicKey: PublicKey; sendTransaction: (tx: Transaction, conn: Connection, opts?: SendTransactionOptions) => Promise<TransactionSignature> }, connection: Connection, to: string, amount: string, // exact string from RAMPS_SIGN_TRANSACTION — do not round mint: PublicKey = USDC_MINT_MAINNET,): Promise<string> { const toKey = new PublicKey(to) const fromATA = await getAssociatedTokenAddress(mint, wallet.publicKey) const toATA = await getAssociatedTokenAddress(mint, toKey, true) const tx = new Transaction() // Priority fee — prevents transactions being dropped during mainnet congestion. // Adjust microLamports based on current network conditions; 50_000 is a safe default. // Use Helius or Triton priority fee APIs for dynamic estimates in production. tx.add(ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 50_000 })) tx.add(ComputeBudgetProgram.setComputeUnitLimit({ units: 100_000 })) // Creates the recipient's token account if it doesn't exist yet. // The idempotent version is a no-op if the account already exists. tx.add(createAssociatedTokenAccountIdempotentInstruction( wallet.publicKey, toATA, toKey, mint, )) // USDC uses 6 decimal places. Avoid float arithmetic on financial amounts — // parseFloat can produce IEEE 754 drift (e.g. 0.1 + 0.2 !== 0.3). // Split on the decimal point and construct the BigInt from integer parts. const [whole, frac = ''] = amount.split('.') const lamports = BigInt(whole) * 1_000_000n + BigInt(frac.padEnd(6, '0').slice(0, 6)) tx.add(createTransferInstruction( fromATA, toATA, wallet.publicKey, lamports, )) // Fetch the blockhash as late as possible — after building the transaction but // before simulating. The blockhash expires in ~90 seconds (~150 slots). Fetching // early (e.g. before user confirmation UI) wastes that window on mobile. const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash() tx.recentBlockhash = blockhash tx.feePayer = wallet.publicKey // Simulate before signing — catches bad addresses and insufficient balance // before an irreversible on-chain error. const simulation = await connection.simulateTransaction(tx) if (simulation.value.err) { throw new Error(`Transaction simulation failed: ${JSON.stringify(simulation.value.err)}`) } const sig = await wallet.sendTransaction(tx, connection) // Confirm using blockhash expiry rather than polling on the signature alone. // This throws TransactionExpiredBlockheightExceededError if the transaction is // dropped before confirmation — surface this to the user instead of hanging. await connection.confirmTransaction( { signature: sig, blockhash, lastValidBlockHeight }, 'confirmed', ) return sig}
Every Solana transaction — including the USDC transfer above — requires a small amount of SOL to pay network fees. Typical costs:
| Scenario | Approximate cost |
|---|---|
| Simple SPL transfer (recipient ATA exists) | ~0.000005 SOL |
| Transfer + create recipient ATA | ~0.002 SOL |
| Transfer + ATA + priority fee | ~0.003 SOL |
RAMPS_CHECK_BALANCE asks for the user's USDC balance only. It does not check SOL. If the user has enough USDC but zero SOL, sendTransaction will fail — silently from the widget's perspective — after the user has completed KYC and accepted disclosures.
Choose the approach that fits your audience:
| Check SOL balance | Sponsor the fee | |
|---|---|---|
| Best for | Crypto-native users who hold SOL | Mainstream / non-crypto users |
| User needs SOL? | Yes | No |
| Backend change required? | No | Yes |
| Complexity | Low | Medium |
Option A — Check SOL balance in RAMPS_CHECK_BALANCE
Extend the balance handler to also verify the user has enough SOL before the flow proceeds:
case 'RAMPS_CHECK_BALANCE': { const [usdcBalance, solLamports] = await Promise.all([ getUsdcBalance(walletAddress, connection), // your existing helper connection.getBalance(new PublicKey(walletAddress)), ]) // ~0.003 SOL covers ATA creation + priority fees with headroom const SOL_FEE_BUFFER = 0.003 * 1e9 // lamports const hasSolForFees = solLamports >= SOL_FEE_BUFFER post('RAMPS_BALANCE_RESULT', { walletAddress, balance: usdcBalance, asset: 'USDC', sufficient: usdcBalance >= ((payload?.amount as number) ?? 0) && hasSolForFees, }) // If !hasSolForFees, show a warning in your UI before the user starts the flow break}
Option B — Sponsor the fee (partner fee payer)
On Solana, a transaction can have a separate fee payer keypair distinct from the transfer signer. Your backend holds a funded keypair that signs as fee payer; the user's wallet signs only the transfer instruction. Users need zero SOL.
High-level flow:
- App builds the unsigned transaction (transfer instruction only).
- App POSTs the serialised transaction to your backend.
- Backend signs as fee payer with its funded keypair and returns the partially signed transaction.
- App has the user's wallet sign the transfer instruction.
- App broadcasts the fully signed transaction and sends the
txHashviaRAMPS_SIGN_SUCCESS.
For a managed solution, Helius offers sponsored transactions. Octane is an open-source fee relayer that lets users pay fees in USDC, removing any SOL dependency entirely.
MoneyGram's protocol is unaffected by either option — RAMPS_SIGN_TRANSACTION and RAMPS_SIGN_SUCCESS remain the same regardless of who pays the fee.
import { useEffect, useRef, useState } from 'react'import { View, ActivityIndicator, StyleSheet, Text, TouchableOpacity, Linking,} from 'react-native'import AsyncStorage from '@react-native-async-storage/async-storage'import { WebView, type WebViewMessageEvent } from 'react-native-webview' interface Props { walletAddress: string balance: number // USDC balance onClose: () => void onTransactionSaved?: (record: MgiRecord) => void /** 'off-ramp' (default) or 'on-ramp' — sets widget URL mode and RAMPS_CONFIG transaction type */ rampMode?: 'off-ramp' | 'on-ramp' // Optional pre-fill sendAmount?: number // Amount to pre-fill destinationCountry?: string // ISO 3166-1 alpha-3 — cash-out only; skips country picker destinationSubdivision?: string // ISO 3166-2 e.g. US-CO customer?: CustomerProfile // View mode — show status of an existing transaction viewTransactionId?: string} // Set via your environment config — Expo: process.env.EXPO_PUBLIC_SESSION_URL// Bare RN: a constants file or react-native-config.const SESSION_URL = process.env.EXPO_PUBLIC_SESSION_URL ?? 'https://yourapi.com/api/moneygram-session' async function fetchSession(walletAddress: string): Promise<Session> { const res = await fetch(SESSION_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ walletAddress }), }) if (!res.ok) throw new Error(`Session creation failed: ${res.status}`) return res.json()} // Only forward non-empty values — never send empty strings to the widgetfunction buildCustomer(p: CustomerProfile): Record<string, string> { const c: Record<string, string> = {} const add = (k: string, v?: string) => { if (v?.trim()) c[k] = v.trim() } add('firstName', p.firstName) add('middleName', p.middleName) add('lastName', p.lastName) add('secondLastName', p.secondLastName) add('dateOfBirth', p.dateOfBirth) add('email', p.email) add('phone', p.phone) add('phoneCountryDialCode', p.phoneCountryDialCode) add('addressLine1', p.addressLine1) add('city', p.city) add('postalCode', p.postalCode) add('countryCode', p.countryCode ?? (p.addressLine1 ? 'USA' : undefined)) add('countrySubdivisionCode', p.countrySubdivisionCode) add('birthCountryCode', p.birthCountryCode) add('citizenshipCountryCode', p.citizenshipCountryCode) add('idType', p.idType) add('idNumber', p.idNumber) add('idIssueCountry', p.idIssueCountry) add('idCountrySubdivisionCode', p.idCountrySubdivisionCode) return c} // The MoneyGram widget domain — used to validate inbound postMessage events.const WIDGET_ORIGIN = 'https://playground.xramps.moneygram.com' // update for production export function MoneyGramWidget({ walletAddress, balance, onClose, onTransactionSaved, rampMode = 'off-ramp', sendAmount, destinationCountry, destinationSubdivision, customer, viewTransactionId,}: Props) { // Source your wallet and connection from your Solana wallet adapter. // If using @solana/wallet-adapter-react, call these hooks here: // const { publicKey, sendTransaction } = useWallet() // const { connection } = useConnection() // Or pass them as props if your wallet state lives higher in the tree. const wallet = /* your wallet adapter */ undefined as any const connection = /* your Connection */ undefined as any const webViewRef = useRef<WebView>(null) const sessionRef = useRef<Session | null>(null) const pendingAmountRef = useRef<string>('') const isViewMode = Boolean(viewTransactionId) const [widgetUrl, setWidgetUrl] = useState<string | null>(null) const [loading, setLoading] = useState(true) const [error, setError] = useState<string | null>(null) const [signing, setSigning] = useState(false) // ── 1. Create session and build widget URL ────────────────────────────────── useEffect(() => { fetchSession(walletAddress) .then((session) => { sessionRef.current = session const url = new URL(session.widgetUrl) url.searchParams.set('mode', rampMode) url.searchParams.set('_t', String(Date.now())) // cache-bust if (isViewMode && viewTransactionId) { url.searchParams.set('transactionId', viewTransactionId) } setWidgetUrl(url.toString()) }) .catch((err) => { setError(err.message) setLoading(false) }) }, [walletAddress, isViewMode, viewTransactionId]) // ── 2. Send a message INTO the WebView ───────────────────────────────────── // Use injectJavaScript to dispatch a MessageEvent into the page. // Payload is Base64-encoded before interpolation to avoid JS injection if the // payload contains characters that could break the surrounding script context. // The injected script MUST end with `true;` or it will be silently ignored. function post(type: string, payload?: Record<string, unknown>) { const json = JSON.stringify(payload ? { type, payload } : { type }) const encoded = btoa(unescape(encodeURIComponent(json))) webViewRef.current?.injectJavaScript(` (function() { var data = JSON.parse(decodeURIComponent(escape(atob('${encoded}')))); window.dispatchEvent(new MessageEvent('message', { data: data, origin: window.location.origin, })); })(); true; `) } // ── 3. Receive messages FROM the WebView ──────────────────────────────────── // Validate the source URL before processing any message. In a React Native // WebView the nativeEvent.url is always the URL of the loaded page — check it // matches the MoneyGram widget domain to reject spoofed messages. async function handleMessage(event: WebViewMessageEvent) { const sourceUrl = event.nativeEvent.url ?? '' if (!sourceUrl.startsWith(WIDGET_ORIGIN)) return let parsed: { type?: string; payload?: Record<string, unknown> } try { parsed = JSON.parse(event.nativeEvent.data) } catch { return } const { type, payload } = parsed switch (type) { // ── A. Widget ready — send full config ──────────────────────────────── case 'RAMPS_READY': { const session = sessionRef.current if (!session) break const builtCustomer = customer ? buildCustomer(customer) : undefined post('RAMPS_CONFIG', { sessionToken: session.sessionToken, wallet: { address: walletAddress, chain: 'solana', asset: 'USDC', walletType: 'non-custodial', }, devConfig: { mockMode: false, // always false — true bypasses real API flows apiBaseUrl: process.env.EXPO_PUBLIC_RAMPS_API_URL ?? 'https://playground.xramps.moneygram.com/api', }, theme: 'dark', // Optional: pre-fill KYC — widget skips the form for filled fields ...(builtCustomer && Object.keys(builtCustomer).length > 0 ? { customer: builtCustomer } : {}), // Optional: pre-fill amount + destination // amount alone stores the value; amount + destinationCountry skips the country picker ...(!isViewMode && sendAmount && sendAmount > 0 ? { transaction: { type: rampMode, asset: 'USDC', amount: sendAmount, ...(rampMode === 'off-ramp' && destinationCountry ? { destinationCountry } : {}), ...(rampMode === 'off-ramp' && destinationSubdivision ? { destinationSubdivision } : {}), }, } : !isViewMode && rampMode === 'on-ramp' ? { transaction: { type: 'on-ramp', asset: 'USDC' } } : {}), // View mode: show status of an existing transaction ...(isViewMode && viewTransactionId ? { mode: 'view', transactionId: viewTransactionId } : {}), }) break } // ── B. Widget asks for USDC balance ─────────────────────────────────── // Note: this only checks USDC. If the user has no SOL for fees, // sendTransaction will fail after KYC. See the "SOL transaction fees" // section above for how to guard against this. case 'RAMPS_CHECK_BALANCE': post('RAMPS_BALANCE_RESULT', { walletAddress, balance, asset: 'USDC', sufficient: balance >= ((payload?.amount as number) ?? 0), }) break // ── C. Sign and broadcast the on-chain transfer ─────────────────────── // Payload: { chain, to, amount, asset } // `chain` and `asset` are always present — use them if your app supports // multiple chains or assets. For Solana/USDC-only integrations they will // always be 'solana' and 'USDC' respectively. case 'RAMPS_SIGN_TRANSACTION': { const to = payload?.to as string const amount = payload?.amount as string const chain = payload?.chain as string // 'solana' const asset = payload?.asset as string // 'USDC' if (!to || !amount) { post('RAMPS_SIGN_ERROR', { error: 'Missing transaction parameters' }) break } // Capture the amount — RAMPS_TRANSACTION_COMPLETE doesn't always include it pendingAmountRef.current = amount // Sandbox-only: detect placeholder deposit address when the Solana wallet // isn't yet provisioned for your agent ID. Gate strictly on __DEV__ so this // code path cannot reach production — do not rely on manual removal. if (__DEV__) { const isPlaceholder = to.toLowerCase().includes('stub') || to.length < 32 if (isPlaceholder) { console.warn('[MoneyGram] Sandbox placeholder address — bypassing signing') post('RAMPS_SIGN_SUCCESS', { txHash: 'SANDBOX_' + Date.now(), walletAddress }) break } } setSigning(true) try { const txHash = await sendUsdc(wallet, connection, to, amount) post('RAMPS_SIGN_SUCCESS', { txHash, walletAddress }) } catch (err) { post('RAMPS_SIGN_ERROR', { error: (err as Error).message }) } finally { setSigning(false) } break } // ── D. Transaction complete — persist the record ────────────────────── // Payload fields: { id, type, status, chain, asset, walletAddress, // amount, destinationCountry, destinationCurrency, // referenceNumber, createdAt, updatedAt } case 'RAMPS_TRANSACTION_COMPLETE': { const p = (payload ?? {}) as Record<string, unknown> const record: MgiRecord = { id: String(p.id ?? `mg-${Date.now()}`), referenceNumber: String(p.referenceNumber ?? ''), amount: pendingAmountRef.current || '0', asset: 'USDC', status: (p.status as TransactionStatus) ?? 'completed', createdAt: Date.now(), } // Persist immediately — referenceNumber is what the recipient shows at the agent location const key = `mgi_tx_${record.id}_${record.createdAt}` await AsyncStorage.setItem(key, JSON.stringify(record)) // or SQLite / MMKV onTransactionSaved?.(record) // Do NOT close — let the widget show its own completion screen // Widget fires RAMPS_CLOSE when the user dismisses it break } // Widget fires RAMPS_SIGN_ERROR independently on its own timeout/failure. // Reset the signing overlay so the user is not permanently blocked. case 'RAMPS_SIGN_ERROR': setSigning(false) break case 'RAMPS_CLOSE': onClose() break // Open external URLs (T&Cs, KYC docs) in the device browser. // Restrict to https:// — reject javascript:, file:, intent:// etc. case 'RAMPS_OPEN_URL': { const url = String(payload?.url ?? '') if (url.startsWith('https://')) Linking.openURL(url) break } } } if (error) { return ( <View style={styles.overlay}> <Text style={styles.errorText}>Unable to load MoneyGram</Text> <Text style={styles.errorDetail}>{error}</Text> <TouchableOpacity onPress={onClose} style={styles.button}> <Text style={styles.buttonText}>Go back</Text> </TouchableOpacity> </View> ) } return ( <View style={StyleSheet.absoluteFillObject}> {widgetUrl && ( <WebView ref={webViewRef} source={{ uri: widgetUrl }} style={{ flex: 1 }} onMessage={handleMessage} onLoadEnd={() => setLoading(false)} javaScriptEnabled domStorageEnabled cacheEnabled={false} allowsInlineMediaPlayback mediaPlaybackRequiresUserAction={false} /> )} {loading && ( <View style={[StyleSheet.absoluteFillObject, styles.overlay]}> <ActivityIndicator size="large" color="#c9a84c" /> <Text style={styles.loadingText}>Loading MoneyGram…</Text> </View> )} {signing && ( <View style={[StyleSheet.absoluteFillObject, styles.overlay]}> <ActivityIndicator size="large" color="#c9a84c" /> <Text style={styles.loadingText}>Signing transaction…</Text> </View> )} </View> )} const styles = StyleSheet.create({ overlay: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#0d1220' }, loadingText: { color: '#9ca3af', marginTop: 12, fontSize: 14 }, errorText: { color: '#fff', fontSize: 16, fontWeight: '600', marginBottom: 8, textAlign: 'center' }, errorDetail: { color: '#9ca3af', fontSize: 13, textAlign: 'center', marginBottom: 20, paddingHorizontal: 24 }, button: { paddingHorizontal: 24, paddingVertical: 12, backgroundColor: '#1e293b', borderRadius: 12 }, buttonText: { color: '#fff', fontSize: 14, fontWeight: '600' },})
// New cash-out — no pre-fill<MoneyGramWidget walletAddress={solanaAddress} balance={usdcBalance} rampMode="off-ramp" onClose={() => setOpen(false)} onTransactionSaved={(record) => saveToStorage(record)}/> // New cash-in — user picks a MoneyGram location in the widget<MoneyGramWidget walletAddress={solanaAddress} balance={usdcBalance} rampMode="on-ramp" sendAmount={100} onClose={() => setOpen(false)} onTransactionSaved={(record) => saveToStorage(record)}/> // New cash-out with full pre-fill (skips country picker and KYC form)<MoneyGramWidget walletAddress={solanaAddress} balance={usdcBalance} rampMode="off-ramp" sendAmount={50} destinationCountry="USA" destinationSubdivision="US-CO" customer={{ firstName: 'Jane', lastName: 'Doe', dateOfBirth: '1990-01-15', phone: '2145550100', phoneCountryDialCode: '+1', addressLine1: '123 Main St', city: 'Dallas', postalCode: '75201', countryCode: 'USA', countrySubdivisionCode: 'US-TX', idType: 'DRV', idNumber: 'TX12345678', idIssueCountry: 'USA', idCountrySubdivisionCode: 'US-TX', }} onClose={() => setOpen(false)} onTransactionSaved={(record) => saveToStorage(record)}/> // View mode — show status of a stored transaction<MoneyGramWidget walletAddress={solanaAddress} balance={usdcBalance} viewTransactionId={storedRecord.id} onClose={() => setViewId(null)}/>
The message table, cash-in sequence, custodial RAMPS_DEPOSIT_ADDRESS, and RAMPS_TRANSACTION_CREATED live on Embed the widget. That page is the contract for Web and React Native.
For cash-in, replace the widget URL mode with searchParams.set('mode', 'on-ramp') and set transaction.type: 'on-ramp' in RAMPS_CONFIG. Do not append a second mode. There is no RAMPS_SIGN_TRANSACTION on that path.
Custodial cash-out uses RAMPS_DEPOSIT_ADDRESS instead of RAMPS_SIGN_TRANSACTION. See the React Native · Custodial cash-out guide for the Solana transfer.
Storing records
When RAMPS_TRANSACTION_COMPLETE fires, persist the record immediately. The referenceNumber is what the recipient presents at the MoneyGram agent location. Use a composite key (${id}-${createdAt}) to prevent overwrites if the sandbox reuses transaction IDs.
// AsyncStorage exampleconst key = `mgi_tx_${record.id}_${record.createdAt}`await AsyncStorage.setItem(key, JSON.stringify(record)) // Or SQLite / MMKV for better query support
History list with view mode
import { FlatList, Modal, TouchableOpacity, Text } from 'react-native' function HistoryList() { const [records, setRecords] = useState<MgiRecord[]>([]) const [viewId, setViewId] = useState<string | null>(null) useEffect(() => { loadAllRecords().then(setRecords) }, []) return ( <> <FlatList data={records.sort((a, b) => b.createdAt - a.createdAt)} keyExtractor={(r) => `${r.id}-${r.createdAt}`} renderItem={({ item: r }) => ( <TouchableOpacity onPress={() => setViewId(r.id)}> <Text>{r.amount} USDC → Cash Pickup</Text> <Text>Ref: {r.referenceNumber}</Text> <Text>View status →</Text> </TouchableOpacity> )} /> {viewId && ( <Modal visible animationType="slide"> <MoneyGramWidget viewTransactionId={viewId} walletAddress={solanaAddress} balance={usdcBalance} onClose={() => setViewId(null)} /> </Modal> )} </> )}
Open an existing transaction with RAMPS_CONFIG mode: 'view' and transactionId. The sample component accepts that id as viewTransactionId. The widget fetches the current status, shows pickup instructions and the status timeline, and handles refund requests inside the widget. No extra API calls from your side.
Transaction ID vs. reference number: View mode requires the Transaction ID (id from RAMPS_TRANSACTION_COMPLETE, e.g. tx_abc123), not the reference number / cash pickup code (the 8-digit MoneyGram code the customer uses at the counter). The reference number identifies the transaction to MoneyGram and the customer. The Transaction ID identifies it to this API. Store both when the transaction completes, and pass only the Transaction ID as transactionId.
Refunds: When a transaction is refund-eligible, the widget presents the option to the user. On completion, USDC is returned to the original Solana wallet address.
Status lifecycle:
quote_pending → kyc_pending → awaiting_funds → funds_received → completed
│
refund_requested → refunded
| Field | Format | Notes |
|---|---|---|
firstName, lastName | Non-empty string | Required if sending customer |
middleName, secondLastName | String | Optional |
dateOfBirth | YYYY-MM-DD | Widget-enforced |
email | Valid email | Optional |
phone | National number, digits only | No country prefix, e.g. 2145550100 |
phoneCountryDialCode | Dial code with a leading +, e.g. +33 | Optional. Defaults to the pick-up country dial code when omitted. Send it when the customer phone country differs from the pick-up country |
addressLine1, city | Non-empty string | |
postalCode | String | Optional |
countryCode | ISO 3166-1 alpha-3 e.g. USA | Defaults to USA if omitted |
countrySubdivisionCode | ISO 3166-2 e.g. US-TX | Required for USA/MEX/CAN senders |
birthCountryCode, citizenshipCountryCode | ISO 3166-1 alpha-3 | Optional |
idType | PAS / DRV / STA / GOV | Passport / Driver's License / State ID / Gov ID |
idNumber | Non-empty string | |
idIssueCountry | ISO 3166-1 alpha-3 | |
idCountrySubdivisionCode | ISO 3166-2 e.g. US-TX | Required for DRV/STA issued in USA or Canada |
Required props (both platforms)
<WebView ref={webViewRef} source={{ uri: widgetUrl }} style={{ flex: 1 }} onMessage={handleMessage} onLoadEnd={() => setLoading(false)} javaScriptEnabled // required — widget relies on JS domStorageEnabled // required — widget uses sessionStorage cacheEnabled={false} // important — prevents stale widget JS after deploys allowsInlineMediaPlayback // required for embedded media content mediaPlaybackRequiresUserAction={false}/>
cacheEnabled={false} is important. If the WebView serves a cached version of the widget, it may be out of sync with the current session token, causing silent failures or a blank screen after an Ramps deploy.
Android
<!-- AndroidManifest.xml --><uses-permission android:name="android.permission.INTERNET" /> <!-- Inside <application> — set to false in production --><application android:usesCleartextTraffic="false" ...>
If loading the widget over HTTP in development:
<application android:usesCleartextTraffic="true" ...>iOS
No additional Info.plist entries are required for HTTPS widget URLs. If you use a custom WKWebViewConfiguration (e.g. via a native module), ensure allowsInlineMediaPlayback is set to true — react-native-webview sets this automatically when you pass the allowsInlineMediaPlayback prop.
If the widget renders blank on iOS 16+, check that your app's NSAppTransportSecurity settings do not block the MoneyGram widget domain.
The widget manages its own internal UI. Your app is responsible for the states that wrap it:
| State | When | What to show |
|---|---|---|
| Loading | Session fetch + WebView initial load | Activity indicator overlaid on the WebView; dismiss on onLoadEnd |
| Signing | Cash-out only: between RAMPS_SIGN_TRANSACTION and RAMPS_SIGN_SUCCESS / RAMPS_SIGN_ERROR | Blocking overlay ("Signing transaction…") |
| Completion | After RAMPS_TRANSACTION_COMPLETE | Do nothing — the widget shows its own completion screen. Cash-out: reference number. Cash-in: counter confirmation code. Only close when RAMPS_CLOSE fires |
| Error | Session fetch failure or RAMPS_SIGN_ERROR | Dismissable error view with a "Go back" affordance — never leave a frozen spinner |
Keep loading and signing as separate boolean flags (as in Step 5). A session fetch error should show a dismissable error screen, not a spinner that never resolves.
Reference number / confirmation code: For cash-out, the referenceNumber from RAMPS_TRANSACTION_COMPLETE is what the recipient presents at the MoneyGram agent. For cash-in, the same field (or the widget done screen) carries the counter confirmation code. Store it immediately and display it prominently in your transaction history.
| Symptom | Root cause | Fix |
|---|---|---|
IP-50000 / code 999 | Agent ID not provisioned for Solana on MoneyGram's backend | Contact MoneyGram — nothing to fix in code |
| Widget blank / frozen | RAMPS_CONFIG sent before RAMPS_READY fired | Wait for RAMPS_READY before sending RAMPS_CONFIG |
| Unexpected API calls to localhost | apiBaseUrl missing from devConfig | Always include apiBaseUrl |
| Placeholder deposit address | MoneyGram's Solana deposit wallet not provisioned for your agent | Add sandbox stub detection gated on __DEV__ (see Step 5 above) |
mockMode: true failures | Mock mode bypasses real API flows | Always use mockMode: false |
| Amount not pre-filled visually | destinationCountry missing alongside amount | Include both in transaction |
| Messages not received by widget | javaScriptEnabled is off in WebView | Set javaScriptEnabled={true} |
injectJavaScript silently fails | Missing true; at end of injected script | Always terminate injected script with true; |
| History records overwritten | DB key collision when sandbox reuses transaction IDs | Use composite key ${id}-${createdAt} |
| 502 entering an amount / errors during cash-in | Picked a real-world MoneyGram location instead of one of the fixed sandbox test locations | Cash-in (deposit) testing only works against a fixed list of sandbox agent locations. The widget highlights these in playground; search may show other stores that will fail. Use one of the following: |
| Country | Address | Location Name |
|---|---|---|
| USA | 2600 RICE CREEK RD, NEW BRIGHTON, MN 55112-5344 | CUB FOODS NEW BRIGHTON |
| USA | 1655 NEW YORK AVE, ARLINGTON, TX 76010-4766 | ELRODS COST PLUS - #7 - ARLINGTON |
| Canada | 407 LAURIER AVE W, OTTAWA, ON K1R 7Y7 | SPOT PLUS - CANADA POST |
| United Kingdom | 48 Elm Row, EDINBURGH, EH7 4AH | BM GLOBALX LIMITED - #UK161 |
| Ireland | 15 AUNGIER STREET, RATHMINES WEST, DUBLIN, CO DUBLIN D02 DD73 | WIRED - CASH AND DEBIT |
| Hungary | DOBO KORUT 8, KECSKEMET, BACS-KISKUN 6000 | CORNER TRADE KFT. |
| Poland | Al. Jerozlimskie 89 lok. 152 B C D, WARSZAWA, MAZOWIECKIE 02-001 | LITTLE INDIA SHOP - SKLEP LITTLE INDIA |
| Argentina | AVENIDA CORDOBA 947, RETIRO, BUENOS AIRES, CAPITAL FEDERAL 1054 | LATIN EXPRESS FINANCIAL SERVICES ARGENTI |
| Argentina | GENERAL MARIANO ACHA 4092, BUENOS AIRES, CAPITAL FEDERAL 1430 | SAAVEDRA COBRO EXPRESS - #LEAR1-1476 |
Credentials & config
- Switch to production
pkandsk - Update server endpoint to production MoneyGram credentials
- Update
apiBaseUrlindevConfigandSESSION_URLto production endpoints from MoneyGram - Update
WIDGET_ORIGINconstant to the production widget domain - Switch
USDC_MINTfrom devnet to mainnet (EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v) - Register app bundle ID / domain with MoneyGram for allowlisting
Verification
- Cash-out: Confirm MoneyGram has provisioned the Solana deposit wallet for your agent ID — inspect the
toaddress in your firstRAMPS_SIGN_TRANSACTIONevent - Cash-in: Test
rampMode="on-ramp"end-to-end — location picker, fraud warning, counter confirmation code inRAMPS_TRANSACTION_COMPLETE - Confirm the
__DEV__sandbox bypass is compiled out — run a release build and verifyRAMPS_SIGN_TRANSACTIONdoes not auto-succeed without a real on-chain transfer - Set
sufficientinRAMPS_BALANCE_RESULTbased on actual USDC balance vs. requested amount (cash-out)
Security
- Store the secret key in a secrets manager (AWS Secrets Manager, HashiCorp Vault) — never in a
.envfile committed to source control - Add rate limiting to your session endpoint — unauthenticated callers should not be able to exhaust your MoneyGram API quota
- Exclude
sessionTokenvalues from crash reporters (SentrydenyUrls, DatadogbeforeSend, etc.) - Confirm server-side amount limits match your product limits — the widget balance check is client-side only