Guide

React Native · Solana / USDC

WebView wiring for cash-in and cash-out — postMessage bridge and SPL USDC signing for cash-out.

45–90 minReact NativeUSDC / Solana

MoneyGram Ramps — React Native Integration Guide (Solana / USDC)

Version 1.0 · Solana + USDC · May 2026

Overview

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 set transaction.type: 'on-ramp' in RAMPS_CONFIG. Do not append a second mode.
  • View mode. Send RAMPS_CONFIG with mode: 'view' and transactionId. The sample below stores that id in a viewTransactionId prop. On the web SDK, pass viewTransaction: { id } to createRamps.

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:

SandboxProduction
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

SandboxProduction
Session API (server-side)https://playground.xramps.moneygram.com/api/v1/sessionsProvided by MoneyGram
apiBaseUrl (devConfig)https://playground.xramps.moneygram.com/apiProvided by MoneyGram
Widget URLReturned by session APIReturned by session API
Public key prefixramps_pk_sbox_...ramps_pk_prod_...
Secret key prefixramps_sk_sbox_...ramps_sk_prod_...
USDC mint (mainnet)EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1vSame
USDC mint (devnet)4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU—
Real moneyNoYes
Reference numbers (cash-out)Issued but not redeemableRedeemable at MoneyGram locations
Confirmation codes (cash-in)Issued for sandbox testingRequired at the counter

Cash-out (off-ramp)Cash-in (on-ramp)
Widget URL?mode=off-ramp (default)?mode=on-ramp
RAMPS_CONFIGtransaction.type: 'off-ramp'transaction.type: 'on-ramp'
User actionSend USDC on SolanaDeposit cash at selected MoneyGram location
Your app handlesRAMPS_SIGN_TRANSACTIONNo signing in the standard flow
CompletionreferenceNumber for pickupCounter 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.).

TypeScript
// 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:

JSON
{  "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.


Bash
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):

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.


Step 3 — Types

TypeScript
// ─── 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).

TypeScript
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:

ScenarioApproximate 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 balanceSponsor the fee
Best forCrypto-native users who hold SOLMainstream / non-crypto users
User needs SOL?YesNo
Backend change required?NoYes
ComplexityLowMedium

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:

TypeScript
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:

  1. App builds the unsigned transaction (transfer instruction only).
  2. App POSTs the serialised transaction to your backend.
  3. Backend signs as fee payer with its funded keypair and returns the partially signed transaction.
  4. App has the user's wallet sign the transfer instruction.
  5. App broadcasts the fully signed transaction and sends the txHash via RAMPS_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.


TSX
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' },})

Usage examples

TSX
// 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.

TypeScript
// 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

TSX
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

KYC field formats

FieldFormatNotes
firstName, lastNameNon-empty stringRequired if sending customer
middleName, secondLastNameStringOptional
dateOfBirthYYYY-MM-DDWidget-enforced
emailValid emailOptional
phoneNational number, digits onlyNo country prefix, e.g. 2145550100
phoneCountryDialCodeDial code with a leading +, e.g. +33Optional. Defaults to the pick-up country dial code when omitted. Send it when the customer phone country differs from the pick-up country
addressLine1, cityNon-empty string
postalCodeStringOptional
countryCodeISO 3166-1 alpha-3 e.g. USADefaults to USA if omitted
countrySubdivisionCodeISO 3166-2 e.g. US-TXRequired for USA/MEX/CAN senders
birthCountryCode, citizenshipCountryCodeISO 3166-1 alpha-3Optional
idTypePAS / DRV / STA / GOVPassport / Driver's License / State ID / Gov ID
idNumberNon-empty string
idIssueCountryISO 3166-1 alpha-3
idCountrySubdivisionCodeISO 3166-2 e.g. US-TXRequired for DRV/STA issued in USA or Canada

Required props (both platforms)

TSX
<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

XML
<!-- 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:

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


UI best practices

The widget manages its own internal UI. Your app is responsible for the states that wrap it:

