Guide
React Native · Custodial cash-out · Solana / USDC
Custodial off-ramp in React Native: WebView bridge, customerIdentifier sessions, RAMPS_DEPOSIT_ADDRESS, and server-side hot-wallet transfers.
MoneyGram Ramps: Custodial Cash-Out (React Native / Solana / USDC)
Version 1.0 · Custodial partners · React Native · Cash-out only · August 2026
This guide is for custodial partners building a React Native app. You hold customer USDC in a shared omnibus or hot wallet; end users do not sign on-chain transactions themselves.
For non-custodial React Native (user wallet signing via RAMPS_SIGN_TRANSACTION), use the React Native · Solana (non-custodial) guide.
For the web iframe SDK (createRamps + onDepositAddress), see Web · Custodial cash-out.
Scope: Custodial cash-out (off-ramp) only. For custodial cash-in, use the Custodial cash-in guide. The WebView message contract is Embed the widget.
How it works:
- Your backend creates a session with
customerIdentifierfor the logged-in user. - Your app opens the MoneyGram widget in a WebView and sends
RAMPS_CONFIGwithwalletType: 'custodial'. - When the user commits, the widget sends
RAMPS_DEPOSIT_ADDRESS(notRAMPS_SIGN_TRANSACTION). - Your app calls your internal transfer API, then replies with
RAMPS_SIGN_SUCCESSand the on-chain txHash. - The widget polls until confirmed and returns a reference number for cash pickup.
The widget and API are the same as web custodial. Only the postMessage bridge differs.
These apply to both web and React Native (sessions, omnibus wallet, profiles):
| Topic | Details |
|---|---|
| Custody mode | Set at onboarding (walletCustody: 'custodial'), cannot switch per session |
customerIdentifier | Required in POST /v1/sessions: stable internal user ID (max 255 chars) |
| Omnibus wallet | custodianCashOutWallet: USDC source for off-ramp; set in partner portal |
| Profile APIs | Optional POST /v1/profiles, GET /v1/profiles/me with Bearer session token |
Full detail for all four lives in the Custodial integration overview, which is shared by every custodial integration. See Web · Custodial cash-out for backend transfer orchestration patterns.
Pass customerIdentifier from your authenticated user. Never trust a raw client-supplied ID without verification.
// Your mobile backend: POST /api/moneygram-sessionasync function createCustodialSession(userId: string) { const res = await fetch('https://playground.xramps.moneygram.com/api/v1/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': process.env.MONEYGRAM_SK!, // ramps_sk_sbox_*, server only }, body: JSON.stringify({ customerIdentifier: userId, walletAddress: process.env.PARTNER_OMNIBUS_WALLET, chain: 'solana', }), }) if (!res.ok) throw new Error(`Session failed: ${res.status}`) return res.json() as Promise<{ sessionToken: string sessionId: string walletType: 'custodial' widgetUrl: string }>}
Session TTL: 1 hour. Create a fresh session each time the user opens the widget.
Use the same WebView + postMessage pattern as the non-custodial React Native guide, with these custodial differences:
| Non-custodial | Custodial |
|---|---|
walletType: 'non-custodial' | walletType: 'custodial' (from session response) |
wallet.address = user's pubkey | wallet.address = your omnibus / hot wallet |
Handle RAMPS_SIGN_TRANSACTION | Handle RAMPS_DEPOSIT_ADDRESS |
| Sign with user wallet adapter | Call internal transfer API → return txHash |
RAMPS_CONFIG (custodial)
On RAMPS_READY, inject config with custodial wallet context:
post('RAMPS_CONFIG', { sessionToken: session.sessionToken, wallet: { address: PARTNER_OMNIBUS_WALLET, // custodianCashOutWallet chain: 'solana', asset: 'USDC', walletType: session.walletType, // 'custodial' }, devConfig: { mockMode: false, apiBaseUrl: process.env.EXPO_PUBLIC_RAMPS_API_URL ?? 'https://playground.xramps.moneygram.com/api', }, transaction: { type: 'off-ramp', asset: 'USDC', // optional: destinationCountry, amount, ... },})
Add this case to your handleMessage switch (alongside RAMPS_SIGN_TRANSACTION, which you do not use for custodial cash-out):
case 'RAMPS_DEPOSIT_ADDRESS': { const address = payload?.address as string const memo = payload?.memo as string | undefined const amount = payload?.amount as string const chain = payload?.chain as string // 'solana' const asset = payload?.asset as string // 'USDC' if (!address || !amount) { post('RAMPS_SIGN_ERROR', { error: 'Missing deposit address or amount' }) break } setSigning(true) try { // Prefer server-side transfer: debit internal balance + hot-wallet send const res = await fetch(INTERNAL_TRANSFER_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${appAuthToken}` }, body: JSON.stringify({ to: address, memo, amount, chain, asset }), }) if (!res.ok) throw new Error(`Transfer failed: ${res.status}`) const { txHash } = await res.json() post('RAMPS_SIGN_SUCCESS', { txHash, walletAddress: PARTNER_OMNIBUS_WALLET, }) } catch (err) { post('RAMPS_SIGN_ERROR', { error: (err as Error).message }) } finally { setSigning(false) } break}
Payload shape:
interface DepositAddressPayload { address: string memo?: string chain: 'solana' asset: 'USDC' amount?: string}
Critical: Always send RAMPS_SIGN_SUCCESS with a real txHash string. If you omit it, the widget stays on "Transfer in progress…".
Do not handle RAMPS_SIGN_TRANSACTION for custodial off-ramp: the widget emits RAMPS_DEPOSIT_ADDRESS instead.
Your backend (not the mobile app alone) should:
- Resolve the customer's active cash-out transaction and verify destination, asset, chain, and amount against the Ramps API (do not trust values forwarded from the WebView)
- Verify the user's internal USDC balance and debit the ledger atomically, keyed on the Ramps transaction id so retries do not pay out twice
- Send USDC from your hot wallet to MoneyGram's deposit
address(includememoif provided), reversing the debit if the send fails - Return
{ txHash }
See the backend example in Web · Custodial cash-out: Step 5.
Never embed hot-wallet private keys in the mobile binary. Route transfers through your API.
Widget (WebView) Your React Native app │ │ │──── RAMPS_READY ────────────────────▶│ │◀─── RAMPS_CONFIG (custodial) ────────│ │ │ │──── RAMPS_CHECK_BALANCE ────────────▶│ optional: omnibus USDC balance │◀─── RAMPS_BALANCE_RESULT ────────────│ │ │ │ [ user: KYC, quote, commit ] │ │ │ │──── RAMPS_DEPOSIT_ADDRESS ──────────▶│ { address, memo, amount, chain, asset } │ (not SIGN_TRANSACTION) │ → POST internal-transfer API │◀─── RAMPS_SIGN_SUCCESS ──────────────│ { txHash } │◀─── RAMPS_SIGN_ERROR ────────────────│ on failure │ │ │──── RAMPS_TRANSACTION_COMPLETE ─────▶│ save referenceNumber │──── RAMPS_CLOSE ────────────────────▶│
- Partner account is custodial;
custodianCashOutWalletconfigured and funded (sandbox USDC) - Session endpoint sends
customerIdentifierper user -
RAMPS_CONFIGuseswalletType: 'custodial'and omnibuswallet.address -
RAMPS_DEPOSIT_ADDRESShandler implemented; returns txHash viaRAMPS_SIGN_SUCCESS -
RAMPS_SIGN_TRANSACTIONnot used for custodial cash-out - Hot-wallet signing happens server-side (not in the app binary)
- Bundle ID registered with MoneyGram (the partner portal allowlist covers web origins only)
- Secret key (
ramps_sk_*) only on your backend
| Symptom | Likely cause |
|---|---|
| Widget stuck on transfer screen | No txHash in RAMPS_SIGN_SUCCESS |
| 400 on session | Missing customerIdentifier |
RAMPS_SIGN_TRANSACTION received | walletType not set to 'custodial' in RAMPS_CONFIG |
| Transfer fails on-chain | Wrong USDC mint, insufficient hot-wallet balance, missing memo |
| Guide | Use when |
|---|---|
| React Native · Solana (non-custodial) | User-controlled wallet signing |
| Web · Custodial cash-out | iframe SDK, shared custodial API details |
| Web · Solana (non-custodial) | Web non-custodial |
- Custodial cash-in (on-ramp): receiving USDC into your omnibus wallet and crediting internal balances. See Custodial cash-in.
- Webhooks: outbound transaction status notifications. Partner URL configuration, payload, signing, and delivery are documented. See Transaction status & webhooks.