StateWhenWhat to show
LoadingSession fetch + WebView initial loadActivity indicator overlaid on the WebView; dismiss on onLoadEnd
SigningCash-out only: between RAMPS_SIGN_TRANSACTION and RAMPS_SIGN_SUCCESS / RAMPS_SIGN_ERRORBlocking overlay ("Signing transaction…")
CompletionAfter RAMPS_TRANSACTION_COMPLETEDo nothing — the widget shows its own completion screen. Cash-out: reference number. Cash-in: counter confirmation code. Only close when RAMPS_CLOSE fires
ErrorSession fetch failure or RAMPS_SIGN_ERRORDismissable 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.


Sandbox gotchas

SymptomRoot causeFix
IP-50000 / code 999Agent ID not provisioned for Solana on MoneyGram's backendContact MoneyGram — nothing to fix in code
Widget blank / frozenRAMPS_CONFIG sent before RAMPS_READY firedWait for RAMPS_READY before sending RAMPS_CONFIG
Unexpected API calls to localhostapiBaseUrl missing from devConfigAlways include apiBaseUrl
Placeholder deposit addressMoneyGram's Solana deposit wallet not provisioned for your agentAdd sandbox stub detection gated on __DEV__ (see Step 5 above)
mockMode: true failuresMock mode bypasses real API flowsAlways use mockMode: false
Amount not pre-filled visuallydestinationCountry missing alongside amountInclude both in transaction
Messages not received by widgetjavaScriptEnabled is off in WebViewSet javaScriptEnabled={true}
injectJavaScript silently failsMissing true; at end of injected scriptAlways terminate injected script with true;
History records overwrittenDB key collision when sandbox reuses transaction IDsUse composite key ${id}-${createdAt}
502 entering an amount / errors during cash-inPicked a real-world MoneyGram location instead of one of the fixed sandbox test locationsCash-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:
CountryAddressLocation Name
USA2600 RICE CREEK RD, NEW BRIGHTON, MN 55112-5344CUB FOODS NEW BRIGHTON
USA1655 NEW YORK AVE, ARLINGTON, TX 76010-4766ELRODS COST PLUS - #7 - ARLINGTON
Canada407 LAURIER AVE W, OTTAWA, ON K1R 7Y7SPOT PLUS - CANADA POST
United Kingdom48 Elm Row, EDINBURGH, EH7 4AHBM GLOBALX LIMITED - #UK161
Ireland15 AUNGIER STREET, RATHMINES WEST, DUBLIN, CO DUBLIN D02 DD73WIRED - CASH AND DEBIT
HungaryDOBO KORUT 8, KECSKEMET, BACS-KISKUN 6000CORNER TRADE KFT.
PolandAl. Jerozlimskie 89 lok. 152 B C D, WARSZAWA, MAZOWIECKIE 02-001LITTLE INDIA SHOP - SKLEP LITTLE INDIA
ArgentinaAVENIDA CORDOBA 947, RETIRO, BUENOS AIRES, CAPITAL FEDERAL 1054LATIN EXPRESS FINANCIAL SERVICES ARGENTI
ArgentinaGENERAL MARIANO ACHA 4092, BUENOS AIRES, CAPITAL FEDERAL 1430SAAVEDRA COBRO EXPRESS - #LEAR1-1476

Credentials & config

  • Switch to production pk and sk
  • Update server endpoint to production MoneyGram credentials
  • Update apiBaseUrl in devConfig and SESSION_URL to production endpoints from MoneyGram
  • Update WIDGET_ORIGIN constant to the production widget domain
  • Switch USDC_MINT from 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 to address in your first RAMPS_SIGN_TRANSACTION event
  • Cash-in: Test rampMode="on-ramp" end-to-end — location picker, fraud warning, counter confirmation code in RAMPS_TRANSACTION_COMPLETE
  • Confirm the __DEV__ sandbox bypass is compiled out — run a release build and verify RAMPS_SIGN_TRANSACTION does not auto-succeed without a real on-chain transfer
  • Set sufficient in RAMPS_BALANCE_RESULT based 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 .env file committed to source control
  • Add rate limiting to your session endpoint — unauthenticated callers should not be able to exhaust your MoneyGram API quota
  • Exclude sessionToken values from crash reporters (Sentry denyUrls, Datadog beforeSend, etc.)
  • Confirm server-side amount limits match your product limits — the widget balance check is client-side only